Something went wrong. Try again.
home to your local SPACEGIRL 💫 arimelody.space
Something went wrong. Try again.
22 kB · 575 lines
Go
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576package admin
import ( "context" "database/sql" "fmt" "net/http" "os" "strings" "time"
"arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/log" "arimelody-web/model" "arimelody-web/view"
"golang.org/x/crypto/bcrypt")
type adminPageData struct { Path string Session *model.Session}
func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux()
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family") if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>")) }))
mux.Handle("/login", loginHandler(app)) mux.Handle("/totp", loginTOTPHandler(app)) mux.Handle("/logout", requireAccount(logoutHandler(app)))
mux.Handle("/register", registerAccountHandler(app))
mux.Handle("/account", requireAccount(accountIndexHandler(app))) mux.Handle("/account/", requireAccount(accountHandler(app)))
mux.Handle("/logs", requireAccount(logsHandler(app)))
mux.Handle("/releases", requireAccount(serveReleases(app))) mux.Handle("/releases/", requireAccount(serveReleases(app))) mux.Handle("/artists", requireAccount(serveArtists(app))) mux.Handle("/artists/", requireAccount(serveArtists(app))) mux.Handle("/tracks", requireAccount(serveTracks(app))) mux.Handle("/tracks/", requireAccount(serveTracks(app)))
mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/static/admin.css" { http.ServeFile(w, r, "./admin/static/admin.css") return } if r.URL.Path == "/static/admin.js" { http.ServeFile(w, r, "./admin/static/admin.js") return } requireAccount( http.StripPrefix("/static", view.ServeFiles("./admin/static"))).ServeHTTP(w, r) }))
mux.Handle("/", requireAccount(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 }
session := r.Context().Value("session").(*model.Session)
releases, err := controller.GetAllReleases(app.DB, false, 3, 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 } releaseCount, err := controller.GetReleaseCount(app.DB, false) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }
artists, err := controller.GetAllArtists(app.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 } artistCount, err := controller.GetArtistCount(app.DB) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }
tracks, err := controller.GetOrphanTracks(app.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 } trackCount, err := controller.GetTrackCount(app.DB) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }
type IndexData struct { adminPageData Releases []*model.Release ReleaseCount int Artists []*model.Artist ArtistCount int Tracks []*model.Track TrackCount int }
err = templates.IndexTemplate.Execute(w, IndexData{ adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, Releases: releases, ReleaseCount: releaseCount, Artists: artists, ArtistCount: artistCount, Tracks: tracks, TrackCount: trackCount, }) 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 } })}
func registerAccountHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session)
if session.Account != nil { // user is already logged in http.Redirect(w, r, "/admin", http.StatusFound) return }
render := func() { err := templates.RegisterTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: 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 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 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) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 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) } } controller.SetSessionError(app.DB, session, "Invalid invite code.") 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) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") 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") { controller.SetSessionError(app.DB, session, "An account with that username already exists.") render() return } fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return }
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
err = controller.DeleteInvite(app.DB, invite.Code) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) }
// registration success! controller.SetSessionAccount(app.DB, session, &account) controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") 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)
render := func() { err := templates.LoginTemplate.Execute(w, adminPageData{ Path: r.URL.Path, 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 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
if !r.Form.Has("username") || !r.Form.Has("password") { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
username := r.FormValue("username") password := r.FormValue("password")
account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) controller.SetSessionError(app.DB, session, "Invalid username or password.") render() return } if account == nil { controller.SetSessionError(app.DB, session, "Invalid username or password.") render() return } if account.Locked { controller.SetSessionError(app.DB, session, "This account is locked.") render() return }
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) if locked := handleFailedLogin(app, account, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") } else { controller.SetSessionError(app.DB, session, "Invalid username or password.") } render() return }
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return }
if len(totps) > 0 { err = controller.SetSessionAttemptAccount(app.DB, session, account) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin/totp", http.StatusFound) return }
// login success! // TODO: log login activity to user app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
err = controller.SetSessionAccount(app.DB, session, account) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") http.Redirect(w, r, "/admin", http.StatusFound) })}
func loginTOTPHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session)
if session.AttemptAccount == nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return }
render := func() { err := templates.LoginTOTPTemplate.Execute(w, adminPageData{ Path: r.URL.Path, Session: session }) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } }
if r.Method == http.MethodGet { render() return }
if r.Method != http.MethodPost { http.NotFound(w, r) return }
r.ParseForm()
if !r.Form.Has("totp") { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
totpCode := r.FormValue("totp")
if len(totpCode) != controller.TOTP_CODE_LENGTH { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return }
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } if totpMethod == nil { app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") controller.SetSessionAttemptAccount(app.DB, session, nil) http.Redirect(w, r, "/admin", http.StatusFound) } else { controller.SetSessionError(app.DB, session, "Incorrect TOTP.") } render() return }
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } err = controller.SetSessionAttemptAccount(app.DB, session, nil) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err) } controller.SetSessionMessage(app.DB, session, "") controller.SetSessionError(app.DB, session, "") 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 = templates.LogoutTemplate.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(next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 } next.ServeHTTP(w, r) })}
/*//go:embed "static"var staticFS embed.FS
func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uri := strings.TrimPrefix(r.URL.Path, "/static") file, err := staticFS.ReadFile(filepath.Join("static", filepath.Clean(uri))) if err != nil { http.NotFound(w, r) return }
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) w.WriteHeader(http.StatusOK)
w.Write(file) })}*/
func enforceSession(app *model.AppState, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session, err := controller.GetSessionFromRequest(app, r) if err != nil { 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 { // 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)) })}
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { locked, err := controller.IncrementAccountFails(app.DB, account.ID) if err != nil { fmt.Fprintf( os.Stderr, "WARN: Failed to increment login failures for \"%s\": %v\n", account.Username, err, ) app.Log.Warn( log.TYPE_ACCOUNT, "Failed to increment login failures for \"%s\"", account.Username, ) } if locked { app.Log.Warn( log.TYPE_ACCOUNT, "Account \"%s\" was locked: %d failed login attempts (IP: %s)", account.Username, model.MAX_LOGIN_FAIL_ATTEMPTS, controller.ResolveIP(app, r), ) } return locked}