diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 098eb59..b2cf18a 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -1,11 +1,10 @@ package admin import ( - "database/sql" "fmt" "net/http" - "net/url" "os" + "strings" "time" "arimelody-web/controller" @@ -14,58 +13,29 @@ import ( "golang.org/x/crypto/bcrypt" ) -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 +type TemplateData struct { + Account *model.Account + Message string + Token string } -func accountIndexHandler(app *model.AppState) http.Handler { +func AccountHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - dbTOTPs, err := controller.GetTOTPsForAccount(app.DB, session.Account.ID) + totps, err := controller.GetTOTPsForAccount(app.DB, 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 ( - 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"), - }) + type AccountResponse struct { + Account *model.Account + TOTPs []model.TOTP } - 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, + err = pages["account"].Execute(w, AccountResponse{ + Account: account, TOTPs: totps, }) if err != nil { @@ -75,298 +45,299 @@ func accountIndexHandler(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.MethodPost { - http.NotFound(w, r) - return - } - - 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 - } - - 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) - return - } - - err := r.ParseForm() - if err != nil { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - if !r.Form.Has("password") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - 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 - } - - err = controller.DeleteAccount(app.DB, session.Account.ID) - 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) - return - } - - fmt.Printf( - "[%s] INFO: Account \"%s\" deleted by user request.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) - - 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) - }) -} - -type totpConfirmData struct { - Session *model.Session - TOTP *model.TOTP - NameEscaped string - QRBase64Image string -} - -func totpSetupHandler(app *model.AppState) http.Handler { +func LoginHandler(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 }) + account, err := controller.GetAccountByRequest(app.DB, r) if err != nil { - fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) 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 } - 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 - } - - 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, totpConfirmData{ Session: session }) + err = pages["login"].Execute(w, TemplateData{}) if err != nil { - fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } return } - qrBase64Image, err := controller.GenerateQRCode( - controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) + type LoginResponse struct { + Account *model.Account + Token string + Message string } - err = totpConfirmTemplate.Execute(w, totpConfirmData{ - 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 - } - - 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: %v\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 - } - - qrBase64Image, err := controller.GenerateQRCode( - controller.GenerateTOTPURI(session.Account.Username, totp.Secret)) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err) - } - - confirmCode := controller.GenerateTOTP(totp.Secret, 0) - if code != confirmCode { - 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{ - Session: session, - TOTP: totp, - NameEscaped: url.PathEscape(totp.Name), - QRBase64Image: qrBase64Image, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } + 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 } } - err = controller.ConfirmTOTP(app.DB, session.Account.ID, name) - 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) + if r.Method != http.MethodPost { + http.NotFound(w, r); 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) + err := r.ParseForm() + if err != nil { + render(LoginResponse{ Message: "Malformed request." }) + 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"), + } + + account, err := controller.GetAccount(app.DB, credentials.Username) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + if account == nil { + render(LoginResponse{ Message: "Invalid username or password" }) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + render(LoginResponse{ Message: "Invalid username or password" }) + 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 + } + + // 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 }) }) } -func totpDeleteHandler(app *model.AppState) http.Handler { +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 } - if len(r.URL.Path) < 2 { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - 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 + } } - name := r.URL.Path[1:] - session := r.Context().Value("session").(*model.Session) + 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) + }) +} - totp, err := controller.GetTOTP(app.DB, session.Account.ID, name) +func createAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkAccount, err := controller.GetAccountByRequest(app.DB, r) 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) + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if totp == nil { + 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 { http.NotFound(w, r) return } - err = controller.DeleteTOTP(app.DB, session.Account.ID, totp.Name) + err = r.ParseForm() 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) + render(CreateAccountResponse{ + Message: "Malformed data.", + }) 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) + 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 + } }) } diff --git a/admin/artisthttp.go b/admin/artisthttp.go index 6dfbbfd..d6a5e76 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -32,15 +32,15 @@ func serveArtist(app *model.AppState) http.Handler { } type ArtistResponse struct { - Session *model.Session + Account *model.Account Artist *model.Artist Credits []*model.Credit } - session := r.Context().Value("session").(*model.Session) + account := r.Context().Value("account").(*model.Account) - err = artistTemplate.Execute(w, ArtistResponse{ - Session: session, + err = pages["artist"].Execute(w, ArtistResponse{ + Account: account, Artist: artist, Credits: credits, }) diff --git a/admin/components/tracks/edittracks.html b/admin/components/tracks/edittracks.html index d03f80a..0500532 100644 --- a/admin/components/tracks/edittracks.html +++ b/admin/components/tracks/edittracks.html @@ -3,20 +3,20 @@

Editing: Tracks

Add -
+