terrible no good massive refactor commit (oh yeah and built generic sessions for admin panel)

This commit is contained in:
ari melody 2025-01-23 00:37:19 +00:00
parent cee99a6932
commit 45f33b8b46
Signed by: ari
GPG key ID: CF99829C92678188
34 changed files with 740 additions and 654 deletions

View file

@ -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 <code>%s</code>", newPassword)),
http.StatusFound,
)
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("Updating password to <code>%s</code>", 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)
})
}

View file

@ -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,
})

View file

@ -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))
})
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;

View file

@ -9,7 +9,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -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;
}

View file

@ -11,7 +11,7 @@ h1 {
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
border-radius: 8px;
background: #f8f8f8f8;
border: 1px solid #808080;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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"),

View file

@ -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,
})

View file

@ -6,29 +6,27 @@
{{define "content"}}
<main>
{{if .Message}}
<p id="message">{{.Message}}</p>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Error}}
<p id="error">{{.Error}}</p>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<h1>Account Settings ({{.Account.Username}})</h1>
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<form action="/admin/account/password" method="POST" id="change-password">
<div>
<label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password">
<label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
<label for="new-password">Password</label>
<input type="password" id="new-password" name="new-password" value="" autocomplete="new-password">
<label for="new-password">New Password</label>
<input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password">
</div>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<button type="submit" class="save">Change Password</button>
</form>
@ -64,9 +62,17 @@
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
You will need to enter your password and TOTP below.
</p>
<button class="delete" id="delete">Delete Account</button>
<form action="/admin/account/delete" method="POST">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<button type="submit" class="delete">Delete Account</button>
</form>
</div>
</main>

View file

@ -36,13 +36,13 @@
{{if .Credits}}
{{range .Credits}}
<div class="credit">
<img src="{{.Artist.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Artist.Release.ID}}">{{.Artist.Release.Title}}</a></h3>
<p class="credit-artists">{{.Artist.Release.PrintArtists true true}}</p>
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Artist.Role}}
{{if .Artist.Primary}}
Role: {{.Role}}
{{if .Primary}}
<small>(Primary)</small>
{{end}}
</p>

View file

@ -9,7 +9,7 @@
<div class="card-title">
<h1>Releases</h1>
<a class="create-btn" id="create-release">Create New</a>
<a class="button new" id="create-release">Create New</a>
</div>
<div class="card releases">
{{range .Releases}}
@ -22,7 +22,7 @@
<div class="card-title">
<h1>Artists</h1>
<a class="create-btn" id="create-artist">Create New</a>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
@ -38,7 +38,7 @@
<div class="card-title">
<h1>Tracks</h1>
<a class="create-btn" id="create-track">Create New</a>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>

View file

@ -24,9 +24,9 @@
<a href="/admin">home</a>
</div>
<div class="flex-fill"></div>
{{if .Account}}
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Account.Username}})</a>
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>

View file

@ -52,35 +52,26 @@ input[disabled] {
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
{{if .Token}}
<meta http-equiv="refresh" content="0;url=/admin/" />
<p>
Logged in successfully.
You should be redirected to <a href="/admin">/admin</a> soon.
</p>
{{else}}
<form action="/admin/login" method="POST" id="login">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username">
<input type="text" name="username" value="" autocomplete="username" required>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password">
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code">
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
</div>
<button type="submit" class="save">Login</button>
</form>
{{end}}
</main>
{{end}}

View file

@ -12,13 +12,10 @@ p a {
{{define "content"}}
<main>
<meta http-equiv="refresh" content="5;url=/" />
<meta http-equiv="refresh" content="0;url=/admin/login" />
<p>
Logged out successfully.
You should be redirected to <a href="/">/</a> in 5 seconds.
<script>
localStorage.removeItem("arime-token");
</script>
You should be redirected to <a href="/admin/login">/admin/login</a> shortly.
</p>
</main>

View file

@ -48,23 +48,23 @@ input {
{{define "content"}}
<main>
{{if .Message}}
<p id="error">{{.Message}}</p>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/register" method="POST" id="create-account">
<div>
<label for="username">Username</label>
<input type="text" name="username" value="">
<input type="text" name="username" value="" autocomplete="username" required>
<label for="email">Email</label>
<input type="text" name="email" value="">
<input type="text" name="email" value="" autocomplete="email" required>
<label for="password">Password</label>
<input type="password" name="password" value="">
<input type="password" name="password" value="" autocomplete="new-password" required>
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="">
<input type="text" name="invite" value="" autocomplete="off" required>
</div>
<button type="submit" class="new">Create Account</button>