diff --git a/admin/accounthttp.go b/admin/account/accounthttp.go similarity index 89% rename from admin/accounthttp.go rename to admin/account/accounthttp.go index 113a17a..d1b0d49 100644 --- a/admin/accounthttp.go +++ b/admin/account/accounthttp.go @@ -1,4 +1,4 @@ -package admin +package account import ( "database/sql" @@ -7,6 +7,7 @@ import ( "net/url" "os" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/log" @@ -15,12 +16,14 @@ import ( "golang.org/x/crypto/bcrypt" ) -func accountHandler(app *model.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() + mux.Handle("/", accountIndexHandler(app)) + mux.Handle("/account/totp-setup", totpSetupHandler(app)) mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) - mux.Handle("/account/totp-delete", totpDeleteHandler(app)) + mux.Handle("/account/totp-delete", http.StripPrefix("/totp-delete", totpDeleteHandler(app))) mux.Handle("/account/password", changePasswordHandler(app)) mux.Handle("/account/delete", deleteAccountHandler(app)) @@ -45,7 +48,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { } accountResponse struct { - adminPageData + core.AdminPageData TOTPs []TOTP } ) @@ -66,7 +69,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { session.Error = sessionError err = templates.AccountTemplate.Execute(w, accountResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, TOTPs: totps, }) if err != nil { @@ -93,7 +96,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { currentPassword := r.Form.Get("current-password") if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(currentPassword)); err != nil { controller.SetSessionError(app.DB, session, "Incorrect password.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -103,7 +106,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { 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.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -112,7 +115,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to update account password: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -120,7 +123,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } @@ -148,7 +151,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Incorrect password.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -156,7 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { if err != nil { fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -170,7 +173,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { } type totpConfirmData struct { - adminPageData + core.AdminPageData TOTP *model.TOTP NameEscaped string QRBase64Image string @@ -181,7 +184,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { if r.Method == http.MethodGet { session := r.Context().Value("session").(*model.Session) - err := templates.TOTPSetupTemplate.Execute(w, adminPageData{ Path: "/account", Session: session }) + err := templates.TOTPSetupTemplate.Execute(w, core.AdminPageData{ Path: "/account", Session: session }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -219,7 +222,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { fmt.Printf("WARN: Failed to create TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") err := templates.TOTPSetupTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, }) if err != nil { fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err) @@ -235,7 +238,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { } err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, TOTP: &totp, NameEscaped: url.PathEscape(totp.Name), QRBase64Image: qrBase64Image, @@ -271,7 +274,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } if totp == nil { @@ -291,7 +294,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { if len(code) != controller.TOTP_CODE_LENGTH || (code != confirmCode && code != confirmCodeOffset) { session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." } err = templates.TOTPConfirmTemplate.Execute(w, totpConfirmData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, TOTP: totp, NameEscaped: url.PathEscape(totp.Name), QRBase64Image: qrBase64Image, @@ -307,7 +310,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -315,7 +318,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } @@ -343,7 +346,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } if totp == nil { @@ -355,7 +358,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler { if err != nil { fmt.Printf("WARN: Failed to delete TOTP method: %s\n", err) controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.") - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) return } @@ -363,6 +366,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler { controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) - http.Redirect(w, r, "/admin/account", http.StatusFound) + http.Redirect(w, r, "/admin/account/", http.StatusFound) }) } diff --git a/admin/auth/authhttp.go b/admin/auth/authhttp.go new file mode 100644 index 0000000..7805638 --- /dev/null +++ b/admin/auth/authhttp.go @@ -0,0 +1,379 @@ +package auth + +import ( + "arimelody-web/admin/core" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" + "database/sql" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +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 := templates.RegisterTemplate.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 + } + + 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, core.AdminPageData{ 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, core.AdminPageData{ 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 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 +} diff --git a/admin/core/funcs.go b/admin/core/funcs.go new file mode 100644 index 0000000..c471f8a --- /dev/null +++ b/admin/core/funcs.go @@ -0,0 +1,56 @@ +package core + +import ( + "arimelody-web/controller" + "arimelody-web/model" + "context" + "fmt" + "net/http" + "os" + "strings" +) + +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) + }) +} + +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)) + }) +} diff --git a/admin/core/structs.go b/admin/core/structs.go new file mode 100644 index 0000000..1a4639f --- /dev/null +++ b/admin/core/structs.go @@ -0,0 +1,8 @@ +package core + +import "arimelody-web/model" + +type AdminPageData struct { + Path string + Session *model.Session +} diff --git a/admin/http.go b/admin/http.go index 2a6b4ae..35bb153 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,59 +1,32 @@ package admin import ( - "context" - "database/sql" "fmt" "net/http" "os" - "strings" - "time" + "arimelody-web/admin/account" + "arimelody-web/admin/auth" + "arimelody-web/admin/core" + "arimelody-web/admin/logs" + "arimelody-web/admin/music" "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 - } + mux.Handle("/register", auth.RegisterAccountHandler(app)) + mux.Handle("/login", auth.LoginHandler(app)) + mux.Handle("/totp", auth.LoginTOTPHandler(app)) + mux.Handle("/logout", core.RequireAccount(auth.LogoutHandler(app))) - w.Write([]byte("")) - })) - - 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("/music/", core.RequireAccount(http.StripPrefix("/music", music.Handler(app)))) + mux.Handle("/logs", core.RequireAccount(logs.Handler(app))) + mux.Handle("/account/", core.RequireAccount(http.StripPrefix("/account", account.Handler(app)))) mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/static/admin.css" { @@ -64,15 +37,15 @@ func Handler(app *model.AppState) http.Handler { http.ServeFile(w, r, "./admin/static/admin.js") return } - requireAccount( + core.RequireAccount( http.StripPrefix("/static", view.ServeFiles("./admin/static"))).ServeHTTP(w, r) })) - mux.Handle("/", requireAccount(AdminIndexHandler(app))) + mux.Handle("/", core.RequireAccount(AdminIndexHandler(app))) // response wrapper to make sure a session cookie exists - return enforceSession(app, mux) + return core.EnforceSession(app, mux) } func AdminIndexHandler(app *model.AppState) http.Handler { @@ -124,7 +97,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { } type IndexData struct { - adminPageData + core.AdminPageData Releases []*model.Release ReleaseCount int Artists []*model.Artist @@ -134,7 +107,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { } err = templates.IndexTemplate.Execute(w, IndexData{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Releases: releases, ReleaseCount: releaseCount, Artists: artists, @@ -150,349 +123,6 @@ func AdminIndexHandler(app *model.AppState) http.Handler { }) } -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 @@ -513,63 +143,3 @@ func staticHandler() http.Handler { }) } */ - -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 -} diff --git a/admin/logshttp.go b/admin/logs/logshttp.go similarity index 90% rename from admin/logshttp.go rename to admin/logs/logshttp.go index a6d8e40..270721e 100644 --- a/admin/logshttp.go +++ b/admin/logs/logshttp.go @@ -1,6 +1,7 @@ -package admin +package logs import ( + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/log" "arimelody-web/model" @@ -10,7 +11,7 @@ import ( "strings" ) -func logsHandler(app *model.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -51,12 +52,12 @@ func logsHandler(app *model.AppState) http.Handler { } type LogsResponse struct { - adminPageData + core.AdminPageData Logs []*log.Log } err = templates.LogsTemplate.Execute(w, LogsResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Logs: logs, }) if err != nil { diff --git a/admin/artisthttp.go b/admin/music/artisthttp.go similarity index 75% rename from admin/artisthttp.go rename to admin/music/artisthttp.go index f151ddd..822ee72 100644 --- a/admin/artisthttp.go +++ b/admin/music/artisthttp.go @@ -1,10 +1,12 @@ -package admin +package music import ( "fmt" "net/http" + "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -24,22 +26,22 @@ func serveArtists(app *model.AppState) http.Handler { artists, err := controller.GetAllArtists(app.DB) if err != nil { - fmt.Printf("WARN: Failed to fetch artists: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type ArtistsResponse struct { - adminPageData + core.AdminPageData Artists []*model.Artist } err = templates.ArtistsTemplate.Execute(w, ArtistsResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Artists: artists, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin artists page: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artists page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -55,31 +57,31 @@ func serveArtist(app *model.AppState, artistID string) http.Handler { http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) if err != nil { - fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type ArtistResponse struct { - adminPageData + core.AdminPageData Artist *model.Artist Credits []*model.Credit } err = templates.EditArtistTemplate.Execute(w, ArtistResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Artist: artist, Credits: credits, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/admin/music/musichttp.go b/admin/music/musichttp.go new file mode 100644 index 0000000..cc2ef93 --- /dev/null +++ b/admin/music/musichttp.go @@ -0,0 +1,18 @@ +package music + +import ( + "arimelody-web/model" + "net/http" +) + +func Handler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + + mux.Handle("/releases/", serveReleases(app)) + mux.Handle("/artists/", serveArtists(app)) + mux.Handle("/tracks/", serveTracks(app)) + + mux.ServeHTTP(w, r) + }) +} diff --git a/admin/releasehttp.go b/admin/music/releasehttp.go similarity index 81% rename from admin/releasehttp.go rename to admin/music/releasehttp.go index 7cca841..de77655 100644 --- a/admin/releasehttp.go +++ b/admin/music/releasehttp.go @@ -1,4 +1,4 @@ -package admin +package music import ( "fmt" @@ -6,6 +6,7 @@ import ( "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -29,7 +30,7 @@ func serveReleases(app *model.AppState) http.Handler { } type ReleasesData struct { - adminPageData + core.AdminPageData Releases []*model.Release } @@ -41,7 +42,7 @@ func serveReleases(app *model.AppState) http.Handler { } err = templates.ReleasesTemplate.Execute(w, ReleasesData{ - adminPageData: adminPageData{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session, }, @@ -65,7 +66,7 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han http.NotFound(w, r) return } - fmt.Printf("WARN: Failed to fetch full release data for %s: %s\n", releaseID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch full release data for %s: %s\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -99,20 +100,18 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han } type ReleaseResponse struct { - adminPageData + core.AdminPageData Release *model.Release } - for i, track := range release.Tracks { - track.Number = i + 1 - } + for i, track := range release.Tracks { track.Number = i + 1 } err = templates.EditReleaseTemplate.Execute(w, ReleaseResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Release: release, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin release page for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin release page for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -123,7 +122,7 @@ func serveEditCredits(release *model.Release) http.Handler { w.Header().Set("Content-Type", "text/html") err := templates.EditCreditsTemplate.Execute(w, release) if err != nil { - fmt.Printf("WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -133,7 +132,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -149,7 +148,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { Artists: artists, }) if err != nil { - fmt.Printf("WARN: Failed to serve add credits component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve add credits component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -160,7 +159,7 @@ func serveNewCredit(app *model.AppState) http.Handler { artistID := strings.Split(r.URL.Path, "/")[3] artist, err := controller.GetArtist(app.DB, artistID) if err != nil { - fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -172,7 +171,7 @@ func serveNewCredit(app *model.AppState) http.Handler { w.Header().Set("Content-Type", "text/html") err = templates.NewCreditTemplate.Execute(w, artist) if err != nil { - fmt.Printf("WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve new credit component for %s: %s\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -183,7 +182,7 @@ func serveEditLinks(release *model.Release) http.Handler { w.Header().Set("Content-Type", "text/html") err := templates.EditLinksTemplate.Execute(w, release) if err != nil { - fmt.Printf("WARN: Failed to serve edit links component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit links component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -195,9 +194,11 @@ func serveEditTracks(release *model.Release) http.Handler { type editTracksData struct { Release *model.Release } + for i, track := range release.Tracks { track.Number = i + 1 } + err := templates.EditTracksTemplate.Execute(w, editTracksData{ Release: release }) if err != nil { - fmt.Printf("WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve edit tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -207,7 +208,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -223,7 +224,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { Tracks: tracks, }) if err != nil { - fmt.Printf("WARN: Failed to add tracks component for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to add tracks component for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -234,7 +235,7 @@ func serveNewTrack(app *model.AppState) http.Handler { trackID := strings.Split(r.URL.Path, "/")[3] track, err := controller.GetTrack(app.DB, trackID) if err != nil { - fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -246,7 +247,7 @@ func serveNewTrack(app *model.AppState) http.Handler { w.Header().Set("Content-Type", "text/html") err = templates.NewTrackTemplate.Execute(w, track) if err != nil { - fmt.Printf("WARN: Failed to serve new track component for %s: %s\n", track.ID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve new track component for %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/admin/trackhttp.go b/admin/music/trackhttp.go similarity index 74% rename from admin/trackhttp.go rename to admin/music/trackhttp.go index bcb5220..80d9ba5 100644 --- a/admin/trackhttp.go +++ b/admin/music/trackhttp.go @@ -1,10 +1,12 @@ -package admin +package music import ( "fmt" "net/http" + "os" "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -24,22 +26,22 @@ func serveTracks(app *model.AppState) http.Handler { tracks, err := controller.GetAllTracks(app.DB) if err != nil { - fmt.Printf("WARN: Failed to fetch tracks: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type TracksResponse struct { - adminPageData + core.AdminPageData Tracks []*model.Track } err = templates.TracksTemplate.Execute(w, TracksResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Tracks: tracks, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin tracks page: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin tracks page: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -51,7 +53,7 @@ func serveTrack(app *model.AppState, trackID string) http.Handler { track, err := controller.GetTrack(app.DB, trackID) if err != nil { - fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -62,24 +64,24 @@ func serveTrack(app *model.AppState, trackID string) http.Handler { releases, err := controller.GetTrackReleases(app.DB, track.ID, true) if err != nil { - fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases for track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type TrackResponse struct { - adminPageData + core.AdminPageData Track *model.Track Releases []*model.Release } err = templates.EditTrackTemplate.Execute(w, TrackResponse{ - adminPageData: adminPageData{ Path: r.URL.Path, Session: session }, + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session }, Track: track, Releases: releases, }) if err != nil { - fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve admin track page for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) diff --git a/admin/static/admin.css b/admin/static/admin.css index 8f983c7..a8e17c3 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -270,6 +270,10 @@ code { border-radius: 4px; } +h1 { + margin: 0 0 .5em 0; +} + .cards { diff --git a/admin/static/index.css b/admin/static/index.css new file mode 100644 index 0000000..0e5121f --- /dev/null +++ b/admin/static/index.css @@ -0,0 +1 @@ +@import url("/admin/static/release-list-item.css"); diff --git a/admin/static/music.css b/admin/static/music.css new file mode 100644 index 0000000..9fcd731 --- /dev/null +++ b/admin/static/music.css @@ -0,0 +1,82 @@ +@import url("/admin/static/release-list-item.css"); + +.artist { + margin-bottom: .5em; + padding: .5em; + display: flex; + flex-direction: row; + align-items: center; + gap: .5em; + + border-radius: 8px; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.artist:hover { + text-decoration: hover; +} + +.artist-avatar { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 100%; +} + +.track { + margin-bottom: 1em; + padding: 1em; + display: flex; + flex-direction: column; + gap: .5em; + + border-radius: 8px; + background: #f8f8f8f8; + border: 1px solid #808080; +} + +.track p { + margin: 0; +} + +.card h2.track-title { + margin: 0; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.track-id { + width: fit-content; + font-family: "Monaspace Argon", monospace; + font-size: .8em; + font-style: italic; + line-height: 1em; + user-select: all; +} + +.track-album { + margin-left: auto; + font-style: italic; + font-size: .75em; + opacity: .5; +} + +.track-album.empty { + color: #ff2020; + opacity: 1; +} + +.track-description { + font-style: italic; +} + +.track-lyrics { + max-height: 10em; + overflow-y: scroll; +} + +.track .empty { + opacity: 0.75; +} diff --git a/admin/static/music.js b/admin/static/music.js new file mode 100644 index 0000000..8bb192f --- /dev/null +++ b/admin/static/music.js @@ -0,0 +1,74 @@ +const newReleaseBtn = document.getElementById("create-release"); +const newArtistBtn = document.getElementById("create-artist"); +const newTrackBtn = document.getElementById("create-track"); + +newReleaseBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this release:"); + if (id == null || id == "") return; + + fetch("/api/v1/music", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + if (res.ok) location = "/admin/music/releases/" + id; + else { + res.text().then(err => { + alert("Request failed: " + err); + console.error(err); + }); + } + }).catch(err => { + alert("Failed to create release. Check the console for details."); + console.error(err); + }); +}); + +newArtistBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this artist:"); + if (id == null || id == "") return; + + fetch("/api/v1/artist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/music/artists/" + id; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create artist. Check the console for details."); + console.error(err); + }); +}); + +newTrackBtn.addEventListener("click", event => { + event.preventDefault(); + const title = prompt("Enter an title for this track:"); + if (title == null || title == "") return; + + fetch("/api/v1/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({title}) + }).then(res => { + res.text().then(text => { + if (res.ok) { + location = "/admin/music/tracks/" + text; + } else { + alert("Request failed: " + text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create track. Check the console for details."); + console.error(err); + }); +}); diff --git a/admin/templates/html/components/artist/artist.html b/admin/templates/html/components/artist/artist.html index 86ac4cc..b066a3c 100644 --- a/admin/templates/html/components/artist/artist.html +++ b/admin/templates/html/components/artist/artist.html @@ -1,6 +1,6 @@ {{define "artist"}}
- {{.Name}} + {{.Name}}
{{end}} diff --git a/admin/templates/html/components/credit/addcredit.html b/admin/templates/html/components/credit/addcredit.html index 082ba17..1650b86 100644 --- a/admin/templates/html/components/credit/addcredit.html +++ b/admin/templates/html/components/credit/addcredit.html @@ -7,7 +7,7 @@ {{range $Artist := .Artists}}
  • diff --git a/admin/templates/html/components/credit/editcredits.html b/admin/templates/html/components/credit/editcredits.html index 38132e2..40b81ce 100644 --- a/admin/templates/html/components/credit/editcredits.html +++ b/admin/templates/html/components/credit/editcredits.html @@ -3,8 +3,8 @@

    Editing: Credits

    Add diff --git a/admin/templates/html/components/release/release.html b/admin/templates/html/components/release/release.html index 7a5059d..701c0fc 100644 --- a/admin/templates/html/components/release/release.html +++ b/admin/templates/html/components/release/release.html @@ -5,7 +5,7 @@

    - {{.Title}} + {{.Title}} {{.ReleaseDate.Year}} {{if not .Visible}}(hidden){{end}} @@ -13,9 +13,9 @@

    {{.PrintArtists true true}}

    {{.ReleaseType}} - ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    + ({{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}})

    diff --git a/admin/templates/html/components/track/addtrack.html b/admin/templates/html/components/track/addtrack.html index 6a2360b..002b1d4 100644 --- a/admin/templates/html/components/track/addtrack.html +++ b/admin/templates/html/components/track/addtrack.html @@ -8,7 +8,7 @@
  • diff --git a/admin/templates/html/components/track/edittracks.html b/admin/templates/html/components/track/edittracks.html index c06f0c3..dadc1a5 100644 --- a/admin/templates/html/components/track/edittracks.html +++ b/admin/templates/html/components/track/edittracks.html @@ -3,8 +3,8 @@

    Editing: Tracks

    Add @@ -12,12 +12,12 @@
      - {{range $i, $track := .Release.Tracks}} -
    • + {{range .Release.Tracks}} +
    • - {{.Add $i 1}} - {{$track.Title}} + {{.Number}} + {{.Title}}

      Delete
      diff --git a/admin/templates/html/components/track/track.html b/admin/templates/html/components/track/track.html index 4db20a3..1151589 100644 --- a/admin/templates/html/components/track/track.html +++ b/admin/templates/html/components/track/track.html @@ -4,7 +4,7 @@ {{if .Number}} {{.Number}} {{end}} - {{.Title}} + {{.Title}}

      Description

      diff --git a/admin/templates/html/edit-artist.html b/admin/templates/html/edit-artist.html index e9b829a..e3b66ea 100644 --- a/admin/templates/html/edit-artist.html +++ b/admin/templates/html/edit-artist.html @@ -39,7 +39,7 @@
      -

      {{.Release.Title}}

      +

      {{.Release.Title}}

      {{.Release.PrintArtists true true}}

      Role: {{.Role}} diff --git a/admin/templates/html/edit-release.html b/admin/templates/html/edit-release.html index 6b47f75..42f623c 100644 --- a/admin/templates/html/edit-release.html +++ b/admin/templates/html/edit-release.html @@ -100,21 +100,22 @@

      -
      +

      Credits ({{len .Release.Credits}} total)

      Edit
      + {{range .Release.Credits}}
      -

      {{.Artist.Name}}

      +

      {{.Artist.Name}}

      {{.Role}} {{if .Primary}} @@ -129,16 +130,17 @@ {{end}}

      -
    • - music + music
    • - - blog + blog
    • diff --git a/templates/html/music-gateway.html b/templates/html/music-gateway.html index febef4d..7fffedc 100644 --- a/templates/html/music-gateway.html +++ b/templates/html/music-gateway.html @@ -134,7 +134,7 @@

      TRACKS

      {{range $i, $track := .Tracks}}
      - {{$track.Add $i 1}}. {{$track.Title}} + {{add $i 1}}. {{$track.Title}} {{if $track.Description}}

      DESCRIPTION

      diff --git a/templates/templates.go b/templates/templates.go index c6ab41e..4a3eee6 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -22,15 +22,24 @@ var musicHTML string var musicGatewayHTML string // //go:embed "html/404.html" // var error404HTML string +//go:embed "html/blog.html" +var blogHTML string +//go:embed "html/blogpost.html" +var blogPostHTML string -var BaseTemplate = template.Must(template.New("base").Parse( - strings.Join([]string{ +var BaseTemplate = template.Must( + template.New("base").Funcs( + template.FuncMap{ + "add": func (a int, b int) int { return a + b }, + }, + ).Parse(strings.Join([]string{ layoutHTML, headerHTML, footerHTML, prideflagHTML, - }, "\n"), -)) + }, "\n"))) var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(indexHTML)) var MusicTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicHTML)) var MusicGatewayTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(musicGatewayHTML)) +var BlogTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(blogHTML)) +var BlogPostTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(blogPostHTML)) diff --git a/view/blog.go b/view/blog.go new file mode 100644 index 0000000..3567818 --- /dev/null +++ b/view/blog.go @@ -0,0 +1,185 @@ +package view + +import ( + "fmt" + "html/template" + "net/http" + "os" + "slices" + "strconv" + "strings" + + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +type ( + BlogView struct { + Collections []*BlogViewPostCollection + } + + BlogViewPostCollection struct { + Name string + Posts []*BlogPostView + } + + BlogPostView struct { + *model.BlogPost + Author *model.Account + Comments []*model.ThreadViewPost + Likes int + Boosts int + BlueskyURL string + MastodonURL string + } +) + +var mdRenderer = html.NewRenderer(html.RendererOptions{ + Flags: html.CommonFlags | html.HrefTargetBlank, +}) + +func BlogHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Count(r.URL.Path, "/") > 1 { + http.NotFound(w, r) + return + } + + if len(r.URL.Path) > 1 { + ServeBlogPost(app, r.URL.Path[1:]).ServeHTTP(w, r) + return + } + + dbPosts, err := controller.GetBlogPosts(app.DB, true, -1, 0) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + collections := []*BlogViewPostCollection{} + posts := []*BlogPostView{} + collectionYear := 0 + for i, post := range dbPosts { + author, err := controller.GetAccountByID(app.DB, post.AuthorID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", post.ID, err) + continue + } + + if i == 0 { + collectionYear = post.CreatedAt.Year() + } + + if post.CreatedAt.Year() != collectionYear || i == len(dbPosts) - 1 { + if i == len(dbPosts) - 1 { + posts = append(posts, &BlogPostView{ + BlogPost: post, + Author: author, + }) + } + postsCopy := slices.Clone(posts) + collections = append(collections, &BlogViewPostCollection{ + Name: strconv.Itoa(collectionYear), + Posts: postsCopy, + }) + posts = []*BlogPostView{} + collectionYear = post.CreatedAt.Year() + } + + posts = append(posts, &BlogPostView{ + BlogPost: post, + Author: author, + }) + } + + err = templates.BlogTemplate.Execute(w, BlogView{ + Collections: collections, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func ServeBlogPost(app *model.AppState, blogPostID string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blog, err := controller.GetBlogPost(app.DB, blogPostID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + http.NotFound(w, r) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if !blog.Visible { + 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 || session.Account == nil { + http.NotFound(w, r) + return + } + } + + author, err := controller.GetAccountByID(app.DB, blog.AuthorID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve author of blog %s: %v\n", blog.ID, err) + } + + // blog.Markdown += " " + + mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + md := mdParser.Parse([]byte(blog.Markdown)) + blog.HTML = template.HTML(markdown.Render(md, mdRenderer)) + + comments := []*model.ThreadViewPost{} + likeCount := 0 + boostCount := 0 + var blueskyURL string + var blueskyPost *model.ThreadViewPost + if blog.BlueskyActorID != nil && blog.BlueskyPostID != nil { + blueskyPost, err = controller.FetchThreadViewPost(*blog.BlueskyActorID, *blog.BlueskyPostID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) + } else { + comments = append(comments, blueskyPost.Replies...) + likeCount += blueskyPost.Post.LikeCount + boostCount += blueskyPost.Post.RepostCount + blueskyURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blueskyPost.Post.Author.Handle, *blog.BlueskyPostID) + } + } + + err = templates.BlogPostTemplate.Execute(w, BlogPostView{ + BlogPost: blog, + Author: author, + Comments: comments, + Likes: likeCount, + Boosts: boostCount, + BlueskyURL: blueskyURL, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering blog post: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/view/music.go b/view/music.go index 8ed5279..f64f045 100644 --- a/view/music.go +++ b/view/music.go @@ -89,7 +89,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler { err := templates.MusicGatewayTemplate.Execute(w, response) if err != nil { - fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) + fmt.Fprintf(os.Stderr, "Error rendering music gateway for %s: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return }