arimelody.me/admin/http.go
ari melody 570cdf6ce2
schema migration and account fixes
very close to rolling this out! just need to address some security concerns first
2025-01-20 18:55:05 +00:00

404 lines
13 KiB
Go

package admin
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/controller"
"arimelody-web/global"
"arimelody-web/model"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
type TemplateData struct {
Account *model.Account
Token string
}
func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/login", LoginHandler())
mux.Handle("/register", createAccountHandler())
mux.Handle("/logout", RequireAccount(global.DB, LogoutHandler()))
// TODO: /admin/account
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", RequireAccount(global.DB, http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", RequireAccount(global.DB, http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", RequireAccount(global.DB, http.StripPrefix("/track", serveTrack())))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
account, err := controller.GetAccountByRequest(global.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
}
releases, err := controller.GetAllReleases(global.DB, false, 0, true)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
artists, err := controller.GetAllArtists(global.DB)
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
tracks, err := controller.GetOrphanTracks(global.DB)
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
}
type IndexData struct {
Account *model.Account
Releases []*model.Release
Artists []*model.Artist
Tracks []*model.Track
}
err = pages["index"].Execute(w, IndexData{
Account: account,
Releases: releases,
Artists: artists,
Tracks: tracks,
})
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
}
}))
return mux
}
func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, err := controller.GetAccountByRequest(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 {
// 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))
})
}
func LoginHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
account, err := controller.GetAccountByRequest(global.DB, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err)
return
}
if account != nil {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
err = pages["login"].Execute(w, TemplateData{})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
return
}
if r.Method != http.MethodPost {
http.NotFound(w, r);
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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(global.DB, credentials.Username)
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
if account == nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
if err != nil {
http.Error(w, "Invalid username or password", http.StatusBadRequest)
return
}
// TODO: check TOTP
// login success!
token, err := controller.CreateToken(global.DB, account.ID, r.UserAgent())
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
cookie := http.Cookie{}
cookie.Name = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: account,
Token: token.Token,
})
if err != nil {
fmt.Printf("Error rendering admin login page: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func LogoutHandler() 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(global.DB, r)
if len(tokenStr) > 0 {
err := controller.DeleteToken(global.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 = global.COOKIE_TOKEN
cookie.Value = ""
cookie.Expires = time.Now()
if strings.HasPrefix(global.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() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkAccount, err := controller.GetAccountByRequest(global.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(global.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(global.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(global.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(global.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(global.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 = global.COOKIE_TOKEN
cookie.Value = token.Token
cookie.Expires = token.ExpiresAt
if strings.HasPrefix(global.Config.BaseUrl, "https") {
cookie.Secure = true
}
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)
err = pages["login"].Execute(w, TemplateData{
Account: &account,
Token: token.Token,
})
if err != nil {
fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
}