diff --git a/admin/accounthttp.go b/admin/account/accounthttp.go similarity index 86% rename from admin/accounthttp.go rename to admin/account/accounthttp.go index 113a17a..4595cd4 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,17 +16,21 @@ import ( "golang.org/x/crypto/bcrypt" ) -func accountHandler(app *model.AppState) http.Handler { - mux := http.NewServeMux() +func Handler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() - mux.Handle("/account/totp-setup", totpSetupHandler(app)) - mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) - mux.Handle("/account/totp-delete", totpDeleteHandler(app)) + mux.Handle("/account/", accountIndexHandler(app)) - mux.Handle("/account/password", changePasswordHandler(app)) - mux.Handle("/account/delete", deleteAccountHandler(app)) + mux.Handle("/account/totp-setup", totpSetupHandler(app)) + mux.Handle("/account/totp-confirm", totpConfirmHandler(app)) + mux.Handle("/account/totp-delete", totpDeleteHandler(app)) - return mux + mux.Handle("/account/password", changePasswordHandler(app)) + mux.Handle("/account/delete", deleteAccountHandler(app)) + + mux.ServeHTTP(w, r) + }) } func accountIndexHandler(app *model.AppState) http.Handler { @@ -45,7 +50,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { } accountResponse struct { - adminPageData + core.AdminPageData TOTPs []TOTP } ) @@ -66,7 +71,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 +98,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 +108,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 +117,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 +125,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 +153,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 +161,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 +175,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { } type totpConfirmData struct { - adminPageData + core.AdminPageData TOTP *model.TOTP NameEscaped string QRBase64Image string @@ -181,7 +186,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 +224,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 +240,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 +276,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 +296,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 +312,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 +320,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 +348,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 +360,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 +368,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/blog/blog.go b/admin/blog/blog.go new file mode 100644 index 0000000..c397f26 --- /dev/null +++ b/admin/blog/blog.go @@ -0,0 +1,133 @@ +package blog + +import ( + "arimelody-web/admin/core" + "arimelody-web/admin/templates" + "arimelody-web/controller" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "slices" +) + +func Handler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + + mux.Handle("/blogs/{id}", serveBlogPost(app)) + mux.Handle("/blogs/", serveBlogIndex(app)) + + mux.ServeHTTP(w, r) + }) +} + +type ( + blogPostCollection struct { + Year int + Posts []*model.BlogPost + } +) + +func (c *blogPostCollection) Clone() blogPostCollection { + return blogPostCollection{ + Year: c.Year, + Posts: slices.Clone(c.Posts), + } +} + +func serveBlogIndex(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + posts, err := controller.GetBlogPosts(app.DB, false, -1, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + collections := []*blogPostCollection{} + collection := blogPostCollection{ + Posts: []*model.BlogPost{}, + Year: -1, + } + for i, post := range posts { + if i == 0 { + collection.Year = post.PublishDate.Year() + } + + if post.PublishDate.Year() != collection.Year { + clone := collection.Clone() + collections = append(collections, &clone) + collection = blogPostCollection{ + Year: post.PublishDate.Year(), + Posts: []*model.BlogPost{}, + } + } + + collection.Posts = append(collection.Posts, post) + + if i == len(posts) - 1 { + collections = append(collections, &collection) + } + } + + type blogsData struct { + core.AdminPageData + TotalPosts int + Collections []*blogPostCollection + } + + err = templates.BlogsTemplate.Execute(w, blogsData{ + AdminPageData: core.AdminPageData{ + Path: r.URL.Path, + Session: session, + }, + TotalPosts: len(posts), + Collections: collections, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin blog index: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func serveBlogPost(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*model.Session) + + blogID := r.PathValue("id") + + post, err := controller.GetBlogPost(app.DB, blogID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog %s: %v\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if post == nil { + http.NotFound(w, r) + return + } + + type blogPostData struct { + core.AdminPageData + Post *model.BlogPost + } + + err = templates.EditBlogTemplate.Execute(w, blogPostData{ + AdminPageData: core.AdminPageData{ + Path: r.URL.Path, + Session: session, + }, + Post: post, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin edit page for blog %s: %v\n", blogID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} 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..e913558 100644 --- a/admin/http.go +++ b/admin/http.go @@ -1,59 +1,35 @@ package admin import ( - "context" - "database/sql" "fmt" "net/http" "os" - "strings" - "time" + "arimelody-web/admin/account" + "arimelody-web/admin/auth" + "arimelody-web/admin/blog" + "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("/logs", core.RequireAccount(logs.Handler(app))) + mux.Handle("/music/", core.RequireAccount(music.Handler(app))) + mux.Handle("/blogs/", core.RequireAccount(blog.Handler(app))) - 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("/account/", core.RequireAccount(account.Handler(app))) mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/static/admin.css" { @@ -64,18 +40,18 @@ 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 { +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) @@ -86,413 +62,93 @@ func AdminIndexHandler(app *model.AppState) http.Handler { releases, err := controller.GetAllReleases(app.DB, false, 3, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %v\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) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %v\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) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %v\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) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %v\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) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %v\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) + fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type BlogPost struct { + *model.BlogPost + Author *model.Account + } + blogPosts, err := controller.GetBlogPosts(app.DB, false, 1, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + var latestBlogPost *model.BlogPost = nil + if len(blogPosts) > 0 { latestBlogPost = blogPosts[0] } + blogCount, err := controller.GetBlogPostCount(app.DB, false) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to pull blog post count: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } type IndexData struct { - adminPageData + core.AdminPageData Releases []*model.Release ReleaseCount int Artists []*model.Artist ArtistCount int Tracks []*model.Track TrackCount int + LatestBlogPost *model.BlogPost + BlogCount int } 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, ArtistCount: artistCount, Tracks: tracks, TrackCount: trackCount, + LatestBlogPost: latestBlogPost, + BlogCount: blogCount, }) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to render admin index: %v\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 @@ -513,63 +169,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 70% rename from admin/artisthttp.go rename to admin/music/artisthttp.go index f151ddd..12855c6 100644 --- a/admin/artisthttp.go +++ b/admin/music/artisthttp.go @@ -1,10 +1,11 @@ -package admin +package music import ( "fmt" "net/http" - "strings" + "os" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -14,32 +15,24 @@ func serveArtists(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/artists")[1:], "/") - artistID := slices[0] - - if len(artistID) > 0 { - serveArtist(app, artistID).ServeHTTP(w, r) - return - } - 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: %v\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: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -55,31 +48,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: %v\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 fetch artist credits for %s: %v\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: %v\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..562d83f --- /dev/null +++ b/admin/music/musichttp.go @@ -0,0 +1,32 @@ +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.HandleFunc("/music/releases/{id}/", func(w http.ResponseWriter, r *http.Request) { + serveEditRelease(app, r.PathValue("id")).ServeHTTP(w, r) + }) + mux.HandleFunc("/music/releases/{id}", func(w http.ResponseWriter, r *http.Request) { + serveRelease(app, r.PathValue("id")).ServeHTTP(w, r) + }) + mux.Handle("/music/releases/", serveReleases(app)) + + mux.HandleFunc("/music/artists/{id}", func(w http.ResponseWriter, r *http.Request) { + serveArtist(app, r.PathValue("id")).ServeHTTP(w, r) + }) + mux.Handle("/music/artists/", serveArtists(app)) + + mux.HandleFunc("/music/tracks/{id}", func(w http.ResponseWriter, r *http.Request) { + serveTrack(app, r.PathValue("id")).ServeHTTP(w, r) + }) + mux.Handle("/music/tracks/", serveTracks(app)) + + mux.ServeHTTP(w, r) + }) +} diff --git a/admin/releasehttp.go b/admin/music/releasehttp.go similarity index 66% rename from admin/releasehttp.go rename to admin/music/releasehttp.go index 7cca841..8ab0123 100644 --- a/admin/releasehttp.go +++ b/admin/music/releasehttp.go @@ -1,11 +1,11 @@ -package admin +package music import ( "fmt" "net/http" "os" - "strings" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -15,115 +15,101 @@ func serveReleases(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/releases")[1:], "/") - releaseID := slices[0] - - var action string = "" - if len(slices) > 1 { - action = slices[1] - } - - if len(releaseID) > 0 { - serveRelease(app, releaseID, action).ServeHTTP(w, r) - return - } - type ReleasesData struct { - adminPageData + core.AdminPageData Releases []*model.Release } releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } err = templates.ReleasesTemplate.Execute(w, ReleasesData{ - adminPageData: adminPageData{ + AdminPageData: core.AdminPageData{ Path: r.URL.Path, Session: session, }, Releases: releases, }) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %s\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to serve releases page: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } }) } -func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { +func serveRelease(app *model.AppState, releaseID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*model.Session) - release, err := controller.GetRelease(app.DB, releaseID, true) if err != nil { - if strings.Contains(err.Error(), "no rows") { - 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 release %s: %v\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - - if len(action) > 0 { - switch action { - case "editcredits": - serveEditCredits(release).ServeHTTP(w, r) - return - case "addcredit": - serveAddCredit(app, release).ServeHTTP(w, r) - return - case "newcredit": - serveNewCredit(app).ServeHTTP(w, r) - return - case "editlinks": - serveEditLinks(release).ServeHTTP(w, r) - return - case "edittracks": - serveEditTracks(release).ServeHTTP(w, r) - return - case "addtrack": - serveAddTrack(app, release).ServeHTTP(w, r) - return - case "newtrack": - serveNewTrack(app).ServeHTTP(w, r) - return - } + if release == nil { http.NotFound(w, r) return } + session := r.Context().Value("session").(*model.Session) + 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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) } +func serveEditRelease(app *model.AppState, releaseID string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release, err := controller.GetRelease(app.DB, releaseID, true) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch release %s: %v\n", releaseID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if release == nil { + http.NotFound(w, r) + return + } + + mux := http.NewServeMux() + + mux.Handle("GET /music/releases/{id}/editcredits", serveEditCredits(release)) + mux.Handle("GET /music/releases/{id}/addcredit", serveAddCredit(app, release)) + mux.Handle("GET /music/releases/{id}/newcredit/{artistID}", serveNewCredit(app)) + + mux.Handle("GET /music/releases/{id}/editlinks", serveEditLinks(release)) + + mux.Handle("GET /music/releases/{id}/edittracks", serveEditTracks(release)) + mux.Handle("GET /music/releases/{id}/addtrack", serveAddTrack(app, release)) + mux.Handle("GET /music/releases/{id}/newtrack/{trackID}", serveNewTrack(app)) + + mux.ServeHTTP(w, r) + }) +} + func serveEditCredits(release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -133,7 +119,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -149,7 +135,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -157,10 +143,10 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { func serveNewCredit(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artistID := strings.Split(r.URL.Path, "/")[3] + artistID := r.PathValue("artistID") 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: %v\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -172,7 +158,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: %v\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -183,7 +169,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -195,9 +181,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -207,7 +195,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -223,7 +211,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: %v\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -231,10 +219,10 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { func serveNewTrack(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - trackID := strings.Split(r.URL.Path, "/")[3] + trackID := r.PathValue("trackID") 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: %v\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -246,7 +234,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: %v\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 69% rename from admin/trackhttp.go rename to admin/music/trackhttp.go index bcb5220..942b3e9 100644 --- a/admin/trackhttp.go +++ b/admin/music/trackhttp.go @@ -1,10 +1,11 @@ -package admin +package music import ( "fmt" "net/http" - "strings" + "os" + "arimelody-web/admin/core" "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" @@ -14,32 +15,24 @@ func serveTracks(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - slices := strings.Split(strings.TrimPrefix(r.URL.Path, "/tracks")[1:], "/") - trackID := slices[0] - - if len(trackID) > 0 { - serveTrack(app, trackID).ServeHTTP(w, r) - return - } - 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: %v\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: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -51,7 +44,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 fetch track %s: %v\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -62,24 +55,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: %v\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: %v\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..d80e477 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -111,7 +111,7 @@ body { font-family: "Inter", sans-serif; font-size: 16px; color: var(--fg-0); - background: var(--bg-0); + background-color: var(--bg-0); transition: background .1s ease-out, color .1s ease-out; } @@ -252,12 +252,6 @@ a { transition: color .1s ease-out, background-color .1s ease-out; } -/* -a:hover { - text-decoration: underline; -} -*/ - img.icon { height: .8em; transition: filter .1s ease-out; @@ -283,7 +277,7 @@ code { .card { flex-basis: 40em; padding: 1em; - background: var(--bg-1); + background-color: var(--bg-1); border-radius: 16px; box-shadow: var(--shadow-lg); @@ -361,7 +355,7 @@ a.delete:not(.button) { font-size: inherit; color: inherit; - background: var(--bg-2); + background-color: var(--bg-2); border: none; border-radius: 10em; box-shadow: var(--shadow-sm); @@ -380,27 +374,27 @@ button:active, .button:active { .button.new, button.new { color: var(--col-on-new); - background: var(--col-new); + background-color: var(--col-new); } .button.save, button.save { color: var(--col-on-save); - background: var(--col-save); + background-color: var(--col-save); } .button.delete, button.delete { color: var(--col-on-delete); - background: var(--col-delete); + background-color: var(--col-delete); } .button:hover, button:hover { color: var(--bg-3); - background: var(--fg-3); + background-color: var(--fg-3); } .button:active, button:active { color: var(--bg-2); - background: var(--fg-0); + background-color: var(--fg-0); } .button[disabled], button[disabled] { color: var(--fg-0) !important; - background: var(--bg-3) !important; + background-color: var(--bg-3) !important; opacity: .5; cursor: default !important; } @@ -436,6 +430,39 @@ input[disabled] { cursor: not-allowed; } + + +.actions { + margin-top: .5em; + display: flex; + flex-direction: row; + gap: .5em; + user-select: none; + color: var(--fg-3); +} +.actions a, +.actions button { + padding: .3em .5em; + display: inline-block; + + border-radius: 4px; + background-color: var(--bg-3); + box-shadow: var(--shadow-sm); + + transition-property: color, background, transform; + transition-duration: .1s; + transition-timing-function: ease-out; +} +.actions a:hover, +.actions button:hover { + background-color: var(--bg-0); + color: var(--fg-3); + text-decoration: none; + transform: scale(1.05); +} + + + @media screen and (max-width: 720px) { main { padding-top: 0; diff --git a/admin/static/artists.css b/admin/static/artists.css index 516a998..904bc70 100644 --- a/admin/static/artists.css +++ b/admin/static/artists.css @@ -2,18 +2,22 @@ padding: .5em; color: var(--fg-3); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); border-radius: 16px; text-align: center; cursor: pointer; - transition: background .1s ease-out, color .1s ease-out; + + transition-property: background, color, transform; + transition-duration: .1s; + transition-timing-function: ease-out; } .artist:hover { - background: var(--bg-1); + background-color: var(--bg-1); text-decoration: hover; + transform: scale(1.1); } .artist .artist-avatar { diff --git a/admin/static/artists.js b/admin/static/artists.js index 29eab22..3a0e326 100644 --- a/admin/static/artists.js +++ b/admin/static/artists.js @@ -4,4 +4,29 @@ document.addEventListener("readystatechange", () => { document.querySelectorAll(".artists-group .artist").forEach(el => { hijackClickEvent(el, el.querySelector("a.artist-name")) }); + + const newArtistBtn = document.getElementById("create-artist"); + if (newArtistBtn) 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(text); + console.error(text); + } + }) + }).catch(err => { + alert("Failed to create artist. Check the console for details."); + console.error(err); + }); + }); }); diff --git a/admin/static/blog.css b/admin/static/blog.css new file mode 100644 index 0000000..da3929e --- /dev/null +++ b/admin/static/blog.css @@ -0,0 +1,62 @@ +.blog-collection { + margin-bottom: 1em; + display: flex; + flex-direction: column; + gap: .5em; +} + +.blog-collection h2 { + margin: 0 0 0 1em; + font-size: 1em; + text-transform: uppercase; + font-weight: 600; + color: var(--fg-0); +} + +.blogpost { + padding: 1em; + display: block; + border-radius: 8px; + background-color: var(--bg-2); + box-shadow: var(--shadow-md); +} + +.blogpost .title { + margin: 0; + font-size: 1.5em; +} + +.blogpost .title small { + display: inline-block; + font-size: .6em; + transform: translateY(-0.1em); + color: var(--fg-0); +} + +.blogpost .description { + margin: .5em 0 .6em 0; + color: var(--fg-1); +} + +.blogpost .meta { + margin: 0; + font-size: .8em; + color: var(--fg-0); +} + +.blogpost .meta .author { + color: var(--fg-1); +} + +.blogpost .meta .author img { + width: 1.3em; + height: 1.3em; + margin-right: .2em; + display: inline-block; + transform: translate(0, 4px); + border-radius: 4px; +} + +.blogpost a:hover { + text-decoration: underline; +} diff --git a/admin/static/blog.js b/admin/static/blog.js new file mode 100644 index 0000000..3ce9111 --- /dev/null +++ b/admin/static/blog.js @@ -0,0 +1,25 @@ +document.addEventListener('readystatechange', () => { + const newBlogBtn = document.getElementById("create-post"); + if (newBlogBtn) newBlogBtn.addEventListener("click", event => { + event.preventDefault(); + const id = prompt("Enter an ID for this blog post:"); + if (id == null || id == "") return; + + fetch("/api/v1/blog", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({id}) + }).then(res => { + if (res.ok) location = "/admin/blogs/" + id; + else { + res.text().then(err => { + alert(err); + console.error(err); + }); + } + }).catch(err => { + alert("Failed to create release. Check the console for details."); + console.error(err); + }); + }); +}); diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index c43d6e9..8e89cbe 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -33,7 +33,7 @@ form#delete-account input { justify-content: space-between; color: var(--fg-3); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); border-radius: 16px; } diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 7bf146b..37b016c 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -6,7 +6,7 @@ gap: 1.2em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -50,26 +50,11 @@ input[type="text"] { font-family: inherit; font-weight: inherit; color: inherit; - background: var(--bg-0); + background-color: var(--bg-0); border: none; border-radius: 4px; outline: none; } -input[type="text"]:hover { - border-color: #80808080; -} -input[type="text"]:active, -input[type="text"]:focus { - border-color: #808080; -} - -.artist-actions { - margin-top: auto; - display: flex; - gap: .5em; - flex-direction: row; - justify-content: right; -} .card-header a.button { text-decoration: none; @@ -84,7 +69,7 @@ input[type="text"]:focus { align-items: center; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); cursor: pointer; @@ -92,7 +77,7 @@ input[type="text"]:focus { } .credit:hover { - background: var(--bg-1); + background-color: var(--bg-1); } .release-artwork { diff --git a/admin/static/edit-blog.css b/admin/static/edit-blog.css new file mode 100644 index 0000000..788d7fb --- /dev/null +++ b/admin/static/edit-blog.css @@ -0,0 +1,140 @@ +input[type="text"] { + padding: .3em .5em; + font-size: inherit; + font-family: inherit; + border: none; + border-radius: 4px; + outline: none; + color: var(--fg-3); + background-color: var(--bg-2); + box-shadow: var(--shadow-sm); + + transition: background-color .1s ease-out, color .1s ease-out; +} + +#blogpost { + margin-bottom: 1em; + padding: 1.5em; + + border-radius: 8px; + background-color: var(--bg-1); + box-shadow: var(--shadow-lg); + + transition: background-color .1s ease-out, color .1s ease-out; +} + +#blogpost label { + margin: 1.2em 0 .2em .1em; + display: block; + font-size: .8em; + text-transform: uppercase; + font-weight: 600; +} +#blogpost label:first-of-type { + margin-top: 0; +} + +#blogpost button#set-current-date { + margin: 0 .5em; + padding: .4em .8em; +} + +#blogpost h2 { + margin: 0; + font-size: 2em; +} + +#blogpost #title { + width: 100%; + margin: 0 -.2em; + padding: 0 .2em; + resize: none; + font-family: inherit; + font-size: inherit; + font-weight: bold; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--fg-3); + outline: none; + cursor: pointer; + + transition: background-color .1s ease-out, color .1s ease-out, border-color .1s ease-out; + + /*position: relative; outline: none;*/ + white-space: pre-wrap; overflow-wrap: break-word; +} + +#blogpost #title:hover { + background-color: var(--bg-3); + border-color: var(--fg-0); +} + +#blogpost #title:active, +#blogpost #title:focus { + background-color: var(--bg-3); +} + +#blogpost #publish-date { + padding: .4em .5em; + font-family: inherit; + font-size: inherit; + border-radius: 4px; + border: none; + background-color: var(--bg-2); + color: var(--fg-3); + box-shadow: var(--shadow-sm); + + transition: background-color .1s ease-out, color .1s ease-out; +} + +#blogpost textarea { + width: calc(100% - 2em); + margin: 0; + padding: 1em; + display: block; + border: none; + border-radius: 4px; + background-color: var(--bg-2); + color: var(--fg-3); + box-shadow: var(--shadow-md); + resize: vertical; + outline: none; + + transition: background-color .1s ease-out, color .1s ease-out; +} + +#blogpost #description { + font-family: inherit; +} + +#blogpost select { + padding: .5em .8em; + font-size: inherit; + border: none; + border-radius: 10em; + color: var(--fg-3); + background-color: var(--bg-2); + box-shadow: var(--shadow-sm); + + transition: background-color .1s ease-out, color .1s ease-out; +} + +#blogpost .social-post-details { + margin: 1em 0 1em 0; + display: flex; + gap: 1em; +} + +#blogpost .blog-actions { + margin-top: 1em; +} + +@media (prefers-color-scheme: dark) { + input[type="text"], + #blogpost #publish-date, + #blogpost textarea, + #blogpost select { + background-color: var(--bg-0); + } +} diff --git a/admin/static/edit-blog.js b/admin/static/edit-blog.js new file mode 100644 index 0000000..2c5911f --- /dev/null +++ b/admin/static/edit-blog.js @@ -0,0 +1,83 @@ +const blogID = document.getElementById("blogpost").dataset.id; +const titleInput = document.getElementById("title"); +const publishDateInput = document.getElementById("publish-date"); +const setCurrentDateBtn = document.getElementById("set-current-date"); +const descInput = document.getElementById("description"); +const mdInput = document.getElementById("markdown"); +const blueskyActorInput = document.getElementById("bluesky-actor"); +const blueskyRecordInput = document.getElementById("bluesky-record"); +const fediverseAccountInput = document.getElementById("fediverse-account"); +const fediverseStatusInput = document.getElementById("fediverse-status"); +const visInput = document.getElementById("visibility"); +const saveBtn = document.getElementById("save"); +const deleteBtn = document.getElementById("delete"); + +setCurrentDateBtn.addEventListener("click", () => { + let now = new Date; + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + publishDateInput.value = now.toISOString().slice(0, 16); + saveBtn.disabled = false; +}); + +saveBtn.addEventListener("click", () => { + fetch("/api/v1/blog/" + blogID, { + method: "PUT", + body: JSON.stringify({ + title: titleInput.innerText, + publish_date: publishDateInput.value + ":00Z", + description: descInput.value, + markdown: mdInput.value, + bluesky: { + actor: blueskyActorInput.value, + record: blueskyRecordInput.value, + }, + fediverse: { + account: fediverseAccountInput.value, + status: fediverseStatusInput.value, + }, + visible: visInput.value === "true", + }), + headers: { "Content-Type": "application/json" } + }).then(res => { + if (!res.ok) { + res.text().then(error => { + console.error(error); + alert("Failed to update blog post: " + error); + }); + return; + } + + location = location; + }); +}); + +deleteBtn.addEventListener("click", () => { + if (blogID != prompt( + "You are about to permanently delete " + blogID + ". " + + "This action is irreversible. " + + "Please enter \"" + blogID + "\" to continue.")) return; + fetch("/api/v1/blog/" + blogID, { + method: "DELETE", + }).then(res => { + if (!res.ok) { + res.text().then(error => { + console.error(error); + alert("Failed to delete blog post: " + error); + }); + return; + } + + location = "/admin"; + }); +}); + +[titleInput, publishDateInput, descInput, mdInput, visInput, + blueskyActorInput, blueskyRecordInput, + fediverseAccountInput, fediverseStatusInput].forEach(input => { + input.addEventListener("change", () => { + saveBtn.disabled = false; + }); + input.addEventListener("keypress", () => { + saveBtn.disabled = false; + }); + }); diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 434b487..bbd9fdc 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -12,7 +12,7 @@ input[type="text"] { gap: 1.2em; border-radius: 8px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -25,6 +25,8 @@ input[type="text"] { .release-artwork img { width: 100%; aspect-ratio: 1; + border-radius: 8px; + box-shadow: var(--shadow-md); } .release-artwork img:hover { outline: 1px solid #808080; @@ -33,7 +35,10 @@ input[type="text"] { .release-artwork #remove-artwork { margin-top: .5em; padding: .3em .6em; - background: var(--bg-3); + background-color: var(--bg-3); +} +#remove-artwork:hover { + background-color: var(--fg-3); } .release-info { @@ -62,13 +67,13 @@ input[type="text"] { } #title:hover { - background: var(--bg-3); + background-color: var(--bg-3); border-color: var(--fg-0); } #title:active, #title:focus { - background: var(--bg-3); + background-color: var(--bg-3); } .release-title small { @@ -93,7 +98,7 @@ input[type="text"] { .release-info table tr td:not(:first-child) select:hover, .release-info table tr td:not(:first-child) input:hover, .release-info table tr td:not(:first-child) textarea:hover { - background: var(--bg-3); + background-color: var(--bg-3); cursor: pointer; } .release-info table td select, @@ -127,7 +132,7 @@ input[type="text"] { .release-actions button, .release-actions .button { color: var(--fg-2); - background: var(--bg-3); + background-color: var(--bg-3); } dialog { @@ -234,7 +239,7 @@ dialog div.dialog-actions { gap: 1em; border-radius: 8px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -280,7 +285,7 @@ dialog div.dialog-actions { border: none; border-radius: 4px; color: var(--fg-2); - background: var(--bg-0); + background-color: var(--bg-0); } #editcredits .credit .credit-info .credit-attribute input[type="checkbox"] { margin: 0 .3em; @@ -299,6 +304,7 @@ dialog div.dialog-actions { #editcredits .credit .delete { margin-right: .5em; cursor: pointer; + overflow: visible; } #editcredits .credit .delete:hover { text-decoration: underline; @@ -315,14 +321,17 @@ dialog div.dialog-actions { display: flex; gap: .5em; cursor: pointer; + background-color: var(--bg-2); } #addcredit ul li.new-artist:nth-child(even) { background: #f0f0f0; + background-color: var(--bg-1); } #addcredit ul li.new-artist:hover { background: #e0e0e0; + background-color: var(--bg-2); } #addcredit .new-artist .artist-id { @@ -375,6 +384,8 @@ dialog div.dialog-actions { #editlinks tr { display: flex; + background-color: var(--bg-1); + transition: background-color .1s ease-out; } #editlinks th { @@ -385,7 +396,7 @@ dialog div.dialog-actions { } #editlinks tr:nth-child(odd) { - background: #f8f8f8; + background-color: var(--bg-2); } #editlinks tr th, @@ -416,6 +427,11 @@ dialog div.dialog-actions { width: 1em; pointer-events: none; } +@media (prefers-color-scheme: dark) { + #editlinks tr .grabber img { + filter: invert(); + } +} #editlinks tr .link-name { width: 8em; } @@ -454,6 +470,7 @@ dialog div.dialog-actions { } #edittracks .track { + background-color: var(--bg-1); transition: transform .2s ease-out, opacity .2s; } @@ -476,7 +493,7 @@ dialog div.dialog-actions { } #edittracks .track:nth-child(even) { - background: #f0f0f0; + background-color: var(--bg-0); } #edittracks .track-number { @@ -492,24 +509,23 @@ dialog div.dialog-actions { #addtrack ul { padding: 0; list-style: none; - background: #f8f8f8; } #addtrack ul li.new-track { padding: .5em; display: flex; gap: .5em; - background-color: var(--bg-0); + background-color: var(--bg-1); cursor: pointer; transition: background-color .1s ease-out, color .1s ease-out; } #addtrack ul li.new-track:nth-child(even) { - background: color-mix(in srgb, var(--bg-0) 95%, #fff); + background-color: var(--bg-0); } #addtrack ul li.new-track:hover { - background: color-mix(in srgb, var(--bg-0) 90%, #fff); + background-color: var(--bg-2); } @media only screen and (max-width: 1105px) { diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index f292ca5..4824124 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -8,7 +8,7 @@ gap: 1.2em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -45,25 +45,13 @@ font-weight: inherit; font-family: inherit; font-size: inherit; - background: var(--bg-0); + background-color: var(--bg-0); border: none; border-radius: 4px; outline: none; color: inherit; } -.track-info input[type="text"]:hover, -.track-info textarea:hover { - border-color: #80808080; -} - -.track-info input[type="text"]:active, -.track-info textarea:active, -.track-info input[type="text"]:focus, -.track-info textarea:focus { - border-color: #808080; -} - .track-actions { margin-top: 1em; display: flex; 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/logs.css b/admin/static/logs.css index 8da60d0..2412a2b 100644 --- a/admin/static/logs.css +++ b/admin/static/logs.css @@ -8,7 +8,7 @@ form#search-form { padding: 1em; border-radius: 16px; color: var(--fg-0); - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); } @@ -23,7 +23,7 @@ div#search { border: none; border-radius: 16px; color: var(--fg-1); - background: var(--bg-0); + background-color: var(--bg-0); box-shadow: var(--shadow-sm); } @@ -100,8 +100,8 @@ td.log-content { #logs .log.warn { color: var(--col-on-warn); - background: var(--col-warn); + background-color: var(--col-warn); } #logs .log.warn:hover { - background: var(--col-warn-hover); + background-color: var(--col-warn-hover); } 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/index.js b/admin/static/music.js similarity index 92% rename from admin/static/index.js rename to admin/static/music.js index 60bdfd0..8bb192f 100644 --- a/admin/static/index.js +++ b/admin/static/music.js @@ -12,7 +12,7 @@ newReleaseBtn.addEventListener("click", event => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({id}) }).then(res => { - if (res.ok) location = "/admin/releases/" + id; + if (res.ok) location = "/admin/music/releases/" + id; else { res.text().then(err => { alert("Request failed: " + err); @@ -37,7 +37,7 @@ newArtistBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/artists/" + id; + location = "/admin/music/artists/" + id; } else { alert("Request failed: " + text); console.error(text); @@ -61,7 +61,7 @@ newTrackBtn.addEventListener("click", event => { }).then(res => { res.text().then(text => { if (res.ok) { - location = "/admin/tracks/" + text; + location = "/admin/music/tracks/" + text; } else { alert("Request failed: " + text); console.error(text); diff --git a/admin/static/releases.css b/admin/static/releases.css index 0694875..7b7a483 100644 --- a/admin/static/releases.css +++ b/admin/static/releases.css @@ -6,7 +6,7 @@ gap: 1em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -54,27 +54,3 @@ flex-wrap: wrap; gap: .5em; } - -.release .release-actions { - margin-top: .5em; - user-select: none; - color: var(--fg-3); -} - -.release .release-actions a { - margin-right: .3em; - padding: .3em .5em; - display: inline-block; - - border-radius: 4px; - background: var(--bg-3); - box-shadow: var(--shadow-sm); - - transition: color .1s ease-out, background .1s ease-out; -} - -.release .release-actions a:hover { - background: var(--bg-0); - color: var(--fg-3); - text-decoration: none; -} diff --git a/admin/static/releases.js b/admin/static/releases.js new file mode 100644 index 0000000..a606fde --- /dev/null +++ b/admin/static/releases.js @@ -0,0 +1,25 @@ +document.addEventListener('readystatechange', () => { + const newReleaseBtn = document.getElementById("create-release"); + if (newReleaseBtn) 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(err); + console.error(err); + }); + } + }).catch(err => { + alert("Failed to create release. Check the console for details."); + console.error(err); + }); + }); +}); diff --git a/admin/static/tracks.css b/admin/static/tracks.css index c36c1b1..3ea4f06 100644 --- a/admin/static/tracks.css +++ b/admin/static/tracks.css @@ -12,7 +12,7 @@ gap: .5em; border-radius: 16px; - background: var(--bg-2); + background-color: var(--bg-2); box-shadow: var(--shadow-md); transition: background .1s ease-out, color .1s ease-out; @@ -44,11 +44,6 @@ opacity: .5; } -#tracks .track-album.empty { - color: #ff2020; - opacity: 1; -} - #tracks .track-description { font-style: italic; } @@ -67,61 +62,4 @@ margin: 0; display: flex; flex-direction: row; - /* - justify-content: space-between; - */ } - -/* -.track { - margin-bottom: 1em; - padding: 1em; - display: flex; - flex-direction: column; - gap: .5em; - - border-radius: 8px; - background-color: var(--bg-2); - box-shadow: var(--shadow-md); - - transition: color .1s ease-out, background-color .1s ease-out; -} - -.track p { - margin: 0; -} - -.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/tracks.js b/admin/static/tracks.js new file mode 100644 index 0000000..3cf7867 --- /dev/null +++ b/admin/static/tracks.js @@ -0,0 +1,24 @@ +const newTrackBtn = document.getElementById("create-track"); +if (newTrackBtn) 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(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/blogs.html b/admin/templates/html/blogs.html new file mode 100644 index 0000000..143d848 --- /dev/null +++ b/admin/templates/html/blogs.html @@ -0,0 +1,33 @@ +{{define "head"}} +Blog - ari melody 💫 + + +{{end}} + +{{define "content"}} +
+
+

Blog Posts ({{.TotalPosts}} total)

+ Create New +
+ + {{if .Collections}} +
+ {{range .Collections}} + {{if .Posts}} +
+

{{.Year}}

+ {{range .Posts}} + {{block "blogpost" .}}{{end}} + {{end}} +
+ {{end}} + {{end}} +
+ {{else}} +

There are no blog posts.

+ {{end}} +
+ + +{{end}} 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/blog/blogpost.html b/admin/templates/html/components/blog/blogpost.html new file mode 100644 index 0000000..11771d0 --- /dev/null +++ b/admin/templates/html/components/blog/blogpost.html @@ -0,0 +1,14 @@ +{{define "blogpost"}} +
+

{{.Title}}{{if not .Visible}} (Not published){{end}}

+

+ {{.Author.DisplayName}} + • {{.PrintDate}} +

+

{{.Description}}

+
+ Edit + View +
+
+{{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..5cefcb4 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}})

    - 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..6469c4c 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
      @@ -49,7 +49,6 @@ deleteBtn.addEventListener("click", e => { e.preventDefault(); - if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return; trackItem.remove(); refreshTrackNumbers(); }); 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-blog.html b/admin/templates/html/edit-blog.html new file mode 100644 index 0000000..c150bca --- /dev/null +++ b/admin/templates/html/edit-blog.html @@ -0,0 +1,118 @@ +{{define "head"}} +Editing {{.Post.Title}} - ari melody 💫 + + +{{end}} + +{{define "content"}} +

      +

      Editing Blog Post

      + +
      + +

      +
      {{.Post.Title}}
      +

      + + + + + + + + + + + + + + + + + + +
      + View + +
      +
      + +
      +
      +

      Danger Zone

      +
      +

      + Clicking the button below will delete this blog post. + This action is irreversible. + You will be prompted to confirm this decision. +

      + +
      +
      + + +{{end}} diff --git a/admin/templates/html/edit-release.html b/admin/templates/html/edit-release.html index 6b47f75..1ca31fe 100644 --- a/admin/templates/html/edit-release.html +++ b/admin/templates/html/edit-release.html @@ -8,7 +8,7 @@ {{define "content"}}
      -

      Editing {{.Release.Title}}

      +

      Editing Release

      @@ -100,21 +100,22 @@
      -
      +

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

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

      {{.Artist.Name}}

      +

      {{.Artist.Name}}

      {{.Role}} {{if .Primary}} @@ -125,40 +126,49 @@

      {{end}} {{if not .Release.Credits}} -

      There are no credits.

      +

      This release has no credits.

      {{end}}
      -
      + - + + {{end}} diff --git a/admin/templates/html/layout.html b/admin/templates/html/layout.html index bf1b542..9f32eaa 100644 --- a/admin/templates/html/layout.html +++ b/admin/templates/html/layout.html @@ -27,24 +27,33 @@ +
      + -
    • - music + music
    • - - blog + blog
    • diff --git a/templates/html/music-gateway.html b/templates/html/music-gateway.html index febef4d..9ad212e 100644 --- a/templates/html/music-gateway.html +++ b/templates/html/music-gateway.html @@ -83,9 +83,9 @@ {{else if .IsSingle}} - {{$Track := index .Tracks 0}} - {{if $Track.Description}} -

      {{$Track.Description}}

      + {{index .Tracks 0}} + {{if .Description}} +

      {{.Description}}

      {{end}} {{end}} @@ -132,18 +132,18 @@ {{else if .Tracks}}

      TRACKS

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

      DESCRIPTION

      - {{$track.Description}} + {{.Description}} {{end}}

      LYRICS

      - {{if $track.Lyrics}} - {{$track.GetLyricsHTML}} + {{if .Lyrics}} + {{.GetLyricsHTML}} {{else}} No lyrics. {{end}} diff --git a/templates/templates.go b/templates/templates.go index c6ab41e..d08aee8 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -22,15 +22,20 @@ 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").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..80929f5 --- /dev/null +++ b/view/blog.go @@ -0,0 +1,175 @@ +package view + +import ( + "fmt" + "html/template" + "net/http" + "os" + "slices" + + "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 []*blogPostCollection + } + + blogPostCollection struct { + Year int + Posts []*model.BlogPost + } + + blogPostView struct { + *model.BlogPost + BlogHTML template.HTML + Comments []*model.ThreadViewPost + Likes int + Boosts int + BlueskyURL string + MastodonURL string + } +) + +func (c *blogPostCollection) Clone() blogPostCollection { + return blogPostCollection{ + Year: c.Year, + Posts: slices.Clone(c.Posts), + } +} + +var mdRenderer = html.NewRenderer(html.RendererOptions{ + Flags: html.CommonFlags | html.HrefTargetBlank, +}) + +func BlogHandler(app *model.AppState) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) { + ServeBlogPost(app, r.PathValue("id")).ServeHTTP(w, r) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + posts, err := controller.GetBlogPosts(app.DB, true, -1, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog posts: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + collections := []*blogPostCollection{} + collection := blogPostCollection{ + Posts: []*model.BlogPost{}, + Year: -1, + } + for i, post := range posts { + if i == 0 { + collection.Year = post.PublishDate.Year() + } + + if post.PublishDate.Year() != collection.Year { + clone := collection.Clone() + collections = append(collections, &clone) + collection = blogPostCollection{ + Year: post.PublishDate.Year(), + Posts: []*model.BlogPost{}, + } + } + + collection.Posts = append(collection.Posts, post) + + if i == len(posts) - 1 { + collections = append(collections, &collection) + } + } + + 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 + } + }) + + return mux +} + +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 { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post %s: %v\n", blogPostID, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if blog == nil { + http.NotFound(w, r) + 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 + } + } + + // blog.Markdown += " " + + mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + md := mdParser.Parse([]byte(blog.Markdown)) + blogHTML := template.HTML(markdown.Render(md, mdRenderer)) + + comments := []*model.ThreadViewPost{} + likeCount := 0 + boostCount := 0 + var blueskyURL string + var blueskyPost *model.ThreadViewPost + if blog.Bluesky != nil { + blueskyPost, err = controller.FetchThreadViewPost(app, blog.Bluesky.ActorDID, blog.Bluesky.RecordID) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch blog post Bluesky thread: %v\n", err) + } else { + replies := []*model.ThreadViewPost{} + for _, reply := range blueskyPost.Replies { + if reply.Post.Author.DID != blog.Bluesky.ActorDID { + replies = append(replies, reply) + } + } + comments = append(comments, replies...) + likeCount += blueskyPost.Post.LikeCount + boostCount += blueskyPost.Post.RepostCount + blueskyURL = fmt.Sprintf("https://bsky.app/profile/%s/post/%s", blueskyPost.Post.Author.Handle, blog.Bluesky.RecordID) + } + } + + err = templates.BlogPostTemplate.Execute(w, blogPostView{ + BlogPost: blog, + BlogHTML: blogHTML, + 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..7eb4a58 100644 --- a/view/music.go +++ b/view/music.go @@ -15,20 +15,10 @@ import ( func MusicHandler(app *model.AppState) http.Handler { mux := http.NewServeMux() - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - ServeCatalog(app).ServeHTTP(w, r) - return - } - - release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true) - if err != nil { - http.NotFound(w, r) - return - } - - ServeGateway(app, release).ServeHTTP(w, r) - })) + mux.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) { + ServeGateway(app, r.PathValue("id")).ServeHTTP(w, r) + }) + mux.Handle("/", ServeCatalog(app)) return mux } @@ -55,8 +45,14 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func ServeGateway(app *model.AppState, release *model.Release) http.Handler { +func ServeGateway(app *model.AppState, releaseID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release, err := controller.GetRelease(app.DB, r.PathValue("id"), true) + if err != nil { + http.NotFound(w, r) + return + } + // only allow authorised users to view hidden releases privileged := false if !release.Visible { @@ -78,18 +74,17 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler { } } - response := *release - - if release.IsReleased() || privileged { - response.Tracks = release.Tracks - response.Credits = release.Credits - response.Links = release.Links + if !release.IsReleased() && !privileged { + release.Tracks = nil + release.Credits = nil + release.Links = nil } - err := templates.MusicGatewayTemplate.Execute(w, response) + for i, track := range release.Tracks { track.Number = i + 1 } + err = templates.MusicGatewayTemplate.Execute(w, release) 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 }