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 @@
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..c212248
--- /dev/null
+++ b/admin/music/musichttp.go
@@ -0,0 +1,69 @@
+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) {
+ 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.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 @@
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
-
- {{range .Releases}}
- {{block "release" .}}{{end}}
- {{end}}
- {{if not .Releases}}
+
+ {{if .LatestRelease}}
+
Latest Release
+ {{block "release" .LatestRelease}}{{end}}
+ {{else}}
There are no releases.
{{end}}
-
-
-
- {{range $Artist := .Artists}}
-
- {{end}}
- {{if not .Artists}}
-
There are no artists.
- {{end}}
-
-
-
-
-
"Orphaned" tracks that have not yet been bound to a release.
-
- {{range $Track := .Tracks}}
-
-
- {{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 @@