diff --git a/admin/accounthttp.go b/admin/accounthttp.go index b354fd5..fc701e7 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -3,9 +3,7 @@ package admin import ( "fmt" "net/http" - "net/url" "os" - "strings" "time" "arimelody-web/controller" @@ -14,16 +12,11 @@ import ( "golang.org/x/crypto/bcrypt" ) -type loginRegisterResponse struct { - Account *model.Account - Message string - Token string -} - -func AccountHandler(app *model.AppState) http.Handler { +func accountHandler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/password", changePasswordHandler(app)) + mux.Handle("/delete", deleteAccountHandler(app)) mux.Handle("/", accountIndexHandler(app)) return mux @@ -31,26 +24,22 @@ 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) + totps, 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 + Session *model.Session TOTPs []model.TOTP - Message string - Error string } err = pages["account"].Execute(w, accountResponse{ - Account: account, + Session: session, TOTPs: totps, - Message: r.URL.Query().Get("message"), - Error: r.URL.Query().Get("error"), }) if err != nil { fmt.Printf("WARN: Failed to render admin account page: %v\n", err) @@ -59,303 +48,6 @@ func accountIndexHandler(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 { - 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, loginRegisterResponse{}) - 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 - } - return - } - - type LoginResponse struct { - Account *model.Account - Token string - Message string - } - - 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 - } - } - - if r.Method != http.MethodPost { - http.NotFound(w, r); - return - } - - 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 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 - } - - 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 - } - } - - 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) - }) -} - -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 account: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - 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 { - http.NotFound(w, r) - return - } - - err = r.ParseForm() - if err != nil { - render(CreateaccountResponse{ - Message: "Malformed data.", - }) - 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, loginRegisterResponse{ - 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 - } - }) -} - func changePasswordHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -363,22 +55,89 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) r.ParseForm() currentPassword := r.Form.Get("current-password") - if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(currentPassword)); err != nil { - http.Redirect(w, r, "/admin/account?error=" + url.PathEscape("Incorrect password."), http.StatusFound) + if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { + controller.SetSessionMessage(app.DB, session, "Incorrect password.") + http.Redirect(w, r, "/admin/account", http.StatusFound) return } newPassword := r.Form.Get("new-password") - http.Redirect( - w, r, "/admin/account?message=" + - url.PathEscape(fmt.Sprintf("Updating password to %s", newPassword)), - http.StatusFound, - ) + controller.SetSessionMessage(app.DB, session, fmt.Sprintf("Updating password to %s", newPassword)) + 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") || !r.Form.Has("totp") { + 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("2006-02-01 15:04:05"), + session.Account.Username, + ) + controller.SetSessionMessage(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 { + fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err) + controller.SetSessionMessage(app.DB, session, "Something went wrong. Please try again.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + return + } + if totpMethod == nil { + fmt.Printf( + "[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n", + time.Now().Format("2006-02-01 15:04:05"), + session.Account.Username, + ) + controller.SetSessionMessage(app.DB, session, "Incorrect TOTP.") + http.Redirect(w, r, "/admin/account", http.StatusFound) + } + + err = controller.DeleteAccount(app.DB, session.Account.ID) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) + controller.SetSessionMessage(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("2006-02-01 15:04:05"), + session.Account.Username, + ) + + session.Account = nil + controller.SetSessionMessage(app.DB, session, "Account deleted successfully.") + http.Redirect(w, r, "/admin/login", http.StatusFound) }) } diff --git a/admin/artisthttp.go b/admin/artisthttp.go index d6a5e76..5979493 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, + Session: session, Artist: artist, Credits: credits, }) diff --git a/admin/http.go b/admin/http.go index 763537a..ad0d44e 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,40 +2,51 @@ package admin import ( "context" + "database/sql" "fmt" "net/http" "os" "path/filepath" + "strings" + "time" "arimelody-web/controller" "arimelody-web/model" + + "golang.org/x/crypto/bcrypt" ) func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() - mux.Handle("/login", LoginHandler(app)) - mux.Handle("/register", createAccountHandler(app)) - mux.Handle("/logout", RequireAccount(app, LogoutHandler(app))) - mux.Handle("/account/", RequireAccount(app, http.StripPrefix("/account", AccountHandler(app)))) - mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) + mux.Handle("/login", loginHandler(app)) + mux.Handle("/logout", RequireAccount(app, logoutHandler(app))) + + mux.Handle("/register", registerAccountHandler(app)) + + mux.Handle("/account", RequireAccount(app, accountIndexHandler(app))) + mux.Handle("/account/", RequireAccount(app, http.StripPrefix("/account", accountHandler(app)))) + mux.Handle("/release/", RequireAccount(app, http.StripPrefix("/release", serveRelease(app)))) mux.Handle("/artist/", RequireAccount(app, http.StripPrefix("/artist", serveArtist(app)))) mux.Handle("/track/", RequireAccount(app, http.StripPrefix("/track", serveTrack(app)))) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) + + mux.Handle("/", RequireAccount(app, AdminIndexHandler(app))) + + // response wrapper to make sure a session cookie exists + return enforceSession(app, mux) +} + +func AdminIndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } - account, err := controller.GetAccountByRequest(app.DB, r) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %s\n", err) - } - if account == nil { - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } + session := r.Context().Value("session").(*model.Session) releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { @@ -52,52 +63,283 @@ func Handler(app *model.AppState) http.Handler { } tracks, err := controller.GetOrphanTracks(app.DB) - if err != nil { + 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 - } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } type IndexData struct { - Account *model.Account + Session *model.Session Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } err = pages["index"].Execute(w, IndexData{ - Account: account, + Session: session, Releases: releases, Artists: artists, Tracks: tracks, }) - if err != nil { + 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 - } - })) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} - return mux +func registerAccountHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + session.Error = sql.NullString{} + session.Message = sql.NullString{} + + if session.Account != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + render := func() { + err := pages["register"].Execute(w, 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 { + session.Error = sql.NullString{ String: "Malformed data.", Valid: true } + render() + 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) + session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true } + 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) } + } + session.Error = sql.NullString{ String: "Invalid invite code.", Valid: true } + 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) + session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true } + 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") { + session.Error = sql.NullString{ String: "An account with that username already exists.", Valid: true } + render() + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true } + render() + 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! + controller.SetSessionAccount(app.DB, session, &account) + 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 := pages["login"].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 { + session.Error = sql.NullString{ String: "Malformed data.", Valid: true } + render() + return + } + + // new accounts won't have TOTP methods at first. there should be a + // second phase of login that prompts the user for a TOTP *only* + // if that account has a TOTP method. + // TODO: login phases (username & password -> TOTP) + + 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.GetAccountByUsername(app.DB, credentials.Username) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) + session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true } + render() + return + } + if account == nil { + session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true } + render() + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + fmt.Printf( + "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n", + time.Now().Format("2006-02-01 15:04:05"), + account.Username, + ) + session.Error = sql.NullString{ String: "Invalid username or password.", Valid: true } + render() + return + } + + totpMethod, err := controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) + session.Error = sql.NullString{ String: "Something went wrong. Please try again.", Valid: true } + render() + return + } + if totpMethod == nil { + session.Error = sql.NullString{ String: "Invalid TOTP.", Valid: true } + render() + return + } + + // TODO: log login activity to user + fmt.Printf( + "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", + time.Now().Format("2006-02-01 15:04:05"), + account.Username, + totpMethod.Name, + ) + + // login success! + controller.SetSessionAccount(app.DB, session, account) + 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 = pages["logout"].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(app *model.AppState, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - 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 { + 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 } - - ctx := context.WithValue(r.Context(), "account", account) - - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r) }) } @@ -121,3 +363,57 @@ 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) { + sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) + if err != nil && err != http.ErrNoCookie { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + var session *model.Session + + if sessionCookie != nil { + // fetch existing session + session, err = controller.GetSession(app.DB, sessionCookie.Value) + + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.Error(w, "Invalid session. Please try clearing your cookies and refresh.", http.StatusBadRequest) + return + } + 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 { + // TODO: consider running security checks here (i.e. user agent mismatches) + } + } + + 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/releasehttp.go b/admin/releasehttp.go index 503166b..7d098e6 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -14,7 +14,7 @@ func serveRelease(app *model.AppState) http.Handler { slices := strings.Split(r.URL.Path[1:], "/") releaseID := slices[0] - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) release, err := controller.GetRelease(app.DB, releaseID, true) if err != nil { @@ -56,12 +56,12 @@ func serveRelease(app *model.AppState) http.Handler { } type ReleaseResponse struct { - Account *model.Account + Session *model.Session Release *model.Release } err = pages["release"].Execute(w, ReleaseResponse{ - Account: account, + Session: session, Release: release, }) if err != nil { diff --git a/admin/static/admin.css b/admin/static/admin.css index 32f69bb..45d67a4 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -24,7 +24,7 @@ nav { justify-content: left; background: #f8f8f8; - border-radius: .5em; + border-radius: 4px; border: 1px solid #808080; } nav .icon { @@ -127,20 +127,34 @@ a img.icon { +#message, #error { - background: #ffa9b8; - border: 1px solid #dc5959; + margin: 0 0 1em 0; padding: 1em; border-radius: 4px; + background: #ffffff; + border: 1px solid #888; +} +#message { + background: #a9dfff; + border-color: #599fdc; +} +#error { + background: #ffa9b8; + border-color: #dc5959; } +a.delete { + color: #d22828; +} + button, .button { padding: .5em .8em; font-family: inherit; font-size: inherit; - border-radius: .5em; + border-radius: 4px; border: 1px solid #a0a0a0; background: #f0f0f0; color: inherit; @@ -154,35 +168,32 @@ button:active, .button:active { border-color: #808080; } -button { +.button, button { color: inherit; } -button.new { +.button.new, button.new { background: #c4ff6a; border-color: #84b141; } -button.save { +.button.save, button.save { background: #6fd7ff; border-color: #6f9eb0; } -button.delete { +.button.delete, button.delete { background: #ff7171; border-color: #7d3535; } -button:hover { +.button:hover, button:hover { background: #fff; border-color: #d0d0d0; } -button:active { +.button:active, button:active { background: #d0d0d0; border-color: #808080; } -button[disabled] { +.button[disabled], button[disabled] { background: #d0d0d0 !important; border-color: #808080 !important; opacity: .5; cursor: not-allowed !important; } -a.delete { - color: #d22828; -} diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 625db13..52fb756 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -1,14 +1,7 @@ @import url("/admin/static/index.css"); -form#change-password { - width: 100%; - display: flex; - flex-direction: column; - align-items: start; -} - -form div { - width: 20rem; +div.card { + margin-bottom: 2rem; } form button { @@ -22,7 +15,7 @@ label { color: #10101080; } input { - width: 100%; + width: min(20rem, calc(100% - 1rem)); margin: .5rem 0; padding: .3rem .5rem; display: block; @@ -33,18 +26,11 @@ input { color: inherit; } -#error { - background: #ffa9b8; - border: 1px solid #dc5959; - padding: 1em; - border-radius: 4px; -} - .mfa-device { padding: .75em; background: #f8f8f8f8; border: 1px solid #808080; - border-radius: .5em; + border-radius: 8px; margin-bottom: .5em; display: flex; justify-content: space-between; diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 793b989..5627e64 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -9,7 +9,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 10eada3..63d399e 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -11,7 +11,7 @@ input[type="text"] { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -160,7 +160,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -170,7 +170,7 @@ dialog div.dialog-actions { } .card.credits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } .card.credits .credit .artist-name { @@ -196,7 +196,7 @@ dialog div.dialog-actions { align-items: center; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -215,7 +215,7 @@ dialog div.dialog-actions { } #editcredits .credit .artist-avatar { - border-radius: .5em; + border-radius: 8px; } #editcredits .credit .credit-info { @@ -393,7 +393,7 @@ dialog div.dialog-actions { flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 8a05089..600b680 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -11,7 +11,7 @@ h1 { flex-direction: row; gap: 1.2em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/index.css b/admin/static/index.css index 9d38940..9fcd731 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -1,23 +1,5 @@ @import url("/admin/static/release-list-item.css"); -.create-btn { - background: #c4ff6a; - padding: .5em .8em; - border-radius: .5em; - border: 1px solid #84b141; - text-decoration: none; -} -.create-btn:hover { - background: #fff; - border-color: #d0d0d0; - text-decoration: inherit; -} -.create-btn:active { - background: #d0d0d0; - border-color: #808080; - text-decoration: inherit; -} - .artist { margin-bottom: .5em; padding: .5em; @@ -26,7 +8,7 @@ align-items: center; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -49,7 +31,7 @@ flex-direction: column; gap: .5em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } diff --git a/admin/static/release-list-item.css b/admin/static/release-list-item.css index ee67de7..638eac0 100644 --- a/admin/static/release-list-item.css +++ b/admin/static/release-list-item.css @@ -5,7 +5,7 @@ flex-direction: row; gap: 1em; - border-radius: .5em; + border-radius: 8px; background: #f8f8f8f8; border: 1px solid #808080; } @@ -50,7 +50,7 @@ padding: .5em; display: block; - border-radius: .5em; + border-radius: 8px; text-decoration: none; color: #f0f0f0; background: #303030; @@ -73,7 +73,7 @@ padding: .3em .5em; display: inline-block; - border-radius: .3em; + border-radius: 4px; background: #e0e0e0; transition: color .1s, background .1s; diff --git a/admin/templates.go b/admin/templates.go index 1fa7a65..1021832 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -18,10 +18,10 @@ var pages = map[string]*template.Template{ filepath.Join("views", "prideflag.html"), filepath.Join("admin", "views", "login.html"), )), - "create-account": template.Must(template.ParseFiles( + "register": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"), - filepath.Join("admin", "views", "create-account.html"), + filepath.Join("admin", "views", "register.html"), )), "logout": template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), diff --git a/admin/trackhttp.go b/admin/trackhttp.go index fa49b53..9436671 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -32,15 +32,15 @@ func serveTrack(app *model.AppState) http.Handler { } type TrackResponse struct { - Account *model.Account + Session *model.Session Track *model.Track Releases []*model.Release } - account := r.Context().Value("account").(*model.Account) + session := r.Context().Value("session").(*model.Session) err = pages["track"].Execute(w, TrackResponse{ - Account: account, + Session: session, Track: track, Releases: releases, }) diff --git a/admin/views/edit-account.html b/admin/views/edit-account.html index fd527b4..0acfaf5 100644 --- a/admin/views/edit-account.html +++ b/admin/views/edit-account.html @@ -6,29 +6,27 @@ {{define "content"}}
- {{if .Message}} -

