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("/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("/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 } session := r.Context().Value("session").(*model.Session) releases, err := controller.GetAllReleases(app.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(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 } 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 } type IndexData struct { Session *model.Session Releases []*model.Release Artists []*model.Artist Tracks []*model.Track } err = pages["index"].Execute(w, IndexData{ Session: session, 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 } }) } 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 } type registerData struct { Session *model.Session } render := func() { err := pages["register"].Execute(w, registerData{ 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 } fmt.Printf( "[%s]: Account registered: %s (%s)\n", time.Now().Format(time.UnixDate), account.Username, account.ID, ) 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) 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) 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 { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 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) 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 } 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(time.UnixDate), account.Username, ) controller.SetSessionError(app.DB, session, "Invalid username or password.") 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) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") render() return } if totpMethod == nil { controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return } // TODO: log login activity to user fmt.Printf( "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", time.Now().Format(time.UnixDate), account.Username, totpMethod.Name, ) // login 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 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) { 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) }) } 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) }) } 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 && !strings.Contains(err.Error(), "no rows") { 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)) }) }