diff --git a/admin/account/accounthttp.go b/admin/accounthttp.go similarity index 89% rename from admin/account/accounthttp.go rename to admin/accounthttp.go index 5601a2e..945a507 100644 --- a/admin/account/accounthttp.go +++ b/admin/accounthttp.go @@ -1,25 +1,22 @@ -package account +package admin import ( - "database/sql" - "fmt" - "net/http" - "net/url" - "os" + "database/sql" + "fmt" + "net/http" + "net/url" + "os" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" - "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/bcrypt" ) -func Handler(app *model.AppState) http.Handler { +func accountHandler(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))) @@ -67,7 +64,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Message = sessionMessage session.Error = sessionError - err = templates.AccountTemplate.Execute(w, accountResponse{ + err = accountTemplate.Execute(w, accountResponse{ Session: session, TOTPs: totps, }) @@ -95,7 +92,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 } @@ -105,7 +102,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 } @@ -114,7 +111,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 } @@ -122,7 +119,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) }) } @@ -150,7 +147,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 } @@ -158,7 +155,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 } @@ -187,7 +184,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err := templates.TotpSetupTemplate.Execute(w, totpSetupData{ Session: session }) + err := 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) @@ -224,7 +221,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 := templates.TotpSetupTemplate.Execute(w, totpConfirmData{ Session: session }) + err := 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) @@ -238,7 +235,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) } - err = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ + err = totpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: &totp, NameEscaped: url.PathEscape(totp.Name), @@ -280,7 +277,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 { @@ -299,7 +296,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 = templates.TotpConfirmTemplate.Execute(w, totpConfirmData{ + err = totpConfirmTemplate.Execute(w, totpConfirmData{ Session: session, TOTP: totp, NameEscaped: url.PathEscape(totp.Name), @@ -317,7 +314,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 } @@ -325,7 +322,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) }) } @@ -348,7 +345,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 { @@ -360,7 +357,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 } @@ -368,6 +365,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/music/artisthttp.go b/admin/artisthttp.go similarity index 88% rename from admin/music/artisthttp.go rename to admin/artisthttp.go index a936d05..9fa6bb2 100644 --- a/admin/music/artisthttp.go +++ b/admin/artisthttp.go @@ -1,13 +1,12 @@ -package music +package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/model" + "arimelody-web/controller" ) func serveArtist(app *model.AppState) http.Handler { @@ -40,7 +39,7 @@ func serveArtist(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = templates.ArtistTemplate.Execute(w, ArtistResponse{ + err = artistTemplate.Execute(w, ArtistResponse{ Session: session, Artist: artist, Credits: credits, diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go deleted file mode 100644 index aba4074..0000000 --- a/admin/auth/authhttp.go +++ /dev/null @@ -1,386 +0,0 @@ -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 debc660..c09a550 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 0c50fac..94dc268 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 142c26a..4b8f41e 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 f62f369..a6dd433 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 3b8368d..d03f80a 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 deleted file mode 100644 index c471f8a..0000000 --- a/admin/core/funcs.go +++ /dev/null @@ -1,56 +0,0 @@ -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 e04c636..245a152 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,38 +1,57 @@ package admin import ( - "fmt" - "net/http" - "os" - "path/filepath" + "context" + "database/sql" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "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" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" + + "golang.org/x/crypto/bcrypt" ) func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() - 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))) + 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("/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)))) + 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("/static/", http.StripPrefix("/static", staticHandler())) - mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) + + mux.Handle("/", requireAccount(AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists - return core.EnforceSession(app, mux) + return enforceSession(app, mux) } func AdminIndexHandler(app *model.AppState) http.Handler { @@ -44,21 +63,39 @@ func AdminIndexHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - latestRelease, err := controller.GetLatestRelease(app.DB) + releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull latest release: %s\n", err) + 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 IndexData struct { - Session *model.Session - LatestRelease *model.Release + Session *model.Session + Releases []*model.Release + Artists []*model.Artist + Tracks []*model.Track } - err = templates.IndexTemplate.Execute(w, IndexData{ + err = indexTemplate.Execute(w, IndexData{ Session: session, - LatestRelease: latestRelease, + Releases: releases, + Artists: artists, + Tracks: tracks, }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) @@ -68,6 +105,361 @@ 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))) @@ -88,3 +480,63 @@ 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/logs/logshttp.go b/admin/logshttp.go similarity index 88% rename from admin/logs/logshttp.go rename to admin/logshttp.go index 5c5c5c9..7249b16 100644 --- a/admin/logs/logshttp.go +++ b/admin/logshttp.go @@ -1,16 +1,15 @@ -package logs +package admin import ( - "arimelody-web/admin/templates" - "arimelody-web/log" - "arimelody-web/model" - "fmt" - "net/http" - "os" - "strings" + "arimelody-web/log" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "strings" ) -func Handler(app *model.AppState) http.Handler { +func logsHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -55,7 +54,7 @@ func Handler(app *model.AppState) http.Handler { Logs []*log.Log } - err = templates.LogsTemplate.Execute(w, LogsResponse{ + err = logsTemplate.Execute(w, LogsResponse{ Session: session, Logs: logs, }) diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go deleted file mode 100644 index c212248..0000000 --- a/admin/music/musichttp.go +++ /dev/null @@ -1,69 +0,0 @@ -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/music/releasehttp.go b/admin/releasehttp.go similarity index 91% rename from admin/music/releasehttp.go rename to admin/releasehttp.go index 6716b1a..c6b68ab 100644 --- a/admin/music/releasehttp.go +++ b/admin/releasehttp.go @@ -1,13 +1,12 @@ -package music +package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" ) func serveRelease(app *model.AppState) http.Handler { @@ -61,7 +60,7 @@ func serveRelease(app *model.AppState) http.Handler { Release *model.Release } - err = templates.ReleaseTemplate.Execute(w, ReleaseResponse{ + err = releaseTemplate.Execute(w, ReleaseResponse{ Session: session, Release: release, }) @@ -75,7 +74,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 := templates.EditCreditsTemplate.Execute(w, release) + err := 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) @@ -98,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.AddCreditTemplate.Execute(w, response{ + err = addCreditTemplate.Execute(w, response{ ReleaseID: release.ID, Artists: artists, }) @@ -124,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.NewCreditTemplate.Execute(w, artist) + err = 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) @@ -135,7 +134,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 := templates.EditLinksTemplate.Execute(w, release) + err := 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) @@ -152,7 +151,7 @@ func serveEditTracks(release *model.Release) http.Handler { Add func(a int, b int) int } - err := templates.EditTracksTemplate.Execute(w, editTracksData{ + err := editTracksTemplate.Execute(w, editTracksData{ Release: release, Add: func(a, b int) int { return a + b }, }) @@ -178,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.AddTrackTemplate.Execute(w, response{ + err = addTrackTemplate.Execute(w, response{ ReleaseID: release.ID, Tracks: tracks, }) @@ -205,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler { } w.Header().Set("Content-Type", "text/html") - err = templates.NewTrackTemplate.Execute(w, track) + err = 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/static/admin.css b/admin/static/admin.css index 99dc276..877b5da 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -27,10 +27,7 @@ nav { border-radius: 4px; border: 1px solid #808080; } -.nav-item.icon { - padding: 0; -} -.nav-item.icon img { +nav .icon { height: 100%; } nav .title { @@ -95,10 +92,6 @@ code { border-radius: 4px; } -h1 { - margin: 0 0 .5em 0; -} - .card { diff --git a/admin/static/index.css b/admin/static/index.css index 0e5121f..9fcd731 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1 +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/index.js b/admin/static/index.js index e69de29..e251802 100644 --- a/admin/static/index.js +++ b/admin/static/index.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/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 deleted file mode 100644 index 9fcd731..0000000 --- a/admin/static/music.css +++ /dev/null @@ -1,82 +0,0 @@ -@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 deleted file mode 100644 index 6e4a7a9..0000000 --- a/admin/static/music.js +++ /dev/null @@ -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/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 new file mode 100644 index 0000000..606d569 --- /dev/null +++ b/admin/templates.go @@ -0,0 +1,125 @@ +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("views", "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("views", "prideflag.html"), + filepath.Join("admin", "views", "login.html"), +)) +var loginTOTPTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "login-totp.html"), +)) +var registerTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "register.html"), +)) +var logoutTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logout.html"), +)) +var accountTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-account.html"), +)) +var totpSetupTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "totp-setup.html"), +)) +var totpConfirmTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "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("views", "prideflag.html"), + filepath.Join("admin", "views", "logs.html"), +)) + +var releaseTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-release.html"), +)) +var artistTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "edit-artist.html"), +)) +var trackTemplate = template.Must(template.ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "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 deleted file mode 100644 index e66c6cd..0000000 --- a/admin/templates/index.go +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 55e7a57..0000000 --- a/admin/templates/login.go +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 7d4eccb..0000000 --- a/admin/templates/logs.go +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 3e9a772..0000000 --- a/admin/templates/music.go +++ /dev/null @@ -1,54 +0,0 @@ -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/music/trackhttp.go b/admin/trackhttp.go similarity index 88% rename from admin/music/trackhttp.go rename to admin/trackhttp.go index 5f67a83..93eacdb 100644 --- a/admin/music/trackhttp.go +++ b/admin/trackhttp.go @@ -1,13 +1,12 @@ -package music +package admin import ( - "fmt" - "net/http" - "strings" + "fmt" + "net/http" + "strings" - "arimelody-web/admin/templates" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/model" + "arimelody-web/controller" ) func serveTrack(app *model.AppState) http.Handler { @@ -40,7 +39,7 @@ func serveTrack(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - err = templates.TrackTemplate.Execute(w, TrackResponse{ + err = trackTemplate.Execute(w, TrackResponse{ Session: session, Track: track, Releases: releases, diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index f0e03dc..b0cfb41 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 34ee86e..02447e1 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 6032849..2b9c897 100644 --- a/admin/views/index.html +++ b/admin/views/index.html @@ -6,19 +6,65 @@ {{define "content"}}
    -

    Admin Dashboard

    +
    -

    Music

    - Browse All +

    Releases

    + Create New
    -
    - {{if .LatestRelease}} -

    Latest Release

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

    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 ac46263..52b0620 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -16,19 +16,14 @@
  • - music + music
  • - blog + blog
  • diff --git a/view/index.html b/views/index.html similarity index 98% rename from view/index.html rename to views/index.html index 0e09475..df4d341 100644 --- a/view/index.html +++ b/views/index.html @@ -241,6 +241,9 @@ isabel roses web button + + sweet like bubblegum web button +
    diff --git a/view/layout.html b/views/layout.html similarity index 100% rename from view/layout.html rename to views/layout.html diff --git a/view/music-gateway.html b/views/music-gateway.html similarity index 100% rename from view/music-gateway.html rename to views/music-gateway.html diff --git a/view/music.html b/views/music.html similarity index 100% rename from view/music.html rename to views/music.html diff --git a/view/prideflag.html b/views/prideflag.html similarity index 100% rename from view/prideflag.html rename to views/prideflag.html