diff --git a/admin/accounthttp.go b/admin/accounthttp.go index b2cf18a..408b4c5 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -3,8 +3,8 @@ package admin import ( "fmt" "net/http" + "net/url" "os" - "strings" "time" "arimelody-web/controller" @@ -13,29 +13,58 @@ import ( "golang.org/x/crypto/bcrypt" ) -type TemplateData struct { - Account *model.Account - Message string - Token string +func accountHandler(app *model.AppState) http.Handler { + mux := http.NewServeMux() + + mux.Handle("/totp-setup", totpSetupHandler(app)) + mux.Handle("/totp-confirm", totpConfirmHandler(app)) + mux.Handle("/totp-delete/", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) + + mux.Handle("/password", changePasswordHandler(app)) + mux.Handle("/delete", deleteAccountHandler(app)) + + return mux } -func AccountHandler(app *model.AppState) http.Handler { +func accountIndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) + dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) if err != nil { fmt.Printf("WARN: Failed to fetch TOTPs: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - type AccountResponse struct { - Account *model.Account - TOTPs []model.TOTP + type ( + TOTP struct { + model.TOTP + CreatedAtString string + } + + accountResponse struct { + Session *model.Session + TOTPs []TOTP + } + ) + + totps := []TOTP{} + for _, totp := range dbTOTPs { + totps = append(totps, TOTP{ + TOTP: totp, + CreatedAtString: totp.CreatedAt.Format("02 Jan 2006, 15:04:05"), + }) } - err = pages["account"].Execute(w, AccountResponse{ - Account: account, + sessionMessage := session.Message + sessionError := session.Error + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + session.Message = sessionMessage + session.Error = sessionError + + err = accountTemplate.Execute(w, accountResponse{ + Session: session, TOTPs: totps, }) if err != nil { @@ -45,299 +74,307 @@ func AccountHandler(app *model.AppState) http.Handler { }) } -func LoginHandler(app *model.AppState) http.Handler { +func changePasswordHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - account, err := controller.GetAccountByRequest(app.DB, r) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) - return - } - if account != nil { - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - err = pages["login"].Execute(w, TemplateData{}) - 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.MethodPost { + http.NotFound(w, r) return } - type LoginResponse struct { - Account *model.Account - Token string - Message string + session := r.Context().Value("session").(*model.Session) + + controller.SetSessionMessage(app.DB, session, "") + controller.SetSessionError(app.DB, session, "") + + r.ParseForm() + + 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) + return } - render := func(data LoginResponse) { - err := pages["login"].Execute(w, data) - 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 - } + newPassword := r.Form.Get("new-password") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 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.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return } + session.Account.Password = string(hashedPassword) + err = controller.UpdateAccount(app.DB, session.Account) + 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) + return + } + + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "Password updated successfully.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + }) +} + +func deleteAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.NotFound(w, r); + http.NotFound(w, r) return } err := r.ParseForm() if err != nil { - render(LoginResponse{ Message: "Malformed request." }) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - credentials := LoginRequest{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - TOTP: r.Form.Get("totp"), + if !r.Form.Has("password") || !r.Form.Has("totp") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return } - account, err := controller.GetAccount(app.DB, credentials.Username) + session := r.Context().Value("session").(*model.Session) + + // check password + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) + controller.SetSessionError(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + + totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp")) if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) + fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) return } - if account == nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return + if totpMethod == nil { + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") + http.Redirect(w, r, "/admin/account", http.StatusFound) } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + err = controller.DeleteAccount(app.DB, session.Account.ID) if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) + 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) return } - totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - if len(totps) > 0 { - success := false - for _, totp := range totps { - check := controller.GenerateTOTP(totp.Secret, 0) - if check == credentials.TOTP { - success = true - break - } - } - if !success { - render(LoginResponse{ Message: "Invalid TOTP" }) - return - } - } else { - // TODO: user should be prompted to add 2FA method - } + fmt.Printf( + "[%s] INFO: Account \"%s\" deleted by user request.\n", + time.Now().Format(time.UnixDate), + session.Account.Username, + ) - // login success! - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - - cookie := http.Cookie{} - cookie.Name = model.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(app.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - render(LoginResponse{ Account: account, Token: token.Token }) + controller.SetSessionAccount(app.DB, session, nil) + controller.SetSessionError(app.DB, session, "") + controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") + http.Redirect(w, r, "/admin/login", http.StatusFound) }) } -func LogoutHandler(app *model.AppState) http.Handler { +func totpSetupHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + type totpSetupData struct { + Session *model.Session + } + + session := r.Context().Value("session").(*model.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) + } + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + type totpSetupData struct { + Session *model.Session + TOTP *model.TOTP + NameEscaped string + QRBase64Image string + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + session := r.Context().Value("session").(*model.Session) + + secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) + totp := model.TOTP { + AccountID: session.Account.ID, + Name: name, + Secret: string(secret), + } + err = controller.CreateTOTP(app.DB, &totp) + 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, 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) + } + return + } + + qrBase64Image, err := controller.GenerateQRCode( + controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) + if err != nil { + fmt.Printf("WARN: Failed to generate TOTP setup QR code: %s\n", err) + controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") + 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) + } + return + } + + err = totpConfirmTemplate.Execute(w, totpSetupData{ + Session: session, + TOTP: &totp, + NameEscaped: url.PathEscape(totp.Name), + QRBase64Image: qrBase64Image, + }) + if err != nil { + fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + }) +} + +func totpConfirmHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + type totpConfirmData struct { + Session *model.Session + TOTP *model.TOTP + } + + session := r.Context().Value("session").(*model.Session) + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + name := r.FormValue("totp-name") + if len(name) == 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + code := r.FormValue("totp") + if len(code) != controller.TOTP_CODE_LENGTH { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) + 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) + return + } + if totp == nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + confirmCode := controller.GenerateTOTP(totp.Secret, 0) + if code != confirmCode { + confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1) + if code != confirmCodeOffset { + controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.") + err = totpConfirmTemplate.Execute(w, totpConfirmData{ + Session: session, + TOTP: totp, + }) + return + } + } + + 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) + }) +} + +func totpDeleteHandler(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 } - tokenStr := controller.GetTokenFromRequest(app.DB, r) - - if len(tokenStr) > 0 { - err := controller.DeleteToken(app.DB, tokenStr) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + if len(r.URL.Path) < 2 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return } + name := r.URL.Path[1:] - cookie := http.Cookie{} - cookie.Name = model.COOKIE_TOKEN - cookie.Value = "" - cookie.Expires = time.Now() - if strings.HasPrefix(app.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - http.Redirect(w, r, "/admin/login", http.StatusFound) - }) -} + session := r.Context().Value("session").(*model.Session) -func createAccountHandler(app *model.AppState) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checkAccount, err := controller.GetAccountByRequest(app.DB, r) + totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) if err != nil { - fmt.Printf("WARN: Failed to fetch account: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + 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) return } - if checkAccount != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - type CreateAccountResponse struct { - Account *model.Account - Message string - } - - render := func(data CreateAccountResponse) { - err := pages["create-account"].Execute(w, data) - 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(CreateAccountResponse{}) - return - } - - if r.Method != http.MethodPost { + if totp == nil { http.NotFound(w, r) return } - err = r.ParseForm() + err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) if err != nil { - render(CreateAccountResponse{ - Message: "Malformed data.", - }) + 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) 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 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) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - 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) } - } - render(CreateAccountResponse{ - Message: "Invalid invite code.", - }) - 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) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - account := model.Account{ - Username: credentials.Username, - Password: string(hashedPassword), - Email: credentials.Email, - AvatarURL: "/img/default-avatar.png", - } - err = controller.CreateAccount(app.DB, &account) - if err != nil { - if strings.HasPrefix(err.Error(), "pq: duplicate key") { - render(CreateAccountResponse{ - Message: "An account with that username already exists.", - }) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - err = controller.DeleteInvite(app.DB, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - - // registration success! - token, err := controller.CreateToken(app.DB, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - // gracefully redirect user to login page - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } - - cookie := http.Cookie{} - cookie.Name = model.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(app.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, TemplateData{ - Account: &account, - Token: token.Token, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } + 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) }) } diff --git a/admin/artisthttp.go b/admin/artisthttp.go index d6a5e76..6dfbbfd 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -32,15 +32,15 @@ func serveArtist(app *model.AppState) http.Handler { } type ArtistResponse struct { - Account *model.Account + Session *model.Session Artist *model.Artist Credits []*model.Credit } - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) - err = pages["artist"].Execute(w, ArtistResponse{ - Account: account, + err = artistTemplate.Execute(w, ArtistResponse{ + Session: session, Artist: artist, Credits: credits, }) diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index 0500532..d03f80a 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,20 +3,20 @@

Editing: Tracks

Add -
+