{{.Message}}

+ {{if .Session.Message.Valid}} +

{{html .Session.Message.String}}

{{end}} - {{if .Error}} -

{{.Error}}

+ {{if .Session.Error.Valid}} +

{{html .Session.Error.String}}

{{end}} -

Account Settings ({{.Account.Username}})

+

Account Settings ({{.Session.Account.Username}})

Change Password

-
- - + + - - + + - - -
+ +
@@ -64,9 +62,17 @@

Clicking the button below will delete your account. This action is irreversible. - You will be prompted to confirm this decision. + You will need to enter your password and TOTP below.

- +
+ + + + + + + +
diff --git a/admin/views/edit-artist.html b/admin/views/edit-artist.html index ccb3a45..b0cfb41 100644 --- a/admin/views/edit-artist.html +++ b/admin/views/edit-artist.html @@ -36,13 +36,13 @@ {{if .Credits}} {{range .Credits}}
- +
-

{{.Artist.Release.Title}}

-

{{.Artist.Release.PrintArtists true true}}

+

{{.Release.Title}}

+

{{.Release.PrintArtists true true}}

- Role: {{.Artist.Role}} - {{if .Artist.Primary}} + Role: {{.Role}} + {{if .Primary}} (Primary) {{end}}

diff --git a/admin/views/index.html b/admin/views/index.html index 8f42e0e..2b9c897 100644 --- a/admin/views/index.html +++ b/admin/views/index.html @@ -9,7 +9,7 @@

Releases

- Create New + Create New
{{range .Releases}} @@ -22,7 +22,7 @@

Artists

- Create New + Create New
{{range $Artist := .Artists}} @@ -38,7 +38,7 @@

Tracks

- Create New + Create New

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

diff --git a/admin/views/layout.html b/admin/views/layout.html index bacf014..8c34c8e 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -24,9 +24,9 @@ home
- {{if .Account}} + {{if .Session.Account}}