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.
-
Delete Account
+
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.PrintArtists true true}}
+
+
{{.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 @@
{{range .Releases}}
@@ -22,7 +22,7 @@
{{range $Artist := .Artists}}
@@ -38,7 +38,7 @@
"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}}
log out
diff --git a/admin/views/login.html b/admin/views/login.html
index 7744e91..e8581e8 100644
--- a/admin/views/login.html
+++ b/admin/views/login.html
@@ -52,35 +52,26 @@ input[disabled] {
{{define "content"}}
- {{if .Message}}
- {{.Message}}
+ {{if .Session.Message.Valid}}
+ {{html .Session.Message.String}}
+ {{end}}
+ {{if .Session.Error.Valid}}
+ {{html .Session.Error.String}}
{{end}}
-
- {{if .Token}}
-
-
-
- Logged in successfully.
- You should be redirected to /admin soon.
-
-
- {{else}}
-
- {{end}}
{{end}}
diff --git a/admin/views/logout.html b/admin/views/logout.html
index 1999377..f127fd6 100644
--- a/admin/views/logout.html
+++ b/admin/views/logout.html
@@ -12,13 +12,10 @@ p a {
{{define "content"}}
-
+
Logged out successfully.
- You should be redirected to / in 5 seconds.
-
+ You should be redirected to /admin/login shortly.
diff --git a/admin/views/create-account.html b/admin/views/register.html
similarity index 73%
rename from admin/views/create-account.html
rename to admin/views/register.html
index 8d59c0f..8899fd9 100644
--- a/admin/views/create-account.html
+++ b/admin/views/register.html
@@ -48,23 +48,23 @@ input {
{{define "content"}}
- {{if .Message}}
- {{.Message}}
+ {{if .Session.Error.Valid}}
+ {{html .Session.Error.String}}
{{end}}