From 7293c672e28c51a4e4e8b7b43646e57b6155a7e7 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 15 Jul 2025 16:40:15 +0100 Subject: [PATCH] refactor: move music admin to /admin/music; keep /admin generic --- admin/{ => account}/accounthttp.go | 59 +- admin/auth/authhttp.go | 386 +++++++++++++ admin/components/credits/addcredit.html | 2 +- admin/components/credits/editcredits.html | 4 +- .../components/release/release-list-item.html | 6 +- admin/components/tracks/addtrack.html | 2 +- admin/components/tracks/edittracks.html | 4 +- admin/core/funcs.go | 56 ++ admin/http.go | 506 +----------------- admin/{ => logs}/logshttp.go | 19 +- admin/{ => music}/artisthttp.go | 15 +- admin/music/musichttp.go | 71 +++ admin/{ => music}/releasehttp.go | 29 +- admin/{ => music}/trackhttp.go | 15 +- admin/static/admin.css | 9 +- admin/static/index.css | 81 --- admin/static/index.js | 74 --- admin/static/music.css | 82 +++ admin/static/music.js | 74 +++ admin/templates.go | 125 ----- admin/templates/index.go | 13 + admin/templates/login.go | 42 ++ admin/templates/logs.go | 41 ++ admin/templates/music.go | 54 ++ admin/views/edit-artist.html | 2 +- admin/views/edit-release.html | 16 +- admin/views/index.html | 62 +-- admin/views/layout.html | 13 +- admin/views/music.html | 73 +++ controller/release.go | 50 +- public/style/index.css | 2 +- 31 files changed, 1081 insertions(+), 906 deletions(-) rename admin/{ => account}/accounthttp.go (89%) create mode 100644 admin/auth/authhttp.go create mode 100644 admin/core/funcs.go rename admin/{ => logs}/logshttp.go (88%) rename admin/{ => music}/artisthttp.go (88%) create mode 100644 admin/music/musichttp.go rename admin/{ => music}/releasehttp.go (91%) rename admin/{ => music}/trackhttp.go (88%) create mode 100644 admin/static/music.css create mode 100644 admin/static/music.js delete mode 100644 admin/templates.go create mode 100644 admin/templates/index.go create mode 100644 admin/templates/login.go create mode 100644 admin/templates/logs.go create mode 100644 admin/templates/music.go create mode 100644 admin/views/music.html diff --git a/admin/accounthttp.go b/admin/account/accounthttp.go similarity index 89% rename from admin/accounthttp.go rename to admin/account/accounthttp.go index 945a507..5601a2e 100644 --- a/admin/accounthttp.go +++ b/admin/account/accounthttp.go @@ -1,22 +1,25 @@ -package admin +package account import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) -func accountHandler(app *model.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() + mux.Handle("/", accountIndexHandler(app)) + mux.Handle("/totp-setup", totpSetupHandler(app)) mux.Handle("/totp-confirm", totpConfirmHandler(app)) mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) @@ -64,7 +67,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Message = sessionMessage session.Error = sessionError - err = accountTemplate.Execute(w, accountResponse{ + err = templates.AccountTemplate.Execute(w, accountResponse{ Session: session, TOTPs: totps, }) @@ -92,7 +95,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { currentPassword := r.Form.Get("current-password") if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { controller.SetSessionError(app.DB, session, "Incorrect password.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -102,7 +105,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -111,7 +114,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -119,7 +122,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } @@ -147,7 +150,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Incorrect password.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -155,7 +158,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -184,7 +187,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session }) + err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -221,7 +224,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) + err := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -235,7 +238,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) } - err = totpConfirmTemplate.Execute(w, totpConfirmData{ + err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: &totp, NameEscaped: url.PathEscape(totp.Name), @@ -277,7 +280,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } if totp == nil { @@ -296,7 +299,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) if code != confirmCodeOffset { session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } - err = totpConfirmTemplate.Execute(w, totpConfirmData{ + err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: totp, NameEscaped: url.PathEscape(totp.Name), @@ -314,7 +317,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -322,7 +325,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } @@ -345,7 +348,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } if totp == nil { @@ -357,7 +360,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -365,6 +368,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go new file mode 100644 index 0000000..aba4074 --- /dev/null +++ b/admin/auth/authhttp.go @@ -0,0 +1,386 @@ +package auth + +import ( + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" + "database/sql" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func RegisterAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type registerData struct { + Session *model.Session + } + + render := func() { + err := templates.RegisterTemplate.Execute(w, registerData{ Session: session }) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + + if r.Method == http.MethodGet { + render() + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure invite code exists in DB + invite, err := controller.GetInvite(app.DB, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(app.DB, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + controller.SetSessionError(app.DB, session, "Invalid invite code.") + render() + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: sql.NullString{ String: credentials.Email, Valid: true }, + AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, + } + err = controller.CreateAccount(app.DB, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + controller.SetSessionError(app.DB, session, "An account with that username already exists.") + render() + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) + + err = controller.DeleteInvite(app.DB, invite.Code) + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) + } + + // registration success! + controller.SetSessionAccount(app.DB, session, &account) + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) +} + +func LoginHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + + type loginData struct { + Session *model.Session + } + + render := func() { + err := templates.LoginTemplate.Execute(w, loginData{ Session: session }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + if r.Method == http.MethodGet { + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + render() + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + if !r.Form.Has("username") || !r.Form.Has("password") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + account, err := controller.GetAccountByUsername(app.DB, username) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() + return + } + if account == nil { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + render() + return + } + if account.Locked { + controller.SetSessionError(app.DB, session, "This account is locked.") + render() + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, account, r); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + } else { + controller.SetSessionError(app.DB, session, "Invalid username or password.") + } + render() + return + } + + totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + + if len(totps) > 0 { + err = controller.SetSessionAttemptAccount(app.DB, session, account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin/totp", http.StatusFound) + return + } + + // login success! + // TODO: log login activity to user + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) + + err = controller.SetSessionAccount(app.DB, session, account) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) +} + +func LoginTOTPHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + if session.AttemptAccount == nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + type loginTOTPData struct { + Session *model.Session + } + + render := func() { + err := templates.LoginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + if r.Method == http.MethodGet { + render() + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + r.ParseForm() + + if !r.Form.Has("totp") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + totpCode := r.FormValue("totp") + + if len(totpCode) != controller.TOTP_CODE_LENGTH { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + controller.SetSessionError(app.DB, session, "Invalid TOTP.") + render() + return + } + + totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + if totpMethod == nil { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") + controller.SetSessionAttemptAccount(app.DB, session, nil) + http.Redirect(w, r, "/admin", http.StatusFound) + } else { + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + } + render() + return + } + + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) + + err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + render() + return + } + err = controller.SetSessionAttemptAccount(app.DB, session, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err) + } + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + http.Redirect(w, r, "/admin", http.StatusFound) + }) +} + +func LogoutHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + session := r.Context().Value("session").(*model.Session) + err := controller.DeleteSession(app.DB, session.Token) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Expires: time.Now(), + Path: "/", + }) + + err = templates.LogoutTemplate.Execute(w, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { + locked, err := controller.IncrementAccountFails(app.DB, account.ID) + if err != nil { + fmt.Fprintf( + os.Stderr, + "WARN: Failed to increment login failures for \"%s\": %v\n", + account.Username, + err, + ) + app.Log.Warn( + log.TYPE_ACCOUNT, + "Failed to increment login failures for \"%s\"", + account.Username, + ) + } + if locked { + app.Log.Warn( + log.TYPE_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), + ) + } + return locked +} diff --git a/admin/components/credits/addcredit.html b/admin/components/credits/addcredit.html index c09a550..debc660 100644 --- a/admin/components/credits/addcredit.html +++ b/admin/components/credits/addcredit.html @@ -7,7 +7,7 @@ {{range $Artist := .Artists}}
  • diff --git a/admin/components/credits/editcredits.html b/admin/components/credits/editcredits.html index 94dc268..0c50fac 100644 --- a/admin/components/credits/editcredits.html +++ b/admin/components/credits/editcredits.html @@ -3,8 +3,8 @@

    Editing: Credits

    Add diff --git a/admin/components/release/release-list-item.html b/admin/components/release/release-list-item.html index 4b8f41e..142c26a 100644 --- a/admin/components/release/release-list-item.html +++ b/admin/components/release/release-list-item.html @@ -5,7 +5,7 @@

    - {{.Title}} + {{.Title}} {{.ReleaseDate.Year}} {{if not .Visible}}(hidden){{end}} @@ -13,9 +13,9 @@

    {{.PrintArtists true true}}

    {{.ReleaseType}} - ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    + ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    diff --git a/admin/components/tracks/addtrack.html b/admin/components/tracks/addtrack.html index a6dd433..f62f369 100644 --- a/admin/components/tracks/addtrack.html +++ b/admin/components/tracks/addtrack.html @@ -8,7 +8,7 @@
  • diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index d03f80a..3b8368d 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,8 +3,8 @@

    Editing: Tracks

    Add diff --git a/admin/core/funcs.go b/admin/core/funcs.go new file mode 100644 index 0000000..c471f8a --- /dev/null +++ b/admin/core/funcs.go @@ -0,0 +1,56 @@ +package core + +import ( + "arimelody-web/controller" + "arimelody-web/model" + "context" + "fmt" + "net/http" + "os" + "strings" +) + +func RequireAccount(next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + if session.Account == nil { + // TODO: include context in redirect + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + next.ServeHTTP(w, r) + }) +} + +func EnforceSession(app *model.AppState, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := controller.GetSessionFromRequest(app, r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if session == nil { + // create a new session + session, err = controller.CreateSession(app.DB, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: model.COOKIE_TOKEN, + Value: session.Token, + Expires: session.ExpiresAt, + Secure: strings.HasPrefix(app.Config.BaseUrl, "https"), + HttpOnly: true, + Path: "/", + }) + } + + ctx := context.WithValue(r.Context(), "session", session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/admin/http.go b/admin/http.go index 245a152..e04c636 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,57 +1,38 @@ package admin import ( - "context" - "database/sql" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "fmt" + "net/http" + "os" + "path/filepath" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" - - "golang.org/x/crypto/bcrypt" + "arimelody-web/admin/account" + "arimelody-web/admin/auth" + "arimelody-web/admin/core" + "arimelody-web/admin/logs" + "arimelody-web/admin/music" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" ) func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() - mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family") - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + mux.Handle("/register", auth.RegisterAccountHandler(app)) + mux.Handle("/login", auth.LoginHandler(app)) + mux.Handle("/totp", auth.LoginTOTPHandler(app)) + mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app))) - w.Write([]byte("")) - })) - - mux.Handle("/login", loginHandler(app)) - mux.Handle("/totp", loginTOTPHandler(app)) - mux.Handle("/logout", requireAccount(logoutHandler(app))) - - mux.Handle("/register", registerAccountHandler(app)) - - mux.Handle("/account", requireAccount(accountIndexHandler(app))) - mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) - - mux.Handle("/logs", requireAccount(logsHandler(app))) - - mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app)))) - mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app)))) - mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app)))) + mux.Handle("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app)))) + mux.Handle("/logs", core.RequireAccount(logs.Handler(app))) + mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app)))) mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) - - mux.Handle("/", requireAccount(AdminIndexHandler(app))) + mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists - return enforceSession(app, mux) + return core.EnforceSession(app, mux) } func AdminIndexHandler(app *model.AppState) http.Handler { @@ -63,39 +44,21 @@ func AdminIndexHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + latestRelease, err := controller.GetLatestRelease(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - artists, err := controller.GetAllArtists(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - tracks, err := controller.GetOrphanTracks(app.DB) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type IndexData struct { - Session *model.Session - Releases []*model.Release - Artists []*model.Artist - Tracks []*model.Track + Session *model.Session + LatestRelease *model.Release } - err = indexTemplate.Execute(w, IndexData{ + err = templates.IndexTemplate.Execute(w, IndexData{ Session: session, - Releases: releases, - Artists: artists, - Tracks: tracks, + LatestRelease: latestRelease, }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) @@ -105,361 +68,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler { }) } -func registerAccountHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - if session.Account != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - type registerData struct { - Session *model.Session - } - - render := func() { - err := registerTemplate.Execute(w, registerData{ Session: session }) - if err != nil { - fmt.Printf("WARN: Error rendering create account page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - } - - if r.Method == http.MethodGet { - render() - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Invite string `json:"invite"` - } - credentials := RegisterRequest{ - Username: r.Form.Get("username"), - Email: r.Form.Get("email"), - Password: r.Form.Get("password"), - Invite: r.Form.Get("invite"), - } - - // make sure invite code exists in DB - invite, err := controller.GetInvite(app.DB, credentials.Invite) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - if invite == nil || time.Now().After(invite.ExpiresAt) { - if invite != nil { - err := controller.DeleteInvite(app.DB, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - } - controller.SetSessionError(app.DB, session, "Invalid invite code.") - render() - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - - account := model.Account{ - Username: credentials.Username, - Password: string(hashedPassword), - Email: sql.NullString{ String: credentials.Email, Valid: true }, - AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, - } - err = controller.CreateAccount(app.DB, &account) - if err != nil { - if strings.HasPrefix(err.Error(), "pq: duplicate key") { - controller.SetSessionError(app.DB, session, "An account with that username already exists.") - render() - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) - - err = controller.DeleteInvite(app.DB, invite.Code) - if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) - } - - // registration success! - controller.SetSessionAccount(app.DB, session, &account) - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin", http.StatusFound) - }) -} - -func loginHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - session := r.Context().Value("session").(*model.Session) - - type loginData struct { - Session *model.Session - } - - render := func() { - err := loginTemplate.Execute(w, loginData{ Session: session }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - - if r.Method == http.MethodGet { - if session.Account != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - render() - return - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - if !r.Form.Has("username") || !r.Form.Has("password") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - - account, err := controller.GetAccountByUsername(app.DB, username) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) - controller.SetSessionError(app.DB, session, "Invalid username or password.") - render() - return - } - if account == nil { - controller.SetSessionError(app.DB, session, "Invalid username or password.") - render() - return - } - if account.Locked { - controller.SetSessionError(app.DB, session, "This account is locked.") - render() - return - } - - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) - if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, account, r); locked { - controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") - } else { - controller.SetSessionError(app.DB, session, "Invalid username or password.") - } - render() - return - } - - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - - if len(totps) > 0 { - err = controller.SetSessionAttemptAccount(app.DB, session, account) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin/totp", http.StatusFound) - return - } - - // login success! - // TODO: log login activity to user - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) - - err = controller.SetSessionAccount(app.DB, session, account) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin", http.StatusFound) - }) -} - -func loginTOTPHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - - if session.AttemptAccount == nil { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - type loginTOTPData struct { - Session *model.Session - } - - render := func() { - err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - - if r.Method == http.MethodGet { - render() - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - r.ParseForm() - - if !r.Form.Has("totp") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - totpCode := r.FormValue("totp") - - if len(totpCode) != controller.TOTP_CODE_LENGTH { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - controller.SetSessionError(app.DB, session, "Invalid TOTP.") - render() - return - } - - totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - if totpMethod == nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) - if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { - controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") - controller.SetSessionAttemptAccount(app.DB, session, nil) - http.Redirect(w, r, "/admin", http.StatusFound) - } else { - controller.SetSessionError(app.DB, session, "Incorrect TOTP.") - } - render() - return - } - - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) - - err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) - controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - render() - return - } - err = controller.SetSessionAttemptAccount(app.DB, session, nil) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err) - } - controller.SetSessionMessage(app.DB, session, "") - controller.SetSessionError(app.DB, session, "") - http.Redirect(w, r, "/admin", http.StatusFound) - }) -} - -func logoutHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.NotFound(w, r) - return - } - - session := r.Context().Value("session").(*model.Session) - err := controller.DeleteSession(app.DB, session.Token) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to delete session: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: model.COOKIE_TOKEN, - Expires: time.Now(), - Path: "/", - }) - - err = logoutTemplate.Execute(w, nil) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - }) -} - -func requireAccount(next http.Handler) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - if session.Account == nil { - // TODO: include context in redirect - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } - next.ServeHTTP(w, r) - }) -} - func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) @@ -480,63 +88,3 @@ func staticHandler() http.Handler { http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r) }) } - -func enforceSession(app *model.AppState, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session, err := controller.GetSessionFromRequest(app, r) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if session == nil { - // create a new session - session, err = controller.CreateSession(app.DB, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create session: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: model.COOKIE_TOKEN, - Value: session.Token, - Expires: session.ExpiresAt, - Secure: strings.HasPrefix(app.Config.BaseUrl, "https"), - HttpOnly: true, - Path: "/", - }) - } - - ctx := context.WithValue(r.Context(), "session", session) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { - locked, err := controller.IncrementAccountFails(app.DB, account.ID) - if err != nil { - fmt.Fprintf( - os.Stderr, - "WARN: Failed to increment login failures for \"%s\": %v\n", - account.Username, - err, - ) - app.Log.Warn( - log.TYPE_ACCOUNT, - "Failed to increment login failures for \"%s\"", - account.Username, - ) - } - if locked { - app.Log.Warn( - log.TYPE_ACCOUNT, - "Account \"%s\" was locked: %d failed login attempts (IP: %s)", - account.Username, - model.MAX_LOGIN_FAIL_ATTEMPTS, - controller.ResolveIP(app, r), - ) - } - return locked -} diff --git a/admin/logshttp.go b/admin/logs/logshttp.go similarity index 88% rename from admin/logshttp.go rename to admin/logs/logshttp.go index 7249b16..5c5c5c9 100644 --- a/admin/logshttp.go +++ b/admin/logs/logshttp.go @@ -1,15 +1,16 @@ -package admin +package logs import ( - "arimelody-web/log" - "arimelody-web/model" - "fmt" - "net/http" - "os" - "strings" + "arimelody-web/admin/templates" + "arimelody-web/log" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "strings" ) -func logsHandler(app *model.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -54,7 +55,7 @@ func logsHandler(app *model.AppState) http.Handler { Logs []*log.Log } - err = logsTemplate.Execute(w, LogsResponse{ + err = templates.LogsTemplate.Execute(w, LogsResponse{ Session: session, Logs: logs, }) diff --git a/admin/artisthttp.go b/admin/music/artisthttp.go similarity index 88% rename from admin/artisthttp.go rename to admin/music/artisthttp.go index 9fa6bb2..a936d05 100644 --- a/admin/artisthttp.go +++ b/admin/music/artisthttp.go @@ -1,12 +1,13 @@ -package admin +package music import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/model" - "arimelody-web/controller" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" ) func serveArtist(app *model.AppState) http.Handler { @@ -39,7 +40,7 @@ func serveArtist(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = artistTemplate.Execute(w, ArtistResponse{ + err = templates.ArtistTemplate.Execute(w, ArtistResponse{ Session: session, Artist: artist, Credits: credits, diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go new file mode 100644 index 0000000..bce2c10 --- /dev/null +++ b/admin/music/musichttp.go @@ -0,0 +1,71 @@ +package music + +import ( + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" + "fmt" + "net/http" + "os" +) + +func Handler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Println(r.URL.Path) + + mux := http.NewServeMux() + + mux.Handle("/release/", http.StripPrefix("/release", serveRelease(app))) + mux.Handle("/artist/", http.StripPrefix("/artist", serveArtist(app))) + mux.Handle("/track/", http.StripPrefix("/track", serveTrack(app))) + mux.Handle("/", musicHandler(app)) + + mux.ServeHTTP(w, r) + }) +} + +func musicHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + releases, err := controller.GetAllReleases(app.DB, false, 0, true) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + artists, err := controller.GetAllArtists(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + tracks, err := controller.GetOrphanTracks(app.DB) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type MusicData struct { + Session *model.Session + Releases []*model.Release + Artists []*model.Artist + Tracks []*model.Track + } + + err = templates.MusicTemplate.Execute(w, MusicData{ + Session: session, + Releases: releases, + Artists: artists, + Tracks: tracks, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/admin/releasehttp.go b/admin/music/releasehttp.go similarity index 91% rename from admin/releasehttp.go rename to admin/music/releasehttp.go index c6b68ab..6716b1a 100644 --- a/admin/releasehttp.go +++ b/admin/music/releasehttp.go @@ -1,12 +1,13 @@ -package admin +package music import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" ) func serveRelease(app *model.AppState) http.Handler { @@ -60,7 +61,7 @@ func serveRelease(app *model.AppState) http.Handler { Release *model.Release } - err = releaseTemplate.Execute(w, ReleaseResponse{ + err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{ Session: session, Release: release, }) @@ -74,7 +75,7 @@ func serveRelease(app *model.AppState) http.Handler { func serveEditCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := editCreditsTemplate.Execute(w, release) + err := templates.EditCreditsTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -97,7 +98,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = addCreditTemplate.Execute(w, response{ + err = templates.AddCreditTemplate.Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -123,7 +124,7 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = newCreditTemplate.Execute(w, artist) + err = templates.NewCreditTemplate.Execute(w, artist) if err != nil { fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -134,7 +135,7 @@ func serveNewCredit(app *model.AppState) http.Handler { func serveEditLinks(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - err := editLinksTemplate.Execute(w, release) + err := templates.EditLinksTemplate.Execute(w, release) if err != nil { fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -151,7 +152,7 @@ func serveEditTracks(release *model.Release) http.Handler { Add func(a int, b int) int } - err := editTracksTemplate.Execute(w, editTracksData{ + err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release, Add: func(a, b int) int { return a + b }, }) @@ -177,7 +178,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = addTrackTemplate.Execute(w, response{ + err = templates.AddTrackTemplate.Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -204,7 +205,7 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = newTrackTemplate.Execute(w, track) + err = templates.NewTrackTemplate.Execute(w, track) if err != nil { fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/trackhttp.go b/admin/music/trackhttp.go similarity index 88% rename from admin/trackhttp.go rename to admin/music/trackhttp.go index 93eacdb..5f67a83 100644 --- a/admin/trackhttp.go +++ b/admin/music/trackhttp.go @@ -1,12 +1,13 @@ -package admin +package music import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/model" - "arimelody-web/controller" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" ) func serveTrack(app *model.AppState) http.Handler { @@ -39,7 +40,7 @@ func serveTrack(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = trackTemplate.Execute(w, TrackResponse{ + err = templates.TrackTemplate.Execute(w, TrackResponse{ Session: session, Track: track, Releases: releases, diff --git a/admin/static/admin.css b/admin/static/admin.css index 877b5da..99dc276 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -27,7 +27,10 @@ nav { border-radius: 4px; border: 1px solid #808080; } -nav .icon { +.nav-item.icon { + padding: 0; +} +.nav-item.icon img { height: 100%; } nav .title { @@ -92,6 +95,10 @@ code { border-radius: 4px; } +h1 { + margin: 0 0 .5em 0; +} + .card { diff --git a/admin/static/index.css b/admin/static/index.css index 9fcd731..0e5121f 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,82 +1 @@ @import url("/admin/static/release-list-item.css"); - -.artist { - margin-bottom: .5em; - padding: .5em; - display: flex; - flex-direction: row; - align-items: center; - gap: .5em; - - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; -} - -.artist:hover { - text-decoration: hover; -} - -.artist-avatar { - width: 32px; - height: 32px; - object-fit: cover; - border-radius: 100%; -} - -.track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 8px; - background: #f8f8f8f8; - border: 1px solid #808080; -} - -.track p { - margin: 0; -} - -.card h2.track-title { - margin: 0; - display: flex; - flex-direction: row; - justify-content: space-between; -} - -.track-id { - width: fit-content; - font-family: "Monaspace Argon", monospace; - font-size: .8em; - font-style: italic; - line-height: 1em; - user-select: all; -} - -.track-album { - margin-left: auto; - font-style: italic; - font-size: .75em; - opacity: .5; -} - -.track-album.empty { - color: #ff2020; - opacity: 1; -} - -.track-description { - font-style: italic; -} - -.track-lyrics { - max-height: 10em; - overflow-y: scroll; -} - -.track .empty { - opacity: 0.75; -} diff --git a/admin/static/index.js b/admin/static/index.js index e251802..e69de29 100644 --- a/admin/static/index.js +++ b/admin/static/index.js @@ -1,74 +0,0 @@ -const newReleaseBtn = document.getElementById("create-release"); -const newArtistBtn = document.getElementById("create-artist"); -const newTrackBtn = document.getElementById("create-track"); - -newReleaseBtn.addEventListener("click", event => { - event.preventDefault(); - const id = prompt("Enter an ID for this release:"); - if (id == null || id == "") return; - - fetch("/api/v1/music", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({id}) - }).then(res => { - if (res.ok) location = "/admin/release/" + id; - else { - res.text().then(err => { - alert("Request failed: " + err); - console.error(err); - }); - } - }).catch(err => { - alert("Failed to create release. Check the console for details."); - console.error(err); - }); -}); - -newArtistBtn.addEventListener("click", event => { - event.preventDefault(); - const id = prompt("Enter an ID for this artist:"); - if (id == null || id == "") return; - - fetch("/api/v1/artist", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({id}) - }).then(res => { - res.text().then(text => { - if (res.ok) { - location = "/admin/artist/" + id; - } else { - alert("Request failed: " + text); - console.error(text); - } - }) - }).catch(err => { - alert("Failed to create artist. Check the console for details."); - console.error(err); - }); -}); - -newTrackBtn.addEventListener("click", event => { - event.preventDefault(); - const title = prompt("Enter an title for this track:"); - if (title == null || title == "") return; - - fetch("/api/v1/track", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({title}) - }).then(res => { - res.text().then(text => { - if (res.ok) { - location = "/admin/track/" + text; - } else { - alert("Request failed: " + text); - console.error(text); - } - }) - }).catch(err => { - alert("Failed to create track. Check the console for details."); - console.error(err); - }); -}); diff --git a/admin/static/music.css b/admin/static/music.css new file mode 100644 index 0000000..9fcd731 --- /dev/null +++ b/admin/static/music.css @@ -0,0 +1,82 @@ +@import url("/admin/static/release-list-item.css"); + +.artist { + margin-bottom: .5em; + padding: .5em; + display: flex; + flex-direction: row; + align-items: center; + gap: .5em; + + border-radius: 8px; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.artist:hover { + text-decoration: hover; +} + +.artist-avatar { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 100%; +} + +.track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: 8px; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.track p { + margin: 0; +} + +.card h2.track-title { + margin: 0; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.track-id { + width: fit-content; + font-family: "Monaspace Argon", monospace; + font-size: .8em; + font-style: italic; + line-height: 1em; + user-select: all; +} + +.track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +.track-album.empty { + color: #ff2020; + opacity: 1; +} + +.track-description { + font-style: italic; +} + +.track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +.track .empty { + opacity: 0.75; +} diff --git a/admin/static/music.js b/admin/static/music.js new file mode 100644 index 0000000..6e4a7a9 --- /dev/null +++ b/admin/static/music.js @@ -0,0 +1,74 @@ +const newReleaseBtn = document.getElementById("create-release"); +const newArtistBtn = document.getElementById("create-artist"); +const newTrackBtn = document.getElementById("create-track"); + +newReleaseBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this release:"); + if (id == null || id == "") return; + + fetch("/api/v1/music", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + if (res.ok) location = "/admin/music/release/" + id; + else { + res.text().then(err => { + alert("Request failed: " + err); + console.error(err); + }); + } + }).catch(err => { + alert("Failed to create release. Check the console for details."); + console.error(err); + }); +}); + +newArtistBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this artist:"); + if (id == null || id == "") return; + + fetch("/api/v1/artist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/music/artist/" + id; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create artist. Check the console for details."); + console.error(err); + }); +}); + +newTrackBtn.addEventListener("click", event => { + event.preventDefault(); + const title = prompt("Enter an title for this track:"); + if (title == null || title == "") return; + + fetch("/api/v1/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({title}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/music/track/" + text; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create track. Check the console for details."); + console.error(err); + }); +}); diff --git a/admin/templates.go b/admin/templates.go deleted file mode 100644 index 4b7c10c..0000000 --- a/admin/templates.go +++ /dev/null @@ -1,125 +0,0 @@ -package admin - -import ( - "arimelody-web/log" - "fmt" - "html/template" - "path/filepath" - "strings" - "time" -) - -var indexTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "index.html"), -)) - -var loginTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "login.html"), -)) -var loginTOTPTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "login-totp.html"), -)) -var registerTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "register.html"), -)) -var logoutTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "logout.html"), -)) -var accountTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "edit-account.html"), -)) -var totpSetupTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "totp-setup.html"), -)) -var totpConfirmTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "totp-confirm.html"), -)) - -var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{ - "parseLevel": func(level log.LogLevel) string { - switch level { - case log.LEVEL_INFO: - return "INFO" - case log.LEVEL_WARN: - return "WARN" - } - return fmt.Sprintf("%d?", level) - }, - "titleCase": func(logType string) string { - runes := []rune(logType) - for i, r := range runes { - if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { - runes[i] = r + ('A' - 'a') - } - } - return string(runes) - }, - "lower": func(str string) string { return strings.ToLower(str) }, - "prettyTime": func(t time.Time) string { - // return t.Format("2006-01-02 15:04:05") - // return t.Format("15:04:05, 2 Jan 2006") - return t.Format("02 Jan 2006, 15:04:05") - }, -}).ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "logs.html"), -)) - -var releaseTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "edit-release.html"), -)) -var artistTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "views", "edit-artist.html"), -)) -var trackTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "views", "layout.html"), - filepath.Join("view", "prideflag.html"), - filepath.Join("admin", "components", "release", "release-list-item.html"), - filepath.Join("admin", "views", "edit-track.html"), -)) - -var editCreditsTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "credits", "editcredits.html"), -)) -var addCreditTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "credits", "addcredit.html"), -)) -var newCreditTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "credits", "newcredit.html"), -)) - -var editLinksTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "links", "editlinks.html"), -)) - -var editTracksTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "tracks", "edittracks.html"), -)) -var addTrackTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "tracks", "addtrack.html"), -)) -var newTrackTemplate = template.Must(template.ParseFiles( - filepath.Join("admin", "components", "tracks", "newtrack.html"), -)) diff --git a/admin/templates/index.go b/admin/templates/index.go new file mode 100644 index 0000000..e66c6cd --- /dev/null +++ b/admin/templates/index.go @@ -0,0 +1,13 @@ +package templates + +import ( + "html/template" + "path/filepath" +) + +var IndexTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "index.html"), +)) diff --git a/admin/templates/login.go b/admin/templates/login.go new file mode 100644 index 0000000..55e7a57 --- /dev/null +++ b/admin/templates/login.go @@ -0,0 +1,42 @@ +package templates + +import ( + "html/template" + "path/filepath" +) + +var LoginTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "login.html"), +)) +var LoginTOTPTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), +)) +var RegisterTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "register.html"), +)) +var LogoutTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "logout.html"), +)) +var AccountTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), +)) +var TotpSetupTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "totp-setup.html"), +)) +var TotpConfirmTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "totp-confirm.html"), +)) diff --git a/admin/templates/logs.go b/admin/templates/logs.go new file mode 100644 index 0000000..7d4eccb --- /dev/null +++ b/admin/templates/logs.go @@ -0,0 +1,41 @@ +package templates + +import ( + "arimelody-web/log" + "fmt" + "html/template" + "path/filepath" + "strings" + "time" +) + +var LogsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{ + "parseLevel": func(level log.LogLevel) string { + switch level { + case log.LEVEL_INFO: + return "INFO" + case log.LEVEL_WARN: + return "WARN" + } + return fmt.Sprintf("%d?", level) + }, + "titleCase": func(logType string) string { + runes := []rune(logType) + for i, r := range runes { + if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { + runes[i] = r + ('A' - 'a') + } + } + return string(runes) + }, + "lower": func(str string) string { return strings.ToLower(str) }, + "prettyTime": func(t time.Time) string { + // return t.Format("2006-01-02 15:04:05") + // return t.Format("15:04:05, 2 Jan 2006") + return t.Format("02 Jan 2006, 15:04:05") + }, +}).ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "logs.html"), +)) diff --git a/admin/templates/music.go b/admin/templates/music.go new file mode 100644 index 0000000..3e9a772 --- /dev/null +++ b/admin/templates/music.go @@ -0,0 +1,54 @@ +package templates + +import ( + "html/template" + "path/filepath" +) + +var MusicTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "music.html"), +)) + +var ReleaseTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "edit-release.html"), +)) +var ArtistTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "views", "edit-artist.html"), +)) +var TrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("view", "prideflag.html"), + filepath.Join("admin", "components", "release", "release-list-item.html"), + filepath.Join("admin", "views", "edit-track.html"), +)) + +var EditCreditsTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "editcredits.html"), +)) +var AddCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "addcredit.html"), +)) +var NewCreditTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "credits", "newcredit.html"), +)) + +var EditLinksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "links", "editlinks.html"), +)) + +var EditTracksTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "edittracks.html"), +)) +var AddTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "addtrack.html"), +)) +var NewTrackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "components", "tracks", "newtrack.html"), +)) diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index b0cfb41..f0e03dc 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -38,7 +38,7 @@
    -

    {{.Release.Title}}

    +

    {{.Release.Title}}

    {{.Release.PrintArtists true true}}

    Role: {{.Role}} diff --git a/admin/views/edit-release.html b/admin/views/edit-release.html index 02447e1..34ee86e 100644 --- a/admin/views/edit-release.html +++ b/admin/views/edit-release.html @@ -100,8 +100,8 @@

    Credits ({{len .Release.Credits}})

    Edit @@ -111,7 +111,7 @@
    -

    {{.Artist.Name}}

    +

    {{.Artist.Name}}

    {{.Role}} {{if .Primary}} @@ -129,8 +129,8 @@

    Links ({{len .Release.Links}})

    Edit @@ -144,8 +144,8 @@

    Tracklist ({{len .Release.Tracks}})

    Edit @@ -155,7 +155,7 @@

    {{.Add $i 1}} - {{$track.Title}} + {{$track.Title}}

    Description

    diff --git a/admin/views/index.html b/admin/views/index.html index 2b9c897..6032849 100644 --- a/admin/views/index.html +++ b/admin/views/index.html @@ -6,65 +6,19 @@ {{define "content"}}
    - +

    Admin Dashboard

    -

    Releases

    - Create New +

    Music

    + Browse All
    -
    - {{range .Releases}} - {{block "release" .}}{{end}} - {{end}} - {{if not .Releases}} +
    + {{if .LatestRelease}} +

    Latest Release

    + {{block "release" .LatestRelease}}{{end}} + {{else}}

    There are no releases.

    {{end}}
    - -
    -

    Artists

    - Create New -
    -
    - {{range $Artist := .Artists}} - - {{end}} - {{if not .Artists}} -

    There are no artists.

    - {{end}} -
    - -
    -

    Tracks

    - Create New -
    -
    -

    "Orphaned" tracks that have not yet been bound to a release.

    -
    - {{range $Track := .Tracks}} -
    -

    - {{$Track.Title}} -

    - {{if $Track.Description}} -

    {{$Track.GetDescriptionHTML}}

    - {{else}} -

    No description provided.

    - {{end}} - {{if $Track.Lyrics}} -

    {{$Track.GetLyricsHTML}}

    - {{else}} -

    There are no lyrics.

    - {{end}} -
    - {{end}} - {{if not .Artists}} -

    There are no artists.

    - {{end}} -
    -
    diff --git a/admin/views/layout.html b/admin/views/layout.html index 52b0620..ac46263 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -16,14 +16,19 @@