diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 113a17a..d26e9bf 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -9,13 +9,13 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" - "arimelody-web/log" "arimelody-web/model" + "arimelody-web/model/app" "golang.org/x/crypto/bcrypt" ) -func accountHandler(app *model.AppState) http.Handler { +func accountHandler(app *app.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/account/totp-setup", totpSetupHandler(app)) @@ -28,7 +28,7 @@ func accountHandler(app *model.AppState) http.Handler { return mux } -func accountIndexHandler(app *model.AppState) http.Handler { +func accountIndexHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -76,7 +76,7 @@ func accountIndexHandler(app *model.AppState) http.Handler { }) } -func changePasswordHandler(app *model.AppState) http.Handler { +func changePasswordHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -107,8 +107,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } - session.Account.Password = string(hashedPassword) - err = controller.UpdateAccount(app.DB, session.Account) + err = app.AccountService.ChangePassword(session.Account.ID, string(hashedPassword)) 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.") @@ -116,7 +115,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") @@ -124,7 +123,7 @@ func changePasswordHandler(app *model.AppState) http.Handler { }) } -func deleteAccountHandler(app *model.AppState) http.Handler { +func deleteAccountHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -146,13 +145,13 @@ func deleteAccountHandler(app *model.AppState) http.Handler { // check password 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)) + app.LogService.Warn(model.LOG_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) return } - err = controller.DeleteAccount(app.DB, session.Account.ID) + err = app.AccountService.Delete(session.Account.ID) 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.") @@ -160,7 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + app.LogService.Info(model.LOG_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionAccount(app.DB, session, nil) controller.SetSessionError(app.DB, session, "") @@ -176,7 +175,7 @@ type totpConfirmData struct { QRBase64Image string } -func totpSetupHandler(app *model.AppState) http.Handler { +func totpSetupHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { session := r.Context().Value("session").(*model.Session) @@ -247,7 +246,7 @@ func totpSetupHandler(app *model.AppState) http.Handler { }) } -func totpConfirmHandler(app *model.AppState) http.Handler { +func totpConfirmHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -311,7 +310,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) + app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name)) @@ -319,7 +318,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler { }) } -func totpDeleteHandler(app *model.AppState) http.Handler { +func totpDeleteHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -359,7 +358,7 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) + app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name)) diff --git a/admin/artisthttp.go b/admin/artisthttp.go index f151ddd..05cfbb5 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -6,11 +6,11 @@ import ( "strings" "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/model/app" ) -func serveArtists(app *model.AppState) http.Handler { +func serveArtists(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -22,7 +22,7 @@ func serveArtists(app *model.AppState) http.Handler { return } - artists, err := controller.GetAllArtists(app.DB) + artists, err := app.MusicService.GetAllArtists() if err != nil { fmt.Printf("WARN: Failed to fetch artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -45,11 +45,11 @@ func serveArtists(app *model.AppState) http.Handler { }) } -func serveArtist(app *model.AppState, artistID string) http.Handler { +func serveArtist(app *app.AppState, artistID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := app.MusicService.GetArtistByID(artistID) if err != nil { if artist == nil { http.NotFound(w, r) @@ -60,7 +60,7 @@ func serveArtist(app *model.AppState, artistID string) http.Handler { return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := app.MusicService.GetArtistCredits(artistID, true) if err != nil { fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/http.go b/admin/http.go index 2a6b4ae..66e66f1 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,7 +2,6 @@ package admin import ( "context" - "database/sql" "fmt" "net/http" "os" @@ -11,8 +10,8 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" - "arimelody-web/log" "arimelody-web/model" + "arimelody-web/model/app" "arimelody-web/view" "golang.org/x/crypto/bcrypt" @@ -23,7 +22,7 @@ type adminPageData struct { Session *model.Session } -func Handler(app *model.AppState) http.Handler { +func Handler(app *app.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -75,7 +74,7 @@ func Handler(app *model.AppState) http.Handler { return enforceSession(app, mux) } -func AdminIndexHandler(app *model.AppState) http.Handler { +func AdminIndexHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -84,39 +83,39 @@ func AdminIndexHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - releases, err := controller.GetAllReleases(app.DB, false, 3, true) + releases, err := app.MusicService.GetAllReleases(false, 3) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - releaseCount, err := controller.GetReleaseCount(app.DB, false) + releaseCount, err := app.MusicService.GetReleaseCount(false) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(app.DB) + artists, err := app.MusicService.GetAllArtists() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artistCount, err := controller.GetArtistCount(app.DB) + artistCount, err := app.MusicService.GetArtistCount() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := controller.GetOrphanTracks(app.DB) + tracks, err := app.MusicService.GetOrphanTracks() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - trackCount, err := controller.GetTrackCount(app.DB) + trackCount, err := app.MusicService.GetTrackCount() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -150,7 +149,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler { }) } -func registerAccountHandler(app *model.AppState) http.Handler { +func registerAccountHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -223,13 +222,13 @@ func registerAccountHandler(app *model.AppState) http.Handler { 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) + defaultAvatar := "/img/default-avatar.png" + accountID, err := app.AccountService.Create( + credentials.Username, + string(hashedPassword), + &credentials.Email, + &defaultAvatar, + ) if err != nil { if strings.HasPrefix(err.Error(), "pq: duplicate key") { controller.SetSessionError(app.DB, session, "An account with that username already exists.") @@ -242,22 +241,36 @@ func registerAccountHandler(app *model.AppState) http.Handler { 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)) + app.LogService.Info( + model.LOG_ACCOUNT, + "Account \"%s\" (%s) created using invite \"%s\". (%s)", + credentials.Username, + accountID, + 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) + app.LogService.Warn(model.LOG_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) } // registration success! - controller.SetSessionAccount(app.DB, session, &account) + if account, err := app.AccountService.GetByID(accountID); err != nil || account == nil { + controller.SetSessionError( + app.DB, session, + "Account created, but something went wrong logging you in. Please try logging in manually.", + ) + } else { + 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 { +func loginHandler(app *app.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) @@ -299,7 +312,7 @@ func loginHandler(app *model.AppState) http.Handler { username := r.FormValue("username") password := r.FormValue("password") - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(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.") @@ -319,7 +332,7 @@ func loginHandler(app *model.AppState) http.Handler { 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)) + app.LogService.Warn(model.LOG_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 { @@ -353,8 +366,8 @@ func loginHandler(app *model.AppState) http.Handler { // 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) + app.LogService.Info(model.LOG_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) + app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) err = controller.SetSessionAccount(app.DB, session, account) if err != nil { @@ -369,7 +382,7 @@ func loginHandler(app *model.AppState) http.Handler { }) } -func loginTOTPHandler(app *model.AppState) http.Handler { +func loginTOTPHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -407,7 +420,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { 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)) + app.LogService.Warn(model.LOG_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return @@ -421,7 +434,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } if totpMethod == nil { - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + app.LogService.Warn(model.LOG_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) @@ -433,7 +446,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) + app.LogService.Info(model.LOG_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 { @@ -452,7 +465,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { }) } -func logoutHandler(app *model.AppState) http.Handler { +func logoutHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -514,7 +527,7 @@ func staticHandler() http.Handler { } */ -func enforceSession(app *model.AppState, next http.Handler) http.Handler { +func enforceSession(app *app.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 { @@ -547,8 +560,45 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler { }) } -func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { - locked, err := controller.IncrementAccountFails(app.DB, account.ID) +// Helper for handling login failures. Increments the account auth failure +// count and logs warnings. If failure count exceeds MAX_LOGIN_FAIL_ATTEMPTS, +// the account will be locked. +func handleFailedLogin(app *app.AppState, account *model.Account, r *http.Request) bool { + failAttempts, err := app.AccountService.IncrementFails(account.ID) + if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { + err = app.AccountService.Lock(account.ID) + if err != nil { + fmt.Fprintf( + os.Stderr, + "WARN: Failed to lock account \"%s\": %v\n", + account.Username, + err, + ) + app.LogService.Warn( + model.LOG_ACCOUNT, + "Failed to lock account \"%s\"", + account.Username, + ) + } + + fmt.Fprintf( + os.Stderr, + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), + ) + app.LogService.Warn( + model.LOG_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), + ) + + return true + } + if err != nil { fmt.Fprintf( os.Stderr, @@ -556,20 +606,12 @@ func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Requ account.Username, err, ) - app.Log.Warn( - log.TYPE_ACCOUNT, + app.LogService.Warn( + model.LOG_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 + + return false } diff --git a/admin/logshttp.go b/admin/logshttp.go index a6d8e40..b17095d 100644 --- a/admin/logshttp.go +++ b/admin/logshttp.go @@ -2,15 +2,15 @@ package admin import ( "arimelody-web/admin/templates" - "arimelody-web/log" "arimelody-web/model" + "arimelody-web/model/app" "fmt" "net/http" "os" "strings" ) -func logsHandler(app *model.AppState) http.Handler { +func logsHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -19,16 +19,16 @@ func logsHandler(app *model.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - levelFilter := []log.LogLevel{} + levelFilter := []model.LogLevel{} typeFilter := []string{} query := r.URL.Query().Get("q") for key, value := range r.URL.Query() { if strings.HasPrefix(key, "level-") && value[0] == "on" { - m := map[string]log.LogLevel{ - "info": log.LEVEL_INFO, - "warn": log.LEVEL_WARN, + m := map[string]model.LogLevel{ + "info": model.LEVEL_INFO, + "warn": model.LEVEL_WARN, } level, ok := m[strings.TrimPrefix(key, "level-")] if ok { @@ -43,7 +43,7 @@ func logsHandler(app *model.AppState) http.Handler { } } - logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0) + logs, err := app.LogService.Search(levelFilter, typeFilter, query, 100, 0) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -52,7 +52,7 @@ func logsHandler(app *model.AppState) http.Handler { type LogsResponse struct { adminPageData - Logs []*log.Log + Logs []*model.Log } err = templates.LogsTemplate.Execute(w, LogsResponse{ diff --git a/admin/releasehttp.go b/admin/releasehttp.go index 7cca841..72586ed 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -7,11 +7,12 @@ import ( "strings" "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/model/app" + "arimelody-web/errors" ) -func serveReleases(app *model.AppState) http.Handler { +func serveReleases(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -33,7 +34,7 @@ func serveReleases(app *model.AppState) http.Handler { Releases []*model.Release } - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := app.MusicService.GetAllFullReleases(false, 0) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -55,13 +56,17 @@ func serveReleases(app *model.AppState) http.Handler { }) } -func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { +func serveRelease(app *app.AppState, releaseID string, action 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) + release, err := app.MusicService.GetFullReleaseByID(releaseID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -103,9 +108,7 @@ func serveRelease(app *model.AppState, releaseID string, action string) http.Han 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 }, @@ -129,9 +132,9 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { +func serveAddCredit(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) + artists, err := app.MusicService.GetArtistsNotOnRelease(release.ID) if err != nil { fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -155,10 +158,11 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewCredit(app *model.AppState) http.Handler { +func serveNewCredit(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artistID := strings.Split(r.URL.Path, "/")[3] - artist, err := controller.GetArtist(app.DB, artistID) + split := strings.Split(r.URL.Path, "/") + artistID := split[len(split) - 1] + artist, err := app.MusicService.GetArtistByID(artistID) if err != nil { fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -195,6 +199,8 @@ 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) @@ -203,9 +209,9 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { +func serveAddTrack(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) + tracks, err := app.MusicService.GetTracksNotOnRelease(release.ID) if err != nil { fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -229,10 +235,11 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { }) } -func serveNewTrack(app *model.AppState) http.Handler { +func serveNewTrack(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - trackID := strings.Split(r.URL.Path, "/")[3] - track, err := controller.GetTrack(app.DB, trackID) + split := strings.Split(r.URL.Path, "/") + trackID := split[len(split) - 1] + track, err := app.MusicService.GetTrackByID(trackID) if err != nil { fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/static/admin.css b/admin/static/admin.css index 8f983c7..821d294 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; } diff --git a/admin/static/artists.css b/admin/static/artists.css index 516a998..faa5888 100644 --- a/admin/static/artists.css +++ b/admin/static/artists.css @@ -2,7 +2,7 @@ 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; @@ -12,7 +12,7 @@ } .artist:hover { - background: var(--bg-1); + background-color: var(--bg-1); text-decoration: hover; } diff --git a/admin/static/artists.js b/admin/static/artists.js index 29eab22..e3a2d5a 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/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/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..0bb85c0 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,18 +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; @@ -84,7 +77,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 +85,7 @@ input[type="text"]:focus { } .credit:hover { - background: var(--bg-1); + background-color: var(--bg-1); } .release-artwork { diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 434b487..8186f2c 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; @@ -33,7 +33,7 @@ input[type="text"] { .release-artwork #remove-artwork { margin-top: .5em; padding: .3em .6em; - background: var(--bg-3); + background-color: var(--bg-3); } .release-info { @@ -62,13 +62,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 +93,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 +127,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 +234,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 +280,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 +299,7 @@ dialog div.dialog-actions { #editcredits .credit .delete { margin-right: .5em; cursor: pointer; + overflow: visible; } #editcredits .credit .delete:hover { text-decoration: underline; @@ -315,14 +316,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 +379,8 @@ dialog div.dialog-actions { #editlinks tr { display: flex; + background-color: var(--bg-1); + transition: background-color .1s ease-out; } #editlinks th { @@ -385,7 +391,7 @@ dialog div.dialog-actions { } #editlinks tr:nth-child(odd) { - background: #f8f8f8; + background-color: var(--bg-2); } #editlinks tr th, @@ -416,6 +422,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 +465,7 @@ dialog div.dialog-actions { } #edittracks .track { + background-color: var(--bg-2); transition: transform .2s ease-out, opacity .2s; } @@ -476,7 +488,7 @@ dialog div.dialog-actions { } #edittracks .track:nth-child(even) { - background: #f0f0f0; + background-color: var(--bg-1); } #edittracks .track-number { @@ -492,7 +504,6 @@ dialog div.dialog-actions { #addtrack ul { padding: 0; list-style: none; - background: #f8f8f8; } #addtrack ul li.new-track { 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.js b/admin/static/index.js deleted file mode 100644 index 60bdfd0..0000000 --- a/admin/static/index.js +++ /dev/null @@ -1,74 +0,0 @@ -const newReleaseBtn = document.getElementById("create-release"); -const newArtistBtn = document.getElementById("create-artist"); -const newTrackBtn = document.getElementById("create-track"); - -newReleaseBtn.addEventListener("click", event => { - event.preventDefault(); - const id = prompt("Enter an ID for this release:"); - if (id == null || id == "") return; - - fetch("/api/v1/music", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({id}) - }).then(res => { - if (res.ok) location = "/admin/releases/" + id; - else { - res.text().then(err => { - alert("Request failed: " + err); - console.error(err); - }); - } - }).catch(err => { - alert("Failed to create release. Check the console for details."); - console.error(err); - }); -}); - -newArtistBtn.addEventListener("click", event => { - event.preventDefault(); - const id = prompt("Enter an ID for this artist:"); - if (id == null || id == "") return; - - fetch("/api/v1/artist", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({id}) - }).then(res => { - res.text().then(text => { - if (res.ok) { - location = "/admin/artists/" + id; - } else { - alert("Request failed: " + text); - console.error(text); - } - }) - }).catch(err => { - alert("Failed to create artist. Check the console for details."); - console.error(err); - }); -}); - -newTrackBtn.addEventListener("click", event => { - event.preventDefault(); - const title = prompt("Enter an title for this track:"); - if (title == null || title == "") return; - - fetch("/api/v1/track", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({title}) - }).then(res => { - res.text().then(text => { - if (res.ok) { - location = "/admin/tracks/" + text; - } else { - alert("Request failed: " + text); - console.error(text); - } - }) - }).catch(err => { - alert("Failed to create track. Check the console for details."); - console.error(err); - }); -}); diff --git a/admin/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/releases.css b/admin/static/releases.css index 0694875..19f393f 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; @@ -67,14 +67,14 @@ display: inline-block; border-radius: 4px; - background: var(--bg-3); + background-color: 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); + background-color: 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..af12429 --- /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/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..bef1152 --- /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/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/components/track/edittracks.html b/admin/templates/html/components/track/edittracks.html index c06f0c3..f9e90f9 100644 --- a/admin/templates/html/components/track/edittracks.html +++ b/admin/templates/html/components/track/edittracks.html @@ -12,12 +12,12 @@