From 49e14b5bc5e7ebbc4c56113c3053b19986882a9d Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 31 Jul 2026 02:43:45 +0100 Subject: [PATCH] HEAVY: migrate accounts and logs to service/repo architecture --- admin/accounthttp.go | 31 +- admin/artisthttp.go | 5 +- admin/http.go | 120 ++++--- admin/logshttp.go | 14 +- admin/releasehttp.go | 13 +- admin/templates/templates.go | 8 +- admin/trackhttp.go | 5 +- api/api.go | 21 +- api/artist.go | 38 +-- api/release.go | 50 +-- api/track.go | 28 +- api/uploads.go | 22 +- controller/account.go | 135 -------- controller/config.go | 20 +- controller/ip.go | 10 +- controller/session.go | 34 +- controller/twitch.go | 17 +- cursor/cursor.go | 22 +- discord/discord.go | 18 +- log/log.go | 143 -------- main.go | 321 ++++++++---------- model/{ => app}/appstate.go | 12 +- model/log.go | 31 ++ model/{ => twitch}/twitch.go | 12 +- repository/account/interface.go | 33 ++ repository/account/postgres.go | 199 +++++++++++ repository/log/interface.go | 9 + repository/log/postgres.go | 110 ++++++ .../postgres}/migrator.go | 0 .../postgres}/schema-migration/000-init.sql | 0 .../schema-migration/001-pre-versioning.sql | 0 .../schema-migration/002-audit-logs.sql | 0 .../schema-migration/003-fail-lock.sql | 0 service/account/account.go | 140 ++++++++ service/log/log.go | 57 ++++ view/index.go | 9 +- view/music.go | 19 +- 37 files changed, 1019 insertions(+), 687 deletions(-) delete mode 100644 controller/account.go delete mode 100644 log/log.go rename model/{ => app}/appstate.go (83%) create mode 100644 model/log.go rename model/{ => twitch}/twitch.go (82%) create mode 100644 repository/account/interface.go create mode 100644 repository/account/postgres.go create mode 100644 repository/log/interface.go create mode 100644 repository/log/postgres.go rename {controller => repository/postgres}/migrator.go (100%) rename {controller => repository/postgres}/schema-migration/000-init.sql (100%) rename {controller => repository/postgres}/schema-migration/001-pre-versioning.sql (100%) rename {controller => repository/postgres}/schema-migration/002-audit-logs.sql (100%) rename {controller => repository/postgres}/schema-migration/003-fail-lock.sql (100%) create mode 100644 service/account/account.go create mode 100644 service/log/log.go diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 113a17a..c93c8b9 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.Log.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.Log.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.Log.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.Log.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.Log.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..ef67a4e 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -8,9 +8,10 @@ import ( "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) @@ -45,7 +46,7 @@ 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) diff --git a/admin/http.go b/admin/http.go index 2a6b4ae..10dc3f6 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) @@ -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.Log.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.Log.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.Log.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.Log.Info(model.LOG_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) + app.Log.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.Log.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.Log.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.Log.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.Log.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.Log.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, @@ -557,19 +607,11 @@ func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Requ err, ) app.Log.Warn( - log.TYPE_ACCOUNT, + 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..70c9627 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 { @@ -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 cee3d1e..d16528e 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -9,9 +9,10 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/model/app" ) -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) @@ -55,7 +56,7 @@ 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) @@ -127,7 +128,7 @@ 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) if err != nil { @@ -153,7 +154,7 @@ 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) { split := strings.Split(r.URL.Path, "/") artistID := split[len(split) - 1] @@ -204,7 +205,7 @@ 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) if err != nil { @@ -230,7 +231,7 @@ 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) { split := strings.Split(r.URL.Path, "/") trackID := split[len(split) - 1] diff --git a/admin/templates/templates.go b/admin/templates/templates.go index 51b1376..e194edd 100644 --- a/admin/templates/templates.go +++ b/admin/templates/templates.go @@ -1,7 +1,7 @@ package templates import ( - "arimelody-web/log" + "arimelody-web/model" _ "embed" "fmt" "html/template" @@ -163,11 +163,11 @@ var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(c -func parseLevel(level log.LogLevel) string { +func parseLevel(level model.LogLevel) string { switch level { - case log.LEVEL_INFO: + case model.LEVEL_INFO: return "INFO" - case log.LEVEL_WARN: + case model.LEVEL_WARN: return "WARN" } return fmt.Sprintf("%d?", level) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index bcb5220..2122748 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -8,9 +8,10 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" + "arimelody-web/model/app" ) -func serveTracks(app *model.AppState) http.Handler { +func serveTracks(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -45,7 +46,7 @@ func serveTracks(app *model.AppState) http.Handler { }) } -func serveTrack(app *model.AppState, trackID string) http.Handler { +func serveTrack(app *app.AppState, trackID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) diff --git a/api/api.go b/api/api.go index c5156c2..84b7f8a 100644 --- a/api/api.go +++ b/api/api.go @@ -1,17 +1,18 @@ package api import ( - "context" - "fmt" - "net/http" - "os" - "strings" + "context" + "fmt" + "net/http" + "os" + "strings" - "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/model/app" ) -func Handler(app *model.AppState) http.Handler { +func Handler(app *app.AppState) http.Handler { mux := http.NewServeMux() // TODO: generate API keys on the frontend @@ -166,7 +167,7 @@ func requireAccount(next http.Handler) http.Handler { }) } -func getSession(app *model.AppState, r *http.Request) (*model.Session, error) { +func getSession(app *app.AppState, r *http.Request) (*model.Session, error) { var token string // check cookies first @@ -184,7 +185,7 @@ func getSession(app *model.AppState, r *http.Request) (*model.Session, error) { if token == "" { return nil, nil } // fetch existing session - session, err := controller.GetSession(app.DB, token) + session, err := controller.GetSession(app, token) if err != nil && !strings.Contains(err.Error(), "no rows") { return nil, fmt.Errorf("Failed to retrieve session: %v\n", err) diff --git a/api/artist.go b/api/artist.go index 01899a6..d5f8a31 100644 --- a/api/artist.go +++ b/api/artist.go @@ -1,21 +1,21 @@ package api import ( - "encoding/json" - "fmt" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/model/app" ) -func ServeAllArtists(app *model.AppState) http.Handler { +func ServeAllArtists(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} artists, err := controller.GetAllArtists(app.DB) @@ -35,7 +35,7 @@ func ServeAllArtists(app *model.AppState) http.Handler { }) } -func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { +func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type ( creditJSON struct { @@ -87,7 +87,7 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler { }) } -func CreateArtist(app *model.AppState) http.Handler { +func CreateArtist(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -115,13 +115,13 @@ func CreateArtist(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username) w.WriteHeader(http.StatusCreated) }) } -func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { +func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -166,11 +166,11 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username) }) } -func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler { +func DeleteArtist(app *app.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -184,6 +184,6 @@ func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username) }) } diff --git a/api/release.go b/api/release.go index 69b7f12..71bac93 100644 --- a/api/release.go +++ b/api/release.go @@ -1,21 +1,21 @@ package api import ( - "encoding/json" - "fmt" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "time" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "time" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/model/app" ) -func ServeRelease(app *model.AppState, release *model.Release) http.Handler { +func ServeRelease(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases privileged := false @@ -127,7 +127,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler { }) } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { releases, err := controller.GetAllReleases(app.DB, false, 0, true) if err != nil { @@ -188,7 +188,7 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func CreateRelease(app *model.AppState) http.Handler { +func CreateRelease(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -224,7 +224,7 @@ func CreateRelease(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username) w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -238,7 +238,7 @@ func CreateRelease(app *model.AppState) http.Handler { }) } -func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { +func UpdateRelease(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -307,11 +307,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseTracks(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -336,11 +336,11 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -381,11 +381,11 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler { +func UpdateReleaseLinks(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -410,11 +410,11 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func DeleteRelease(app *model.AppState, release *model.Release) http.Handler { +func DeleteRelease(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -428,6 +428,6 @@ func DeleteRelease(app *model.AppState, release *model.Release) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username) }) } diff --git a/api/track.go b/api/track.go index 4e48418..8d81ea6 100644 --- a/api/track.go +++ b/api/track.go @@ -1,13 +1,13 @@ package api import ( - "encoding/json" - "fmt" - "net/http" + "encoding/json" + "fmt" + "net/http" - "arimelody-web/controller" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/model/app" ) type ( @@ -17,7 +17,7 @@ type ( } ) -func ServeAllTracks(app *model.AppState) http.Handler { +func ServeAllTracks(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { type Track struct { ID string `json:"id"` @@ -50,7 +50,7 @@ func ServeAllTracks(app *model.AppState) http.Handler { }) } -func ServeTrack(app *model.AppState, track *model.Track) http.Handler { +func ServeTrack(app *app.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) if err != nil { @@ -74,7 +74,7 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler { }) } -func CreateTrack(app *model.AppState) http.Handler { +func CreateTrack(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -97,7 +97,7 @@ func CreateTrack(app *model.AppState) http.Handler { return } - app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username) w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusCreated) @@ -105,7 +105,7 @@ func CreateTrack(app *model.AppState) http.Handler { }) } -func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { +func UpdateTrack(app *app.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.NotFound(w, r) @@ -132,7 +132,7 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { return } - app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username) w.Header().Add("Content-Type", "application/json") encoder := json.NewEncoder(w) @@ -144,7 +144,7 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler { }) } -func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { +func DeleteTrack(app *app.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.NotFound(w, r) @@ -160,6 +160,6 @@ func DeleteTrack(app *model.AppState, track *model.Track) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username) }) } diff --git a/api/uploads.go b/api/uploads.go index 3c3c58a..27731c2 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,18 +1,18 @@ package api import ( - "arimelody-web/log" - "arimelody-web/model" - "bufio" - "encoding/base64" - "errors" - "fmt" - "os" - "path/filepath" - "strings" + "arimelody-web/model" + "arimelody-web/model/app" + "bufio" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" ) -func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) { +func HandleImageUpload(app *app.AppState, data *string, directory string, filename string) (string, error) { split := strings.Split(*data, ";base64,") header := split[0] imageData, err := base64.StdEncoding.DecodeString(split[1]) @@ -50,7 +50,7 @@ func HandleImageUpload(app *model.AppState, data *string, directory string, file return "", nil } - app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath) + app.Log.Info(model.LOG_FILES, "\"%s\" created.", imagePath) return filename, nil } diff --git a/controller/account.go b/controller/account.go deleted file mode 100644 index ab64ca5..0000000 --- a/controller/account.go +++ /dev/null @@ -1,135 +0,0 @@ -package controller - -import ( - "arimelody-web/model" - "strings" - - "github.com/jmoiron/sqlx" -) - -func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) { - var accounts = []model.Account{} - - err := db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC") - if err != nil { - return nil, err - } - - return accounts, nil -} - -func GetAccountByID(db *sqlx.DB, id string) (*model.Account, error) { - var account = model.Account{} - - err := db.Get(&account, "SELECT * FROM account WHERE id=$1", id) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, nil - } - return nil, err - } - - return &account, nil -} - -func GetAccountByUsername(db *sqlx.DB, username string) (*model.Account, error) { - var account = model.Account{} - - err := db.Get(&account, "SELECT * FROM account WHERE username=$1", username) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, nil - } - return nil, err - } - - return &account, nil -} - -func GetAccountByEmail(db *sqlx.DB, email string) (*model.Account, error) { - var account = model.Account{} - - err := db.Get(&account, "SELECT * FROM account WHERE email=$1", email) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, nil - } - return nil, err - } - - return &account, nil -} - -func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, error) { - if sessionToken == "" { return nil, nil } - - account := model.Account{} - - err := db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, nil - } - return nil, err - } - - return &account, nil -} - -func CreateAccount(db *sqlx.DB, account *model.Account) error { - err := db.Get( - &account.ID, - "INSERT INTO account (username, password, email, avatar_url) " + - "VALUES ($1, $2, $3, $4) " + - "RETURNING id", - account.Username, - account.Password, - account.Email, - account.AvatarURL, - ) - - return err -} - -func UpdateAccount(db *sqlx.DB, account *model.Account) error { - _, err := db.Exec( - "UPDATE account " + - "SET username=$2,password=$3,email=$4,avatar_url=$5 " + - "WHERE id=$1", - account.ID, - account.Username, - account.Password, - account.Email, - account.AvatarURL, - ) - - return err -} - -func DeleteAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) - return err -} - -func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) { - failAttempts := 0 - err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) - if err != nil { return false, err } - locked := false - if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { - err = LockAccount(db, accountID) - if err != nil { return false, err } - locked = true - } - return locked, err -} - -func LockAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) - return err -} - -func UnlockAccount(db *sqlx.DB, accountID string) error { - _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) - return err -} diff --git a/controller/config.go b/controller/config.go index 77619d7..25744ac 100644 --- a/controller/config.go +++ b/controller/config.go @@ -1,28 +1,28 @@ package controller import ( - "errors" - "fmt" - "os" - "strconv" + "errors" + "fmt" + "os" + "strconv" - "arimelody-web/model" + "arimelody-web/model/app" - "github.com/pelletier/go-toml/v2" + "github.com/pelletier/go-toml/v2" ) -func GetConfig() model.Config { +func GetConfig() app.Config { configFile := os.Getenv("ARIMELODY_CONFIG") if configFile == "" { configFile = "config.toml" } - config := model.Config{ + config := app.Config{ BaseUrl: "https://arimelody.space", Host: "0.0.0.0", Port: 8080, TrustedProxies: []string{ "127.0.0.1" }, - DB: model.DBConfig{ + DB: app.DBConfig{ Host: "127.0.0.1", Port: 5432, User: "arimelody", @@ -53,7 +53,7 @@ func GetConfig() model.Config { return config } -func handleConfigOverrides(config *model.Config) error { +func handleConfigOverrides(config *app.Config) error { var err error if env, has := os.LookupEnv("ARIMELODY_BASE_URL"); has { config.BaseUrl = env } diff --git a/controller/ip.go b/controller/ip.go index cbc3054..1d6795b 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -1,15 +1,15 @@ package controller import ( - "arimelody-web/model" - "net/http" - "slices" - "strings" + "arimelody-web/model/app" + "net/http" + "slices" + "strings" ) // Returns the request's original IP address, resolving the `x-forwarded-for` // header if the request originates from a trusted proxy. -func ResolveIP(app *model.AppState, r *http.Request) string { +func ResolveIP(app *app.AppState, r *http.Request) string { addr := strings.Split(r.RemoteAddr, ":")[0] if slices.Contains(app.Config.TrustedProxies, addr) { forwardedFor := r.Header.Get("x-forwarded-for") diff --git a/controller/session.go b/controller/session.go index dfae551..875d50f 100644 --- a/controller/session.go +++ b/controller/session.go @@ -1,21 +1,21 @@ package controller import ( - "database/sql" - "fmt" - "net/http" - "strings" - "time" + "database/sql" + "fmt" + "net/http" + "strings" + "time" - "arimelody-web/log" - "arimelody-web/model" + "arimelody-web/model" + "arimelody-web/model/app" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) const TOKEN_LEN = 64 -func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) { +func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session, error) { sessionCookie, err := r.Cookie(model.COOKIE_TOKEN) if err != nil && err != http.ErrNoCookie { return nil, fmt.Errorf("Failed to retrieve session cookie: %v", err) @@ -25,7 +25,7 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session if sessionCookie != nil { // fetch existing session - session, err = GetSession(app.DB, sessionCookie.Value) + session, err = GetSession(app, sessionCookie.Value) if err != nil && !strings.Contains(err.Error(), "no rows") { return nil, fmt.Errorf("Failed to retrieve session: %v", err) @@ -35,13 +35,13 @@ func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session if session.UserAgent != r.UserAgent() { msg := "Session user agent mismatch. A cookie may have been hijacked!" if session.Account != nil { - account, _ := GetAccountByID(app.DB, session.Account.ID) + account, _ := app.AccountService.GetByID(session.Account.ID) msg += " (Account \"" + account.Username + "\")" } - app.Log.Warn(log.TYPE_ACCOUNT, msg) + app.Log.Warn(model.LOG_ACCOUNT, msg) err = DeleteSession(app.DB, session.Token) if err != nil { - app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session") + app.Log.Warn(model.LOG_ACCOUNT, "Failed to delete affected session") } return nil, nil } @@ -137,7 +137,7 @@ func SetSessionError(db *sqlx.DB, session *model.Session, message string) error return err } -func GetSession(db *sqlx.DB, token string) (*model.Session, error) { +func GetSession(app *app.AppState, token string) (*model.Session, error) { type dbSession struct { model.Session AttemptAccountID sql.NullString `db:"attempt_account"` @@ -145,7 +145,7 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) { } session := dbSession{} - err := db.Get( + err := app.DB.Get( &session, "SELECT * FROM session WHERE token=$1", token, @@ -155,14 +155,14 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) { } if session.AccountID.Valid { - session.Account, err = GetAccountByID(db, session.AccountID.String) + session.Account, err = app.AccountService.GetByID(session.AccountID.String) if err != nil { return nil, err } } if session.AttemptAccountID.Valid { - session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String) + session.AttemptAccount, err = app.AccountService.GetByID(session.AttemptAccountID.String) if err != nil { return nil, err } diff --git a/controller/twitch.go b/controller/twitch.go index 98d3b9a..184ba61 100644 --- a/controller/twitch.go +++ b/controller/twitch.go @@ -1,7 +1,8 @@ package controller import ( - "arimelody-web/model" + "arimelody-web/model/app" + "arimelody-web/model/twitch" "bytes" "encoding/json" "net/http" @@ -11,13 +12,13 @@ import ( const TWITCH_API_BASE = "https://api.twitch.tv/helix/" -func TwitchSetup(app *model.AppState) error { - app.Twitch = &model.TwitchState{} +func TwitchSetup(app *app.AppState) error { + app.Twitch = &twitch.State{} err := RefreshTwitchToken(app) return err } -func RefreshTwitchToken(app *model.AppState) error { +func RefreshTwitchToken(app *app.AppState) error { if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) { return nil } @@ -45,7 +46,7 @@ func RefreshTwitchToken(app *model.AppState) error { return err } - app.Twitch.Token = &model.TwitchOAuthToken{ + app.Twitch.Token = &twitch.OAuthToken{ AccessToken: oauthResponse.AccessToken, ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(), TokenType: oauthResponse.TokenType, @@ -54,10 +55,10 @@ func RefreshTwitchToken(app *model.AppState) error { return nil } -var lastStreamState *model.TwitchStreamInfo +var lastStreamState *twitch.StreamInfo var lastStreamStateAt time.Time -func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) { +func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo, error) { if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) { return lastStreamState, nil } @@ -76,7 +77,7 @@ func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStre } type StreamsResponse struct { - Data []model.TwitchStreamInfo `json:"data"` + Data []twitch.StreamInfo `json:"data"` } streamInfo := StreamsResponse{} err = json.NewDecoder(res.Body).Decode(&streamInfo) diff --git a/cursor/cursor.go b/cursor/cursor.go index 56edb56..cd1d259 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,16 +1,16 @@ package cursor import ( - "arimelody-web/model" - "fmt" - "math/rand" - "net/http" - "strconv" - "strings" - "sync" - "time" + "arimelody-web/model/app" + "fmt" + "math/rand" + "net/http" + "strconv" + "strings" + "sync" + "time" - "github.com/gorilla/websocket" + "github.com/gorilla/websocket" ) type CursorClient struct { @@ -49,7 +49,7 @@ var clients = make(map[int32]*CursorClient) var broadcast = make(chan CursorMessage) var mutex = &sync.Mutex{} -func StartCursor(app *model.AppState) { +func StartCursor(app *app.AppState) { var includes = func (clients []*CursorClient, client *CursorClient) bool { for _, c := range clients { if c.ID == client.ID { return true } @@ -145,7 +145,7 @@ func handleClient(client *CursorClient) { } } -func Handler(app *model.AppState) http.HandlerFunc { +func Handler(app *app.AppState) http.HandlerFunc { var upgrader = websocket.Upgrader{ CheckOrigin: func (r *http.Request) bool { origin := r.Header.Get("Origin") diff --git a/discord/discord.go b/discord/discord.go index 0eb9b97..a840b09 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -1,13 +1,13 @@ package discord import ( - "arimelody-web/model" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" + "arimelody-web/model/app" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" ) const API_ENDPOINT = "https://discord.com/api/v10" @@ -47,7 +47,7 @@ type ( } ) -func GetOAuthTokenFromCode(app *model.AppState, code string) (string, error) { +func GetOAuthTokenFromCode(app *app.AppState, code string) (string, error) { // let's get an oauth token! req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/oauth2/token", API_ENDPOINT), strings.NewReader(url.Values{ @@ -99,7 +99,7 @@ func GetOAuthCallbackURI(baseURL string) string { return fmt.Sprintf("%s/admin/login", baseURL) } -func GetRedirectURI(app *model.AppState) string { +func GetRedirectURI(app *app.AppState) string { return fmt.Sprintf( "https://discord.com/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=identify", app.Config.Discord.ClientID, diff --git a/log/log.go b/log/log.go deleted file mode 100644 index 88d328b..0000000 --- a/log/log.go +++ /dev/null @@ -1,143 +0,0 @@ -package log - -import ( - "fmt" - "os" - "time" - - "github.com/jmoiron/sqlx" -) - -type ( - Logger struct { - DB *sqlx.DB - } - - Log struct { - ID string `json:"id" db:"id"` - Level LogLevel `json:"level" db:"level"` - Type string `json:"type" db:"type"` - Content string `json:"content" db:"content"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - } -) - -const ( - TYPE_ACCOUNT string = "account" - TYPE_MUSIC string = "music" - TYPE_ARTIST string = "artist" - TYPE_BLOG string = "blog" - TYPE_ARTWORK string = "artwork" - TYPE_FILES string = "files" - TYPE_MISC string = "misc" - TYPE_CURSOR string = "cursor" -) - -type LogLevel int -const ( - LEVEL_INFO LogLevel = 0 - LEVEL_WARN LogLevel = 1 -) - -const DEFAULT_LOG_PAGE_LENGTH = 25 - -func (self *Logger) Info(logType string, format string, args ...any) { - logString := fmt.Sprintf(format, args...) - fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString) - err := createLog(self.DB, LEVEL_INFO, logType, logString) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err) - } -} - -func (self *Logger) Warn(logType string, format string, args ...any) { - logString := fmt.Sprintf(format, args...) - fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString) - err := createLog(self.DB, LEVEL_WARN, logType, logString) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err) - } -} - -func (self *Logger) Fetch(id string) (*Log, error) { - log := Log{} - err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id) - return &log, err -} - -func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) { - logs := []*Log{} - - params := []any{ limit, offset } - conditions := "" - - if len(content) > 0 { - content = "%" + content + "%" - conditions += " WHERE content LIKE $3" - params = append(params, content) - } - - if len(levelFilters) > 0 { - if len(conditions) > 0 { - conditions += " AND level IN (" - } else { - conditions += " WHERE level IN (" - } - for i := range levelFilters { - conditions += fmt.Sprintf("$%d", len(params) + 1) - if i < len(levelFilters) - 1 { - conditions += "," - } - params = append(params, levelFilters[i]) - } - conditions += ")" - } - - if len(typeFilters) > 0 { - if len(conditions) > 0 { - conditions += " AND type IN (" - } else { - conditions += " WHERE type IN (" - } - for i := range typeFilters { - conditions += fmt.Sprintf("$%d", len(params) + 1) - if i < len(typeFilters) - 1 { - conditions += "," - } - params = append(params, typeFilters[i]) - } - conditions += ")" - } - - query := fmt.Sprintf( - "SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2", - conditions, - ) - - /* - fmt.Printf("%s (", query) - for i, param := range params { - fmt.Print(param) - if i < len(params) - 1 { - fmt.Print(", ") - } - } - fmt.Print(")\n") - */ - - err := self.DB.Select(&logs, query, params...) - if err != nil { - return nil, err - } - return logs, nil -} - -func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error { - _, err := db.Exec( - "INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)", - logLevel, - logType, - content, - ) - return err -} diff --git a/main.go b/main.go index 553f109..235b2e8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "embed" "errors" "fmt" + "log" stdLog "log" "math" "math/rand" @@ -21,8 +22,13 @@ import ( "arimelody-web/colour" "arimelody-web/controller" "arimelody-web/cursor" - "arimelody-web/log" "arimelody-web/model" + "arimelody-web/model/app" + accountRepo "arimelody-web/repository/account" + logRepo "arimelody-web/repository/log" + repo "arimelody-web/repository/postgres" + accountService "arimelody-web/service/account" + logService "arimelody-web/service/log" "arimelody-web/view" "github.com/jmoiron/sqlx" @@ -35,14 +41,17 @@ const DB_VERSION = 1 const DEFAULT_PORT int64 = 8080 const HRT_DATE int64 = 1756478697 +const DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds //go:embed "public" var publicFS embed.FS func main() { - fmt.Printf("made with <3 by ari melody\n\n") + logger := log.New(os.Stderr, "main", DEFAULT_LOG_FLAGS) - app := model.AppState{ + logger.Print("made with <3 by ari melody\n\n") + + app := app.AppState{ Config: controller.GetConfig(), Twitch: nil, PublicFS: publicFS, @@ -50,44 +59,49 @@ func main() { // initialise database connection if app.Config.DB.Host == "" { - fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") - os.Exit(1) + logger.Fatalf("FATAL: db.host not provided! Exiting...\n") } if app.Config.DB.Name == "" { - fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") - os.Exit(1) + logger.Fatalf("FATAL: db.name not provided! Exiting...\n") } if app.Config.DB.User == "" { - fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") - os.Exit(1) + logger.Fatalf("FATAL: db.user not provided! Exiting...\n") } if app.Config.DB.Pass == "" { - fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") - os.Exit(1) + logger.Fatalf("FATAL: db.pass not provided! Exiting...\n") } - var err error - app.DB, err = sqlx.Connect( + psqlDB, err := sqlx.Connect( "postgres", fmt.Sprintf( - "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", + "host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable", app.Config.DB.Host, app.Config.DB.Port, app.Config.DB.User, - app.Config.DB.Name, app.Config.DB.Pass, + app.Config.DB.Name, ), ) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) - os.Exit(1) + logger.Fatalf("Failed to connect to database: %v", err) } - app.DB.SetConnMaxLifetime(time.Minute * 3) - app.DB.SetMaxOpenConns(10) - app.DB.SetMaxIdleConns(10) - defer app.DB.Close() + defer psqlDB.Close() + psqlDB.SetConnMaxLifetime(time.Minute * 3) + psqlDB.SetMaxOpenConns(10) + psqlDB.SetMaxIdleConns(10) + app.DB = psqlDB - app.Log = log.Logger{ DB: app.DB } + logRepo := logRepo.NewLogRepositoryPostgres(psqlDB) + app.Log = logService.NewLogService( + logRepo, + log.New(os.Stderr, "logger", DEFAULT_LOG_FLAGS), + ) + + accountRepo := accountRepo.NewAccountRepositoryPostgres(psqlDB) + app.AccountService = accountService.NewAccountService( + accountRepo, + log.New(os.Stderr, "account-repo", DEFAULT_LOG_FLAGS), + ) // handle command arguments if len(os.Args) > 1 { @@ -96,21 +110,18 @@ func main() { switch arg { case "createTOTP": if len(os.Args) < 4 { - fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` and `name` must be specified for createTOTP.\n") } username := os.Args[2] totpName := os.Args[3] - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) @@ -123,133 +134,116 @@ func main() { err = controller.CreateTOTP(app.DB, &totp) if err != nil { if strings.HasPrefix(err.Error(), "pq: duplicate key") { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name) } - fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to create TOTP method: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username) + app.Log.Info(model.LOG_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username) url := controller.GenerateTOTPURI(account.Username, totp.Secret) - fmt.Printf("%s\n", url) + logger.Printf("%s\n", url) return case "deleteTOTP": if len(os.Args) < 4 { - fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` and `name` must be specified for deleteTOTP.\n") } username := os.Args[2] totpName := os.Args[3] - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } err = controller.DeleteTOTP(app.DB, account.ID, totpName) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to create TOTP method: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username) - fmt.Printf("TOTP method \"%s\" deleted.\n", totpName) + app.Log.Info(model.LOG_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username) + logger.Printf("TOTP method \"%s\" deleted.\n", totpName) return case "listTOTP": if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` must be specified for listTOTP.\n") } username := os.Args[2] - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to create TOTP methods: %v\n", err) } for i, totp := range totps { - fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) + logger.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) } if len(totps) == 0 { - fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username) + logger.Printf("\"%s\" has no TOTP methods.\n", account.Username) } return case "testTOTP": if len(os.Args) < 4 { - fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` and `name` must be specified for testTOTP.\n") } username := os.Args[2] totpName := os.Args[3] - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } totp, err := controller.GetTOTP(app.DB, account.ID, totpName) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) } if totp == nil { - fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) - os.Exit(1) + logger.Fatalf("FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) } code := controller.GenerateTOTP(totp.Secret, 0) - fmt.Printf("%s\n", code) + logger.Printf("%s\n", code) return case "cleanTOTP": err := controller.DeleteUnconfirmedTOTPs(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to clean up TOTP methods: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.") - fmt.Printf("Cleaned up dangling TOTP methods successfully.\n") + app.Log.Info(model.LOG_ACCOUNT, "TOTP methods pruned via config utility.") + logger.Printf("Cleaned up dangling TOTP methods successfully.\n") return case "createInvite": - fmt.Printf("Creating invite...\n") + logger.Printf("Creating invite...\n") invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to create invite code: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code) - fmt.Printf( + app.Log.Info(model.LOG_ACCOUNT, "Invite generted via config utility (%s).", invite.Code) + logger.Printf( "Here you go! This code expires in %d hours: %s\n", int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())), invite.Code, @@ -257,28 +251,26 @@ func main() { return case "purgeInvites": - fmt.Printf("Deleting all invites...\n") + logger.Printf("Deleting all invites...\n") err := controller.DeleteAllInvites(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to delete invites: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.") - fmt.Printf("Invites deleted successfully.\n") + app.Log.Info(model.LOG_ACCOUNT, "Invites purged via config utility.") + logger.Printf("Invites deleted successfully.\n") return case "listAccounts": - accounts, err := controller.GetAllAccounts(app.DB) + accounts, err := app.AccountService.GetAll() if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch accounts: %v\n", err) } for _, account := range accounts { email := "" if account.Email.Valid { email = account.Email.String } - fmt.Printf( + logger.Printf( "User: %s\n" + "\tID: %s\n" + "\tEmail: %s\n" + @@ -295,150 +287,140 @@ func main() { case "changePassword": if len(os.Args) < 4 { - fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` and `password` must be specified for changePassword\n") } username := os.Args[2] password := os.Args[3] - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to update password: %v\n", err) } account.Password = string(hashedPassword) - err = controller.UpdateAccount(app.DB, account) - if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) - os.Exit(1) + + var email *string = nil + if account.Email.Valid { email = &account.Email.String } + var avatarURL *string = nil + if account.AvatarURL.Valid { email = &account.AvatarURL.String } + if err = app.AccountService.Update( + account.ID, + username, string(hashedPassword), + email, avatarURL, + ); err != nil { + logger.Fatalf("FATAL: Failed to update password: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username) - fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username) + app.Log.Info(model.LOG_ACCOUNT, "Password for '%s' updated via config utility.", account.Username) + logger.Printf("Password for \"%s\" updated successfully.\n", account.Username) return case "deleteAccount": if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` must be specified for deleteAccount\n") } username := os.Args[2] - fmt.Printf("Deleting account \"%s\"...\n", username) + logger.Printf("Deleting account \"%s\"...\n", username) - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } - fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) + logger.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) res := "" fmt.Scanln(&res) if !strings.HasPrefix(res, "y") { return } - err = controller.DeleteAccount(app.DB, account.ID) + err = app.AccountService.Delete(account.ID) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to delete account: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username) - fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) + app.Log.Info(model.LOG_ACCOUNT, "Account '%s' deleted via config utility.", account.Username) + logger.Printf("Account \"%s\" deleted successfully.\n", account.Username) return case "lockAccount": if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` must be specified for lockAccount\n") } username := os.Args[2] - fmt.Printf("Unlocking account \"%s\"...\n", username) + logger.Printf("Unlocking account \"%s\"...\n", username) - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } - err = controller.LockAccount(app.DB, account.ID) + err = app.AccountService.Lock(account.ID) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to lock account: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username) - fmt.Printf("Account \"%s\" locked successfully.\n", account.Username) + app.Log.Info(model.LOG_ACCOUNT, "Account '%s' locked via config utility.", account.Username) + logger.Printf("Account \"%s\" locked successfully.\n", account.Username) return case "unlockAccount": if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n") - os.Exit(1) + logger.Fatalf("FATAL: `username` must be specified for unlockAccount\n") } username := os.Args[2] - fmt.Printf("Unlocking account \"%s\"...\n", username) + logger.Printf("Unlocking account \"%s\"...\n", username) - account, err := controller.GetAccountByUsername(app.DB, username) + account, err := app.AccountService.GetByUsername(username) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) } if account == nil { - fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) - os.Exit(1) + logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) } - err = controller.UnlockAccount(app.DB, account.ID) + err = app.AccountService.Unlock(account.ID) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to unlock account: %v\n", err) } - app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) - fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username) + app.Log.Info(model.LOG_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) + logger.Printf("Account \"%s\" unlocked successfully.\n", account.Username) return case "logs": // TODO: add log search parameters - logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0) + logs, err := app.Log.Search([]model.LogLevel{}, []string{}, "", 100, 0) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to fetch logs: %v\n", err) } for _, item := range(logs) { levelStr := "" switch item.Level { - case log.LEVEL_INFO: + case model.LEVEL_INFO: levelStr = "INFO" - case log.LEVEL_WARN: + case model.LEVEL_WARN: levelStr = "WARN" default: levelStr = fmt.Sprintf("? (%d)", item.Level) } - fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content) + logger.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content) } return } @@ -464,68 +446,66 @@ func main() { } // handle DB migrations - controller.CheckDBVersionAndMigrate(app.DB) + if psqlDB != nil { + repo.CheckDBVersionAndMigrate(psqlDB) + } if app.Config.Twitch != nil { err = controller.TwitchSetup(&app) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) + logger.Printf("WARN: Failed to set up Twitch integration: %v\n", err) } } // initial invite code - accountsCount := 0 - err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") + accountsCount, err := app.AccountService.GetCount() if err != nil { panic(err) } if accountsCount == 0 { _, err := app.DB.Exec("DELETE FROM invite") if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to clear existing invite codes: %v\n", err) } invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to create invite code: %v\n", err) } - fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) + logger.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) } // delete expired sessions err = controller.DeleteExpiredSessions(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to clear expired sessions: %v\n", err) } // delete expired invites err = controller.DeleteExpiredInvites(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to clear expired invite codes: %v\n", err) } // clean up unconfirmed TOTP methods err = controller.DeleteUnconfirmedTOTPs(app.DB) if err != nil { - fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err) - os.Exit(1) + logger.Fatalf("FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err) } go cursor.StartCursor(&app) + httpLogger := log.New(os.Stderr, "http", DEFAULT_LOG_FLAGS) + // start the web server! mux := createServeMux(&app) - fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) + logger.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) stdLog.Fatal( http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), - CheckRequest(&app, HTTPLog(DefaultHeaders(mux))), + CheckRequest(&app, httpLogger, HTTPLog(httpLogger, DefaultHeaders(mux))), )) } -func createServeMux(app *model.AppState) *http.ServeMux { +func createServeMux(app *app.AppState) *http.ServeMux { mux := http.NewServeMux() mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) @@ -568,7 +548,7 @@ var PoweredByStrings = []string{ "30 billion dollars in VC funding", } -func CheckRequest(app *model.AppState, next http.Handler) http.Handler { +func CheckRequest(app *app.AppState, log *log.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // requests with empty user agents are considered suspicious. // every browser supplies them; hell, even curl supplies them. @@ -585,8 +565,7 @@ func CheckRequest(app *model.AppState, next http.Handler) http.Handler { if strings.HasSuffix(r.URL.Path, ".php") || strings.HasSuffix(r.URL.Path, ".php7") { http.NotFound(w, r) - fmt.Fprintf( - os.Stderr, + log.Printf( "WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n", r.URL.Path, r.RemoteAddr, @@ -636,7 +615,7 @@ func (lrw *LoggingResponseWriter) WriteHeader(status int) { lrw.ResponseWriter.WriteHeader(status) } -func HTTPLog(next http.Handler) http.Handler { +func HTTPLog(log *log.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -658,7 +637,7 @@ func HTTPLog(next http.Handler) http.Handler { if lrw.Status - 400 <= 0 { statusColour = colour.White } if lrw.Status - 300 <= 0 { statusColour = colour.Green } - fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", + log.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", after.Format(time.UnixDate), r.Method, r.URL.Path, diff --git a/model/appstate.go b/model/app/appstate.go similarity index 83% rename from model/appstate.go rename to model/app/appstate.go index 1a13be9..bc8d69b 100644 --- a/model/appstate.go +++ b/model/app/appstate.go @@ -1,11 +1,13 @@ -package model +package app import ( "embed" "github.com/jmoiron/sqlx" - "arimelody-web/log" + "arimelody-web/model/twitch" + accountService "arimelody-web/service/account" + "arimelody-web/service/log" ) type ( @@ -43,8 +45,10 @@ type ( AppState struct { DB *sqlx.DB Config Config - Log log.Logger - Twitch *TwitchState + Log *log.LogService + Twitch *twitch.State PublicFS embed.FS + + AccountService *accountService.AccountService } ) diff --git a/model/log.go b/model/log.go new file mode 100644 index 0000000..af4df5e --- /dev/null +++ b/model/log.go @@ -0,0 +1,31 @@ +package model + +import "time" + +type ( + LogLevel int + + Log struct { + ID string `json:"id" db:"id"` + Level LogLevel `json:"level" db:"level"` + Type string `json:"type" db:"type"` + Content string `json:"content" db:"content"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + } +) + +const ( + LOG_ACCOUNT string = "account" + LOG_MUSIC string = "music" + LOG_ARTIST string = "artist" + LOG_BLOG string = "blog" + LOG_ARTWORK string = "artwork" + LOG_FILES string = "files" + LOG_MISC string = "misc" + LOG_CURSOR string = "cursor" +) + +const ( + LEVEL_INFO LogLevel = 0 + LEVEL_WARN LogLevel = 1 +) diff --git a/model/twitch.go b/model/twitch/twitch.go similarity index 82% rename from model/twitch.go rename to model/twitch/twitch.go index 6bca17d..4902403 100644 --- a/model/twitch.go +++ b/model/twitch/twitch.go @@ -1,4 +1,4 @@ -package model +package twitch import ( "fmt" @@ -7,17 +7,17 @@ import ( ) type ( - TwitchOAuthToken struct { + OAuthToken struct { AccessToken string ExpiresAt time.Time TokenType string } - TwitchState struct { - Token *TwitchOAuthToken + State struct { + Token *OAuthToken } - TwitchStreamInfo struct { + StreamInfo struct { ID string `json:"id"` UserID string `json:"user_id"` UserLogin string `json:"user_login"` @@ -36,7 +36,7 @@ type ( } ) -func (info *TwitchStreamInfo) Thumbnail(width int, height int) string { +func (info *StreamInfo) Thumbnail(width int, height int) string { res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1) res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1) return res diff --git a/repository/account/interface.go b/repository/account/interface.go new file mode 100644 index 0000000..5e047d6 --- /dev/null +++ b/repository/account/interface.go @@ -0,0 +1,33 @@ +package account + +import "arimelody-web/model" + +type AccountRepository interface { + GetAll() ([]model.Account, error) + GetCount() (int, error) + GetByID(id string) (*model.Account, error) + GetByUsername(username string) (*model.Account, error) + GetByEmail(email string) (*model.Account, error) + GetBySession(sessionToken string) (*model.Account, error) + + // Create an account, returning the new account ID. + Create(username string, password string, email *string, avatarURL *string) (string, error) + + // Intended for large profile updates. For smaller adjusments, + // more specialised Change* and Remove* functions should be used. + Update(id string, username string, password string, email *string, avatarUrl *string) error + ChangeUsername(id string, username string) error + ChangePassword(id string, password string) error + ChangeEmail(id string, email string) error + RemoveEmail(id string) error + ChangeAvatarURL(id string, avatarURL string) error + RemoveAvatar(id string) error + + Delete(accountID string) error + + // Increment the number of account login failure attempts, + // returning the current fail count. + IncrementFails(accountID string) (int, error) + Lock(accountID string) error + Unlock(accountID string) error +} diff --git a/repository/account/postgres.go b/repository/account/postgres.go new file mode 100644 index 0000000..f38cf3e --- /dev/null +++ b/repository/account/postgres.go @@ -0,0 +1,199 @@ +package account + +import ( + "arimelody-web/model" + "strings" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +type ( + AccountRepositoryPostgres struct { + db *sqlx.DB + } +) + +var _ AccountRepository = new(AccountRepositoryPostgres) + +func NewAccountRepositoryPostgres(db *sqlx.DB) *AccountRepositoryPostgres { + return &AccountRepositoryPostgres{ db: db } +} + +func (repo *AccountRepositoryPostgres) GetAll() ([]model.Account, error) { + var accounts = []model.Account{} + + err := repo.db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC") + if err != nil { + return nil, err + } + + return accounts, nil +} + +func (repo *AccountRepositoryPostgres) GetCount() (int, error) { + accountsCount := 0 + err := repo.db.Get(&accountsCount, "SELECT count(*) FROM account") + return accountsCount, err +} + +func (repo *AccountRepositoryPostgres) GetByID(id string) (*model.Account, error) { + var account = model.Account{} + + err := repo.db.Get(&account, "SELECT * FROM account WHERE id=$1", id) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func (repo *AccountRepositoryPostgres) GetByUsername(username string) (*model.Account, error) { + var account = model.Account{} + + err := repo.db.Get(&account, "SELECT * FROM account WHERE username=$1", username) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func (repo *AccountRepositoryPostgres) GetByEmail(email string) (*model.Account, error) { + var account = model.Account{} + + err := repo.db.Get(&account, "SELECT * FROM account WHERE email=$1", email) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func (repo *AccountRepositoryPostgres) GetBySession(sessionToken string) (*model.Account, error) { + if sessionToken == "" { return nil, nil } + + account := model.Account{} + + err := repo.db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + +func (repo *AccountRepositoryPostgres) Create( + username string, + password string, + email *string, + avatarURL *string, +) (string, error) { + var id string + + err := repo.db.Get( + &id, + "INSERT INTO account (username, password, email, avatar_url) " + + "VALUES ($1, $2, $3, $4) " + + "RETURNING id", + username, + password, + email, + avatarURL, + ) + + return id, err +} + +func (repo *AccountRepositoryPostgres) Update( + id string, + username string, + password string, + email *string, + avatarURL *string, +) error { + _, err := repo.db.Exec( + "UPDATE account " + + "SET username=$2,password=$3,email=$4,avatar_url=$5 " + + "WHERE id=$1", + id, + username, + password, + email, + avatarURL, + ) + + return err +} + +func (repo *AccountRepositoryPostgres) ChangeUsername(id string, username string) error { + _, err := repo.db.Exec( + "UPDATE account SET username=$2 WHERE id=$1", + id, username, + ) + return err +} +func (repo *AccountRepositoryPostgres) ChangePassword(id string, password string) error { + _, err := repo.db.Exec( + "UPDATE account SET password=$2 WHERE id=$1", + id, password, + ) + return err +} +func (repo *AccountRepositoryPostgres) ChangeEmail(id string, email string) error { + _, err := repo.db.Exec( + "UPDATE account SET email=$2 WHERE id=$1", + id, email, + ) + return err +} +func (repo *AccountRepositoryPostgres) RemoveEmail(id string) error { + _, err := repo.db.Exec("UPDATE account SET email=NULL WHERE id=$1", id) + return err +} +func (repo *AccountRepositoryPostgres) ChangeAvatarURL(id string, avatarURL string) error { + _, err := repo.db.Exec( + "UPDATE account SET avatar_url=$2 WHERE id=$1", + id, avatarURL, + ) + return err +} +func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error { + _, err := repo.db.Exec("UPDATE account SET avatar_url=NULL WHERE id=$1", id) + return err +} + +func (repo *AccountRepositoryPostgres) Delete(accountID string) error { + _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", accountID) + return err +} + +// Increment the number of account login failure attempts, +// returning the current fail count. +func (repo *AccountRepositoryPostgres) IncrementFails(accountID string) (int, error) { + failAttempts := 0 + err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) + return failAttempts, err +} + +func (repo *AccountRepositoryPostgres) Lock(accountID string) error { + _, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) + return err +} + +func (repo *AccountRepositoryPostgres) Unlock(accountID string) error { + _, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) + return err +} diff --git a/repository/log/interface.go b/repository/log/interface.go new file mode 100644 index 0000000..232a005 --- /dev/null +++ b/repository/log/interface.go @@ -0,0 +1,9 @@ +package log + +import "arimelody-web/model" + +type LogRepository interface { + Create(logLevel model.LogLevel, logType string, content string) error + Get(id string) (*model.Log, error) + Search(levelFilters []model.LogLevel, typeFilters []string, content string, limit int, offset int) ([]*model.Log, error) +} diff --git a/repository/log/postgres.go b/repository/log/postgres.go new file mode 100644 index 0000000..ba1246a --- /dev/null +++ b/repository/log/postgres.go @@ -0,0 +1,110 @@ +package log + +import ( + "arimelody-web/model" + "fmt" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +type ( + LogRepositoryPostgres struct { + db *sqlx.DB + } +) + +var _ LogRepository = new(LogRepositoryPostgres) + +func NewLogRepositoryPostgres(db *sqlx.DB) *LogRepositoryPostgres { + return &LogRepositoryPostgres{ db: db } +} + +func (repo *LogRepositoryPostgres) Create(logLevel model.LogLevel, logType string, content string) error { + _, err := repo.db.Exec( + "INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)", + logLevel, + logType, + content, + ) + return err +} + +func (repo *LogRepositoryPostgres) Get(id string) (*model.Log, error) { + log := model.Log{} + err := repo.db.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id) + return &log, err +} + +func (repo *LogRepositoryPostgres) Search( + levelFilters []model.LogLevel, + typeFilters []string, + content string, + limit int, + offset int, +) ([]*model.Log, error) { + logs := []*model.Log{} + + params := []any{ limit, offset } + conditions := "" + + if len(content) > 0 { + content = "%" + content + "%" + conditions += " WHERE content LIKE $3" + params = append(params, content) + } + + if len(levelFilters) > 0 { + if len(conditions) > 0 { + conditions += " AND level IN (" + } else { + conditions += " WHERE level IN (" + } + for i := range levelFilters { + conditions += fmt.Sprintf("$%d", len(params) + 1) + if i < len(levelFilters) - 1 { + conditions += "," + } + params = append(params, levelFilters[i]) + } + conditions += ")" + } + + if len(typeFilters) > 0 { + if len(conditions) > 0 { + conditions += " AND type IN (" + } else { + conditions += " WHERE type IN (" + } + for i := range typeFilters { + conditions += fmt.Sprintf("$%d", len(params) + 1) + if i < len(typeFilters) - 1 { + conditions += "," + } + params = append(params, typeFilters[i]) + } + conditions += ")" + } + + query := fmt.Sprintf( + "SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2", + conditions, + ) + + /* + fmt.Printf("%s (", query) + for i, param := range params { + fmt.Print(param) + if i < len(params) - 1 { + fmt.Print(", ") + } + } + fmt.Print(")\n") + */ + + err := repo.db.Select(&logs, query, params...) + if err != nil { + return nil, err + } + return logs, nil +} diff --git a/controller/migrator.go b/repository/postgres/migrator.go similarity index 100% rename from controller/migrator.go rename to repository/postgres/migrator.go diff --git a/controller/schema-migration/000-init.sql b/repository/postgres/schema-migration/000-init.sql similarity index 100% rename from controller/schema-migration/000-init.sql rename to repository/postgres/schema-migration/000-init.sql diff --git a/controller/schema-migration/001-pre-versioning.sql b/repository/postgres/schema-migration/001-pre-versioning.sql similarity index 100% rename from controller/schema-migration/001-pre-versioning.sql rename to repository/postgres/schema-migration/001-pre-versioning.sql diff --git a/controller/schema-migration/002-audit-logs.sql b/repository/postgres/schema-migration/002-audit-logs.sql similarity index 100% rename from controller/schema-migration/002-audit-logs.sql rename to repository/postgres/schema-migration/002-audit-logs.sql diff --git a/controller/schema-migration/003-fail-lock.sql b/repository/postgres/schema-migration/003-fail-lock.sql similarity index 100% rename from controller/schema-migration/003-fail-lock.sql rename to repository/postgres/schema-migration/003-fail-lock.sql diff --git a/service/account/account.go b/service/account/account.go new file mode 100644 index 0000000..29be2cc --- /dev/null +++ b/service/account/account.go @@ -0,0 +1,140 @@ +package account + +import ( + "arimelody-web/model" + repository "arimelody-web/repository/account" + "errors" + "log" +) + +type AccountService struct { + repo repository.AccountRepository + log *log.Logger +} + +func NewAccountService(repo repository.AccountRepository, logger *log.Logger) (*AccountService) { + return &AccountService{ + repo: repo, + log: logger, + } +} + +func (s *AccountService) GetAll() ([]model.Account, error) { + return s.repo.GetAll() +} + +func (s *AccountService) GetCount() (int, error) { + return s.repo.GetCount() +} + +func (s *AccountService) GetByID(id string) (*model.Account, error) { + return s.repo.GetByID(id) +} + +func (s *AccountService) GetByUsername(username string) (*model.Account, error) { + return s.repo.GetByUsername(username) +} + +func (s *AccountService) GetByEmail(email string) (*model.Account, error) { + return s.repo.GetByEmail(email) +} + +func (s *AccountService) GetBySession(sessionToken string) (*model.Account, error) { + if sessionToken == "" { return nil, nil } + + return s.repo.GetBySession(sessionToken) +} + +func (s *AccountService) Create( + username string, + password string, + email *string, + avatarURL *string, +) (string, error) { + var id string + var err error + + if len(username) == 0 { return id, errors.New("Username cannot be empty") } + if len(password) == 0 { return id, errors.New("Password cannot be empty") } + if email != nil && len(*email) == 0 { return id, errors.New("Email cannot be empty") } + + if id, err = s.repo.Create(username, password, email, avatarURL); err != nil { + return id, err + } + + s.log.Printf("Created account '%s' (%s)", username, id) + return id, nil +} + +// Intended for large profile updates. For smaller adjusments, +// more specialised Change* and Remove* functions should be used. +func (s *AccountService) Update( + id string, + username string, + password string, + email *string, + avatarUrl *string, +) error { + if len(username) == 0 { return errors.New("Username cannot be empty") } + if len(password) == 0 { return errors.New("Password cannot be empty") } + if email != nil && len(*email) == 0 { return errors.New("Email cannot be empty") } + + if err := s.repo.Update(id, username, password, email, avatarUrl); err != nil { + return err + } + + s.log.Printf("Updated account '%s' (%s)", username, id) + return nil +} +func (s *AccountService) ChangeUsername(id string, username string) error { + if len(username) == 0 { return errors.New("Username cannot be empty") } + if err := s.repo.ChangeUsername(id, username); err != nil { + return err + } + + s.log.Printf("Changed username for %s to '%s'", id, username) + return nil +} +func (s *AccountService) ChangePassword(id string, password string) error { + if len(password) == 0 { return errors.New("Password cannot be empty") } + if err := s.repo.ChangePassword(id, password); err != nil { + return err + } + + s.log.Printf("Changed password for %s", id) + return nil +} +func (s *AccountService) ChangeEmail(id string, email string) error { + if len(email) == 0 { return s.repo.RemoveEmail(id) } + if err := s.repo.ChangeEmail(id, email); err != nil { + return err + } + + s.log.Printf("Changed email for %s to '%s'", id, email) + return nil +} +func (s *AccountService) ChangeAvatarURL(id string, avatarURL string) error { + if len(avatarURL) == 0 { return s.repo.RemoveAvatar(id) } + if err := s.repo.ChangeAvatarURL(id, avatarURL); err != nil { + return err + } + + s.log.Printf("Changed avatar URL for %s to '%s'", id, avatarURL) + return nil +} + +func (s *AccountService) Delete(accountID string) error { + return s.repo.Delete(accountID) +} + +func (s *AccountService) IncrementFails(accountID string) (int, error) { + return s.repo.IncrementFails(accountID) +} + +func (s *AccountService) Lock(accountID string) error { + return s.repo.Lock(accountID) +} + +func (s *AccountService) Unlock(accountID string) error { + return s.repo.Unlock(accountID) +} diff --git a/service/log/log.go b/service/log/log.go new file mode 100644 index 0000000..21f1650 --- /dev/null +++ b/service/log/log.go @@ -0,0 +1,57 @@ +package log + +import ( + "arimelody-web/model" + repository "arimelody-web/repository/log" + "fmt" + "log" + "time" +) + +type LogService struct { + repo repository.LogRepository + log *log.Logger +} + +func NewLogService(repo repository.LogRepository, logger *log.Logger) *LogService { + return &LogService{ + repo: repo, + log: logger, + } +} + +const DEFAULT_LOG_PAGE_LENGTH = 25 + +func (s *LogService) Info(logType string, format string, args ...any) { + logString := fmt.Sprintf(format, args...) + + s.log.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString) + + if err := s.repo.Create(model.LEVEL_INFO, logType, logString); err != nil { + log.Printf("WARN: Failed to push log to database: %v\n", err) + } +} + +func (s *LogService) Warn(logType string, format string, args ...any) { + logString := fmt.Sprintf(format, args...) + + log.Printf("[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString) + + if err := s.repo.Create(model.LEVEL_INFO, logType, logString); err != nil { + log.Printf("WARN: Failed to push log to database: %v\n", err) + } +} + +func (s *LogService) Fetch(id string) (*model.Log, error) { + return s.repo.Get(id) +} + +func (s *LogService) Search( + levelFilters []model.LogLevel, + typeFilters []string, + content string, + limit int, + offset int, +) ([]*model.Log, error) { + return s.repo.Search(levelFilters, typeFilters, content, limit, offset) +} diff --git a/view/index.go b/view/index.go index 0b334ef..4e7092e 100644 --- a/view/index.go +++ b/view/index.go @@ -2,14 +2,15 @@ package view import ( "arimelody-web/controller" - "arimelody-web/model" + "arimelody-web/model/app" + "arimelody-web/model/twitch" "arimelody-web/templates" "fmt" "net/http" "os" ) -func IndexHandler(app *model.AppState) http.Handler { +func IndexHandler(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -18,10 +19,10 @@ func IndexHandler(app *model.AppState) http.Handler { if r.URL.Path == "/" || r.URL.Path == "/index.html" { type IndexData struct { - TwitchStatus *model.TwitchStreamInfo + TwitchStatus *twitch.StreamInfo } var err error - var twitchStatus *model.TwitchStreamInfo = nil + var twitchStatus *twitch.StreamInfo = nil if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 { twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) if err != nil { diff --git a/view/music.go b/view/music.go index fa466d9..9cb4e59 100644 --- a/view/music.go +++ b/view/music.go @@ -1,18 +1,19 @@ package view import ( - "fmt" - "net/http" - "os" + "fmt" + "net/http" + "os" - "arimelody-web/controller" - "arimelody-web/model" - "arimelody-web/templates" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/model/app" + "arimelody-web/templates" ) // HTTP HANDLER METHODS -func MusicHandler(app *model.AppState) http.Handler { +func MusicHandler(app *app.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -33,7 +34,7 @@ func MusicHandler(app *model.AppState) http.Handler { return mux } -func ServeCatalog(app *model.AppState) http.Handler { +func ServeCatalog(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { releases, err := controller.GetAllReleases(app.DB, true, 0, true) if err != nil { @@ -55,7 +56,7 @@ func ServeCatalog(app *model.AppState) http.Handler { }) } -func ServeGateway(app *model.AppState, release *model.Release) http.Handler { +func ServeGateway(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // only allow authorised users to view hidden releases privileged := false