diff --git a/admin/accounthttp.go b/admin/accounthttp.go index c93c8b9..113a17a 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 *app.AppState) http.Handler { +func accountHandler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/account/totp-setup", totpSetupHandler(app)) @@ -28,7 +28,7 @@ func accountHandler(app *app.AppState) http.Handler { return mux } -func accountIndexHandler(app *app.AppState) http.Handler { +func accountIndexHandler(app *model.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 *app.AppState) http.Handler { }) } -func changePasswordHandler(app *app.AppState) http.Handler { +func changePasswordHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -107,7 +107,8 @@ func changePasswordHandler(app *app.AppState) http.Handler { return } - err = app.AccountService.ChangePassword(session.Account.ID, string(hashedPassword)) + session.Account.Password = string(hashedPassword) + err = controller.UpdateAccount(app.DB, session.Account) 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.") @@ -115,7 +116,7 @@ func changePasswordHandler(app *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + app.Log.Info(log.TYPE_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.") @@ -123,7 +124,7 @@ func changePasswordHandler(app *app.AppState) http.Handler { }) } -func deleteAccountHandler(app *app.AppState) http.Handler { +func deleteAccountHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -145,13 +146,13 @@ func deleteAccountHandler(app *app.AppState) http.Handler { // check password if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { - app.Log.Warn(model.LOG_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Incorrect password.") http.Redirect(w, r, "/admin/account", http.StatusFound) return } - err = app.AccountService.Delete(session.Account.ID) + err = controller.DeleteAccount(app.DB, 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.") @@ -159,7 +160,7 @@ func deleteAccountHandler(app *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r)) + app.Log.Info(log.TYPE_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, "") @@ -175,7 +176,7 @@ type totpConfirmData struct { QRBase64Image string } -func totpSetupHandler(app *app.AppState) http.Handler { +func totpSetupHandler(app *model.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) @@ -246,7 +247,7 @@ func totpSetupHandler(app *app.AppState) http.Handler { }) } -func totpConfirmHandler(app *app.AppState) http.Handler { +func totpConfirmHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -310,7 +311,7 @@ func totpConfirmHandler(app *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name) + app.Log.Info(log.TYPE_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)) @@ -318,7 +319,7 @@ func totpConfirmHandler(app *app.AppState) http.Handler { }) } -func totpDeleteHandler(app *app.AppState) http.Handler { +func totpDeleteHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.NotFound(w, r) @@ -358,7 +359,7 @@ func totpDeleteHandler(app *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name) + app.Log.Info(log.TYPE_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 ef67a4e..f151ddd 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -8,10 +8,9 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" - "arimelody-web/model/app" ) -func serveArtists(app *app.AppState) http.Handler { +func serveArtists(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -46,7 +45,7 @@ func serveArtists(app *app.AppState) http.Handler { }) } -func serveArtist(app *app.AppState, artistID string) http.Handler { +func serveArtist(app *model.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 10dc3f6..2a6b4ae 100644 --- a/admin/http.go +++ b/admin/http.go @@ -2,6 +2,7 @@ package admin import ( "context" + "database/sql" "fmt" "net/http" "os" @@ -10,8 +11,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" @@ -22,7 +23,7 @@ type adminPageData struct { Session *model.Session } -func Handler(app *app.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -74,7 +75,7 @@ func Handler(app *app.AppState) http.Handler { return enforceSession(app, mux) } -func AdminIndexHandler(app *app.AppState) http.Handler { +func AdminIndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -149,7 +150,7 @@ func AdminIndexHandler(app *app.AppState) http.Handler { }) } -func registerAccountHandler(app *app.AppState) http.Handler { +func registerAccountHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -222,13 +223,13 @@ func registerAccountHandler(app *app.AppState) http.Handler { return } - defaultAvatar := "/img/default-avatar.png" - accountID, err := app.AccountService.Create( - credentials.Username, - string(hashedPassword), - &credentials.Email, - &defaultAvatar, - ) + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: sql.NullString{ String: credentials.Email, Valid: true }, + AvatarURL: sql.NullString{ String: "/img/default-avatar.png", Valid: true }, + } + err = controller.CreateAccount(app.DB, &account) if err != nil { if strings.HasPrefix(err.Error(), "pq: duplicate key") { controller.SetSessionError(app.DB, session, "An account with that username already exists.") @@ -241,36 +242,22 @@ func registerAccountHandler(app *app.AppState) http.Handler { return } - app.Log.Info( - model.LOG_ACCOUNT, - "Account \"%s\" (%s) created using invite \"%s\". (%s)", - credentials.Username, - accountID, - invite.Code, - controller.ResolveIP(app, r), - ) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r)) err = controller.DeleteInvite(app.DB, invite.Code) if err != nil { - app.Log.Warn(model.LOG_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) } // registration success! - 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.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 *app.AppState) http.Handler { +func loginHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.NotFound(w, r) @@ -312,7 +299,7 @@ func loginHandler(app *app.AppState) http.Handler { username := r.FormValue("username") password := r.FormValue("password") - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err) controller.SetSessionError(app.DB, session, "Invalid username or password.") @@ -332,7 +319,7 @@ func loginHandler(app *app.AppState) http.Handler { err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { - app.Log.Warn(model.LOG_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) if locked := handleFailedLogin(app, account, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") } else { @@ -366,8 +353,8 @@ func loginHandler(app *app.AppState) http.Handler { // login success! // TODO: log login activity to user - 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) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) err = controller.SetSessionAccount(app.DB, session, account) if err != nil { @@ -382,7 +369,7 @@ func loginHandler(app *app.AppState) http.Handler { }) } -func loginTOTPHandler(app *app.AppState) http.Handler { +func loginTOTPHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -420,7 +407,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler { totpCode := r.FormValue("totp") if len(totpCode) != controller.TOTP_CODE_LENGTH { - app.Log.Warn(model.LOG_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return @@ -434,7 +421,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler { return } if totpMethod == nil { - app.Log.Warn(model.LOG_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) if locked := handleFailedLogin(app, session.AttemptAccount, r); locked { controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") controller.SetSessionAttemptAccount(app.DB, session, nil) @@ -446,7 +433,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r)) err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) if err != nil { @@ -465,7 +452,7 @@ func loginTOTPHandler(app *app.AppState) http.Handler { }) } -func logoutHandler(app *app.AppState) http.Handler { +func logoutHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.NotFound(w, r) @@ -527,7 +514,7 @@ func staticHandler() http.Handler { } */ -func enforceSession(app *app.AppState, next http.Handler) http.Handler { +func enforceSession(app *model.AppState, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session, err := controller.GetSessionFromRequest(app, r) if err != nil { @@ -560,45 +547,8 @@ func enforceSession(app *app.AppState, next http.Handler) http.Handler { }) } -// 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 - } - +func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool { + locked, err := controller.IncrementAccountFails(app.DB, account.ID) if err != nil { fmt.Fprintf( os.Stderr, @@ -607,11 +557,19 @@ func handleFailedLogin(app *app.AppState, account *model.Account, r *http.Reques err, ) app.Log.Warn( - model.LOG_ACCOUNT, + log.TYPE_ACCOUNT, "Failed to increment login failures for \"%s\"", account.Username, ) } - - return false + if locked { + app.Log.Warn( + log.TYPE_ACCOUNT, + "Account \"%s\" was locked: %d failed login attempts (IP: %s)", + account.Username, + model.MAX_LOGIN_FAIL_ATTEMPTS, + controller.ResolveIP(app, r), + ) + } + return locked } diff --git a/admin/logshttp.go b/admin/logshttp.go index 70c9627..a6d8e40 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 *app.AppState) http.Handler { +func logsHandler(app *model.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 *app.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - levelFilter := []model.LogLevel{} + levelFilter := []log.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]model.LogLevel{ - "info": model.LEVEL_INFO, - "warn": model.LEVEL_WARN, + m := map[string]log.LogLevel{ + "info": log.LEVEL_INFO, + "warn": log.LEVEL_WARN, } level, ok := m[strings.TrimPrefix(key, "level-")] if ok { @@ -52,7 +52,7 @@ func logsHandler(app *app.AppState) http.Handler { type LogsResponse struct { adminPageData - Logs []*model.Log + Logs []*log.Log } err = templates.LogsTemplate.Execute(w, LogsResponse{ diff --git a/admin/releasehttp.go b/admin/releasehttp.go index d16528e..cee3d1e 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -9,10 +9,9 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" - "arimelody-web/model/app" ) -func serveReleases(app *app.AppState) http.Handler { +func serveReleases(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -56,7 +55,7 @@ func serveReleases(app *app.AppState) http.Handler { }) } -func serveRelease(app *app.AppState, releaseID string, action string) http.Handler { +func serveRelease(app *model.AppState, releaseID string, action string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -128,7 +127,7 @@ func serveEditCredits(release *model.Release) http.Handler { }) } -func serveAddCredit(app *app.AppState, release *model.Release) http.Handler { +func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { @@ -154,7 +153,7 @@ func serveAddCredit(app *app.AppState, release *model.Release) http.Handler { }) } -func serveNewCredit(app *app.AppState) http.Handler { +func serveNewCredit(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { split := strings.Split(r.URL.Path, "/") artistID := split[len(split) - 1] @@ -205,7 +204,7 @@ func serveEditTracks(release *model.Release) http.Handler { }) } -func serveAddTrack(app *app.AppState, release *model.Release) http.Handler { +func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { @@ -231,7 +230,7 @@ func serveAddTrack(app *app.AppState, release *model.Release) http.Handler { }) } -func serveNewTrack(app *app.AppState) http.Handler { +func serveNewTrack(app *model.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 e194edd..51b1376 100644 --- a/admin/templates/templates.go +++ b/admin/templates/templates.go @@ -1,7 +1,7 @@ package templates import ( - "arimelody-web/model" + "arimelody-web/log" _ "embed" "fmt" "html/template" @@ -163,11 +163,11 @@ var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(c -func parseLevel(level model.LogLevel) string { +func parseLevel(level log.LogLevel) string { switch level { - case model.LEVEL_INFO: + case log.LEVEL_INFO: return "INFO" - case model.LEVEL_WARN: + case log.LEVEL_WARN: return "WARN" } return fmt.Sprintf("%d?", level) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 2122748..bcb5220 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -8,10 +8,9 @@ import ( "arimelody-web/admin/templates" "arimelody-web/controller" "arimelody-web/model" - "arimelody-web/model/app" ) -func serveTracks(app *app.AppState) http.Handler { +func serveTracks(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) @@ -46,7 +45,7 @@ func serveTracks(app *app.AppState) http.Handler { }) } -func serveTrack(app *app.AppState, trackID string) http.Handler { +func serveTrack(app *model.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 84b7f8a..c5156c2 100644 --- a/api/api.go +++ b/api/api.go @@ -1,18 +1,17 @@ package api import ( - "context" - "fmt" - "net/http" - "os" - "strings" + "context" + "fmt" + "net/http" + "os" + "strings" - "arimelody-web/controller" - "arimelody-web/model" - "arimelody-web/model/app" + "arimelody-web/controller" + "arimelody-web/model" ) -func Handler(app *app.AppState) http.Handler { +func Handler(app *model.AppState) http.Handler { mux := http.NewServeMux() // TODO: generate API keys on the frontend @@ -167,7 +166,7 @@ func requireAccount(next http.Handler) http.Handler { }) } -func getSession(app *app.AppState, r *http.Request) (*model.Session, error) { +func getSession(app *model.AppState, r *http.Request) (*model.Session, error) { var token string // check cookies first @@ -185,7 +184,7 @@ func getSession(app *app.AppState, r *http.Request) (*model.Session, error) { if token == "" { return nil, nil } // fetch existing session - session, err := controller.GetSession(app, token) + session, err := controller.GetSession(app.DB, 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 d5f8a31..01899a6 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/model" - "arimelody-web/model/app" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) -func ServeAllArtists(app *app.AppState) http.Handler { +func ServeAllArtists(app *model.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 *app.AppState) http.Handler { }) } -func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler { +func ServeArtist(app *model.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 *app.AppState, artist *model.Artist) http.Handler { }) } -func CreateArtist(app *app.AppState) http.Handler { +func CreateArtist(app *model.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 *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username) w.WriteHeader(http.StatusCreated) }) } -func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler { +func UpdateArtist(app *model.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 *app.AppState, artist *model.Artist) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username) }) } -func DeleteArtist(app *app.AppState, artist *model.Artist) http.Handler { +func DeleteArtist(app *model.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 *app.AppState, artist *model.Artist) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username) + app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username) }) } diff --git a/api/release.go b/api/release.go index 71bac93..69b7f12 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/model" - "arimelody-web/model/app" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) -func ServeRelease(app *app.AppState, release *model.Release) http.Handler { +func ServeRelease(app *model.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 *app.AppState, release *model.Release) http.Handler { }) } -func ServeCatalog(app *app.AppState) http.Handler { +func ServeCatalog(app *model.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 *app.AppState) http.Handler { }) } -func CreateRelease(app *app.AppState) http.Handler { +func CreateRelease(app *model.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 *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_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 *app.AppState) http.Handler { }) } -func UpdateRelease(app *app.AppState, release *model.Release) http.Handler { +func UpdateRelease(app *model.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 *app.AppState, release *model.Release) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseTracks(app *app.AppState, release *model.Release) http.Handler { +func UpdateReleaseTracks(app *model.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 *app.AppState, release *model.Release) http.Handler http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handler { +func UpdateReleaseCredits(app *model.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 *app.AppState, release *model.Release) http.Handle http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func UpdateReleaseLinks(app *app.AppState, release *model.Release) http.Handler { +func UpdateReleaseLinks(app *model.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 *app.AppState, release *model.Release) http.Handler http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) }) } -func DeleteRelease(app *app.AppState, release *model.Release) http.Handler { +func DeleteRelease(app *model.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 *app.AppState, release *model.Release) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username) + app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username) }) } diff --git a/api/track.go b/api/track.go index 8d81ea6..4e48418 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/model" - "arimelody-web/model/app" + "arimelody-web/controller" + "arimelody-web/log" + "arimelody-web/model" ) type ( @@ -17,7 +17,7 @@ type ( } ) -func ServeAllTracks(app *app.AppState) http.Handler { +func ServeAllTracks(app *model.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 *app.AppState) http.Handler { }) } -func ServeTrack(app *app.AppState, track *model.Track) http.Handler { +func ServeTrack(app *model.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 *app.AppState, track *model.Track) http.Handler { }) } -func CreateTrack(app *app.AppState) http.Handler { +func CreateTrack(app *model.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 *app.AppState) http.Handler { return } - app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(log.TYPE_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 *app.AppState) http.Handler { }) } -func UpdateTrack(app *app.AppState, track *model.Track) http.Handler { +func UpdateTrack(app *model.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 *app.AppState, track *model.Track) http.Handler { return } - app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(log.TYPE_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 *app.AppState, track *model.Track) http.Handler { }) } -func DeleteTrack(app *app.AppState, track *model.Track) http.Handler { +func DeleteTrack(app *model.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 *app.AppState, track *model.Track) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.Log.Info(model.LOG_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username) + app.Log.Info(log.TYPE_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 27731c2..3c3c58a 100644 --- a/api/uploads.go +++ b/api/uploads.go @@ -1,18 +1,18 @@ package api import ( - "arimelody-web/model" - "arimelody-web/model/app" - "bufio" - "encoding/base64" - "errors" - "fmt" - "os" - "path/filepath" - "strings" + "arimelody-web/log" + "arimelody-web/model" + "bufio" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" ) -func HandleImageUpload(app *app.AppState, data *string, directory string, filename string) (string, error) { +func HandleImageUpload(app *model.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 *app.AppState, data *string, directory string, filena return "", nil } - app.Log.Info(model.LOG_FILES, "\"%s\" created.", imagePath) + app.Log.Info(log.TYPE_FILES, "\"%s\" created.", imagePath) return filename, nil } diff --git a/controller/account.go b/controller/account.go new file mode 100644 index 0000000..ab64ca5 --- /dev/null +++ b/controller/account.go @@ -0,0 +1,135 @@ +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 25744ac..77619d7 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/app" + "arimelody-web/model" - "github.com/pelletier/go-toml/v2" + "github.com/pelletier/go-toml/v2" ) -func GetConfig() app.Config { +func GetConfig() model.Config { configFile := os.Getenv("ARIMELODY_CONFIG") if configFile == "" { configFile = "config.toml" } - config := app.Config{ + config := model.Config{ BaseUrl: "https://arimelody.space", Host: "0.0.0.0", Port: 8080, TrustedProxies: []string{ "127.0.0.1" }, - DB: app.DBConfig{ + DB: model.DBConfig{ Host: "127.0.0.1", Port: 5432, User: "arimelody", @@ -53,7 +53,7 @@ func GetConfig() app.Config { return config } -func handleConfigOverrides(config *app.Config) error { +func handleConfigOverrides(config *model.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 1d6795b..cbc3054 100644 --- a/controller/ip.go +++ b/controller/ip.go @@ -1,15 +1,15 @@ package controller import ( - "arimelody-web/model/app" - "net/http" - "slices" - "strings" + "arimelody-web/model" + "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 *app.AppState, r *http.Request) string { +func ResolveIP(app *model.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/repository/postgres/migrator.go b/controller/migrator.go similarity index 100% rename from repository/postgres/migrator.go rename to controller/migrator.go diff --git a/repository/postgres/schema-migration/000-init.sql b/controller/schema-migration/000-init.sql similarity index 100% rename from repository/postgres/schema-migration/000-init.sql rename to controller/schema-migration/000-init.sql diff --git a/repository/postgres/schema-migration/001-pre-versioning.sql b/controller/schema-migration/001-pre-versioning.sql similarity index 100% rename from repository/postgres/schema-migration/001-pre-versioning.sql rename to controller/schema-migration/001-pre-versioning.sql diff --git a/repository/postgres/schema-migration/002-audit-logs.sql b/controller/schema-migration/002-audit-logs.sql similarity index 100% rename from repository/postgres/schema-migration/002-audit-logs.sql rename to controller/schema-migration/002-audit-logs.sql diff --git a/repository/postgres/schema-migration/003-fail-lock.sql b/controller/schema-migration/003-fail-lock.sql similarity index 100% rename from repository/postgres/schema-migration/003-fail-lock.sql rename to controller/schema-migration/003-fail-lock.sql diff --git a/controller/session.go b/controller/session.go index 875d50f..dfae551 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/model" - "arimelody-web/model/app" + "arimelody-web/log" + "arimelody-web/model" - "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx" ) const TOKEN_LEN = 64 -func GetSessionFromRequest(app *app.AppState, r *http.Request) (*model.Session, error) { +func GetSessionFromRequest(app *model.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 *app.AppState, r *http.Request) (*model.Session, if sessionCookie != nil { // fetch existing session - session, err = GetSession(app, sessionCookie.Value) + session, err = GetSession(app.DB, 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 *app.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, _ := app.AccountService.GetByID(session.Account.ID) + account, _ := GetAccountByID(app.DB, session.Account.ID) msg += " (Account \"" + account.Username + "\")" } - app.Log.Warn(model.LOG_ACCOUNT, msg) + app.Log.Warn(log.TYPE_ACCOUNT, msg) err = DeleteSession(app.DB, session.Token) if err != nil { - app.Log.Warn(model.LOG_ACCOUNT, "Failed to delete affected session") + app.Log.Warn(log.TYPE_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(app *app.AppState, token string) (*model.Session, error) { +func GetSession(db *sqlx.DB, token string) (*model.Session, error) { type dbSession struct { model.Session AttemptAccountID sql.NullString `db:"attempt_account"` @@ -145,7 +145,7 @@ func GetSession(app *app.AppState, token string) (*model.Session, error) { } session := dbSession{} - err := app.DB.Get( + err := db.Get( &session, "SELECT * FROM session WHERE token=$1", token, @@ -155,14 +155,14 @@ func GetSession(app *app.AppState, token string) (*model.Session, error) { } if session.AccountID.Valid { - session.Account, err = app.AccountService.GetByID(session.AccountID.String) + session.Account, err = GetAccountByID(db, session.AccountID.String) if err != nil { return nil, err } } if session.AttemptAccountID.Valid { - session.AttemptAccount, err = app.AccountService.GetByID(session.AttemptAccountID.String) + session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String) if err != nil { return nil, err } diff --git a/controller/twitch.go b/controller/twitch.go index 184ba61..98d3b9a 100644 --- a/controller/twitch.go +++ b/controller/twitch.go @@ -1,8 +1,7 @@ package controller import ( - "arimelody-web/model/app" - "arimelody-web/model/twitch" + "arimelody-web/model" "bytes" "encoding/json" "net/http" @@ -12,13 +11,13 @@ import ( const TWITCH_API_BASE = "https://api.twitch.tv/helix/" -func TwitchSetup(app *app.AppState) error { - app.Twitch = &twitch.State{} +func TwitchSetup(app *model.AppState) error { + app.Twitch = &model.TwitchState{} err := RefreshTwitchToken(app) return err } -func RefreshTwitchToken(app *app.AppState) error { +func RefreshTwitchToken(app *model.AppState) error { if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) { return nil } @@ -46,7 +45,7 @@ func RefreshTwitchToken(app *app.AppState) error { return err } - app.Twitch.Token = &twitch.OAuthToken{ + app.Twitch.Token = &model.TwitchOAuthToken{ AccessToken: oauthResponse.AccessToken, ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(), TokenType: oauthResponse.TokenType, @@ -55,10 +54,10 @@ func RefreshTwitchToken(app *app.AppState) error { return nil } -var lastStreamState *twitch.StreamInfo +var lastStreamState *model.TwitchStreamInfo var lastStreamStateAt time.Time -func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo, error) { +func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) { if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) { return lastStreamState, nil } @@ -77,7 +76,7 @@ func GetTwitchStatus(app *app.AppState, broadcaster string) (*twitch.StreamInfo, } type StreamsResponse struct { - Data []twitch.StreamInfo `json:"data"` + Data []model.TwitchStreamInfo `json:"data"` } streamInfo := StreamsResponse{} err = json.NewDecoder(res.Body).Decode(&streamInfo) diff --git a/cursor/cursor.go b/cursor/cursor.go index cd1d259..56edb56 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,16 +1,16 @@ package cursor import ( - "arimelody-web/model/app" - "fmt" - "math/rand" - "net/http" - "strconv" - "strings" - "sync" - "time" + "arimelody-web/model" + "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 *app.AppState) { +func StartCursor(app *model.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 *app.AppState) http.HandlerFunc { +func Handler(app *model.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 a840b09..0eb9b97 100644 --- a/discord/discord.go +++ b/discord/discord.go @@ -1,13 +1,13 @@ package discord import ( - "arimelody-web/model/app" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" + "arimelody-web/model" + "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 *app.AppState, code string) (string, error) { +func GetOAuthTokenFromCode(app *model.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 *app.AppState) string { +func GetRedirectURI(app *model.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 new file mode 100644 index 0000000..88d328b --- /dev/null +++ b/log/log.go @@ -0,0 +1,143 @@ +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 235b2e8..553f109 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,6 @@ import ( "embed" "errors" "fmt" - "log" stdLog "log" "math" "math/rand" @@ -22,13 +21,8 @@ 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" @@ -41,17 +35,14 @@ 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() { - logger := log.New(os.Stderr, "main", DEFAULT_LOG_FLAGS) + fmt.Printf("made with <3 by ari melody\n\n") - logger.Print("made with <3 by ari melody\n\n") - - app := app.AppState{ + app := model.AppState{ Config: controller.GetConfig(), Twitch: nil, PublicFS: publicFS, @@ -59,49 +50,44 @@ func main() { // initialise database connection if app.Config.DB.Host == "" { - logger.Fatalf("FATAL: db.host not provided! Exiting...\n") + fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n") + os.Exit(1) } if app.Config.DB.Name == "" { - logger.Fatalf("FATAL: db.name not provided! Exiting...\n") + fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n") + os.Exit(1) } if app.Config.DB.User == "" { - logger.Fatalf("FATAL: db.user not provided! Exiting...\n") + fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n") + os.Exit(1) } if app.Config.DB.Pass == "" { - logger.Fatalf("FATAL: db.pass not provided! Exiting...\n") + fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n") + os.Exit(1) } - psqlDB, err := sqlx.Connect( + var err error + app.DB, err = sqlx.Connect( "postgres", fmt.Sprintf( - "host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable", + "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable", app.Config.DB.Host, app.Config.DB.Port, app.Config.DB.User, - app.Config.DB.Pass, app.Config.DB.Name, + app.Config.DB.Pass, ), ) if err != nil { - logger.Fatalf("Failed to connect to database: %v", err) + fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err) + os.Exit(1) } - defer psqlDB.Close() - psqlDB.SetConnMaxLifetime(time.Minute * 3) - psqlDB.SetMaxOpenConns(10) - psqlDB.SetMaxIdleConns(10) - app.DB = psqlDB + app.DB.SetConnMaxLifetime(time.Minute * 3) + app.DB.SetMaxOpenConns(10) + app.DB.SetMaxIdleConns(10) + defer app.DB.Close() - 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), - ) + app.Log = log.Logger{ DB: app.DB } // handle command arguments if len(os.Args) > 1 { @@ -110,18 +96,21 @@ func main() { switch arg { case "createTOTP": if len(os.Args) < 4 { - logger.Fatalf("FATAL: `username` and `name` must be specified for createTOTP.\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n") + os.Exit(1) } username := os.Args[2] totpName := os.Args[3] - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH) @@ -134,116 +123,133 @@ func main() { err = controller.CreateTOTP(app.DB, &totp) if err != nil { if strings.HasPrefix(err.Error(), "pq: duplicate key") { - logger.Fatalf("FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name) + 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: Failed to create TOTP method: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username) + app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username) url := controller.GenerateTOTPURI(account.Username, totp.Secret) - logger.Printf("%s\n", url) + fmt.Printf("%s\n", url) return case "deleteTOTP": if len(os.Args) < 4 { - logger.Fatalf("FATAL: `username` and `name` must be specified for deleteTOTP.\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n") + os.Exit(1) } username := os.Args[2] totpName := os.Args[3] - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } err = controller.DeleteTOTP(app.DB, account.ID, totpName) if err != nil { - logger.Fatalf("FATAL: Failed to create TOTP method: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err) + os.Exit(1) } - 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) + 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) return case "listTOTP": if len(os.Args) < 3 { - logger.Fatalf("FATAL: `username` must be specified for listTOTP.\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n") + os.Exit(1) } username := os.Args[2] - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } totps, err := controller.GetTOTPsForAccount(app.DB, account.ID) if err != nil { - logger.Fatalf("FATAL: Failed to create TOTP methods: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err) + os.Exit(1) } for i, totp := range totps { - logger.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) + fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt) } if len(totps) == 0 { - logger.Printf("\"%s\" has no TOTP methods.\n", account.Username) + fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username) } return case "testTOTP": if len(os.Args) < 4 { - logger.Fatalf("FATAL: `username` and `name` must be specified for testTOTP.\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n") + os.Exit(1) } username := os.Args[2] totpName := os.Args[3] - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } totp, err := controller.GetTOTP(app.DB, account.ID, totpName) if err != nil { - logger.Fatalf("FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err) + os.Exit(1) } if totp == nil { - logger.Fatalf("FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) + fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username) + os.Exit(1) } code := controller.GenerateTOTP(totp.Secret, 0) - logger.Printf("%s\n", code) + fmt.Printf("%s\n", code) return case "cleanTOTP": err := controller.DeleteUnconfirmedTOTPs(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to clean up TOTP methods: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "TOTP methods pruned via config utility.") - logger.Printf("Cleaned up dangling TOTP methods successfully.\n") + app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.") + fmt.Printf("Cleaned up dangling TOTP methods successfully.\n") return case "createInvite": - logger.Printf("Creating invite...\n") + fmt.Printf("Creating invite...\n") invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) if err != nil { - logger.Fatalf("FATAL: Failed to create invite code: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "Invite generted via config utility (%s).", invite.Code) - logger.Printf( + app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code) + fmt.Printf( "Here you go! This code expires in %d hours: %s\n", int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())), invite.Code, @@ -251,26 +257,28 @@ func main() { return case "purgeInvites": - logger.Printf("Deleting all invites...\n") + fmt.Printf("Deleting all invites...\n") err := controller.DeleteAllInvites(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to delete invites: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "Invites purged via config utility.") - logger.Printf("Invites deleted successfully.\n") + app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.") + fmt.Printf("Invites deleted successfully.\n") return case "listAccounts": - accounts, err := app.AccountService.GetAll() + accounts, err := controller.GetAllAccounts(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to fetch accounts: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err) + os.Exit(1) } for _, account := range accounts { email := "" if account.Email.Valid { email = account.Email.String } - logger.Printf( + fmt.Printf( "User: %s\n" + "\tID: %s\n" + "\tEmail: %s\n" + @@ -287,140 +295,150 @@ func main() { case "changePassword": if len(os.Args) < 4 { - logger.Fatalf("FATAL: `username` and `password` must be specified for changePassword\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n") + os.Exit(1) } username := os.Args[2] password := os.Args[3] - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - logger.Fatalf("FATAL: Failed to update password: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) + os.Exit(1) } account.Password = string(hashedPassword) - - 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) + err = controller.UpdateAccount(app.DB, account) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err) + os.Exit(1) } - 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) + 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) return case "deleteAccount": if len(os.Args) < 3 { - logger.Fatalf("FATAL: `username` must be specified for deleteAccount\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n") + os.Exit(1) } username := os.Args[2] - logger.Printf("Deleting account \"%s\"...\n", username) + fmt.Printf("Deleting account \"%s\"...\n", username) - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } - logger.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username) + fmt.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 = app.AccountService.Delete(account.ID) + err = controller.DeleteAccount(app.DB, account.ID) if err != nil { - logger.Fatalf("FATAL: Failed to delete account: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "Account '%s' deleted via config utility.", account.Username) - logger.Printf("Account \"%s\" deleted successfully.\n", account.Username) + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username) + fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) return case "lockAccount": if len(os.Args) < 3 { - logger.Fatalf("FATAL: `username` must be specified for lockAccount\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n") + os.Exit(1) } username := os.Args[2] - logger.Printf("Unlocking account \"%s\"...\n", username) + fmt.Printf("Unlocking account \"%s\"...\n", username) - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } - err = app.AccountService.Lock(account.ID) + err = controller.LockAccount(app.DB, account.ID) if err != nil { - logger.Fatalf("FATAL: Failed to lock account: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "Account '%s' locked via config utility.", account.Username) - logger.Printf("Account \"%s\" locked successfully.\n", account.Username) + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username) + fmt.Printf("Account \"%s\" locked successfully.\n", account.Username) return case "unlockAccount": if len(os.Args) < 3 { - logger.Fatalf("FATAL: `username` must be specified for unlockAccount\n") + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n") + os.Exit(1) } username := os.Args[2] - logger.Printf("Unlocking account \"%s\"...\n", username) + fmt.Printf("Unlocking account \"%s\"...\n", username) - account, err := app.AccountService.GetByUsername(username) + account, err := controller.GetAccountByUsername(app.DB, username) if err != nil { - logger.Fatalf("FATAL: Failed to fetch account \"%s\": %v\n", username, err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) + os.Exit(1) } if account == nil { - logger.Fatalf("FATAL: Account \"%s\" does not exist.\n", username) + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) + os.Exit(1) } - err = app.AccountService.Unlock(account.ID) + err = controller.UnlockAccount(app.DB, account.ID) if err != nil { - logger.Fatalf("FATAL: Failed to unlock account: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err) + os.Exit(1) } - app.Log.Info(model.LOG_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) - logger.Printf("Account \"%s\" unlocked successfully.\n", account.Username) + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) + fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username) return case "logs": // TODO: add log search parameters - logs, err := app.Log.Search([]model.LogLevel{}, []string{}, "", 100, 0) + logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0) if err != nil { - logger.Fatalf("FATAL: Failed to fetch logs: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err) + os.Exit(1) } for _, item := range(logs) { levelStr := "" switch item.Level { - case model.LEVEL_INFO: + case log.LEVEL_INFO: levelStr = "INFO" - case model.LEVEL_WARN: + case log.LEVEL_WARN: levelStr = "WARN" default: levelStr = fmt.Sprintf("? (%d)", item.Level) } - logger.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content) + fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content) } return } @@ -446,66 +464,68 @@ func main() { } // handle DB migrations - if psqlDB != nil { - repo.CheckDBVersionAndMigrate(psqlDB) - } + controller.CheckDBVersionAndMigrate(app.DB) if app.Config.Twitch != nil { err = controller.TwitchSetup(&app) if err != nil { - logger.Printf("WARN: Failed to set up Twitch integration: %v\n", err) + fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) } } // initial invite code - accountsCount, err := app.AccountService.GetCount() + accountsCount := 0 + err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") if err != nil { panic(err) } if accountsCount == 0 { _, err := app.DB.Exec("DELETE FROM invite") if err != nil { - logger.Fatalf("FATAL: Failed to clear existing invite codes: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err) + os.Exit(1) } invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24) if err != nil { - logger.Fatalf("FATAL: Failed to create invite code: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err) + os.Exit(1) } - logger.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) + fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code) } // delete expired sessions err = controller.DeleteExpiredSessions(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to clear expired sessions: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err) + os.Exit(1) } // delete expired invites err = controller.DeleteExpiredInvites(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to clear expired invite codes: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err) + os.Exit(1) } // clean up unconfirmed TOTP methods err = controller.DeleteUnconfirmedTOTPs(app.DB) if err != nil { - logger.Fatalf("FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err) + fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err) + os.Exit(1) } go cursor.StartCursor(&app) - httpLogger := log.New(os.Stderr, "http", DEFAULT_LOG_FLAGS) - // start the web server! mux := createServeMux(&app) - logger.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) + fmt.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, httpLogger, HTTPLog(httpLogger, DefaultHeaders(mux))), + CheckRequest(&app, HTTPLog(DefaultHeaders(mux))), )) } -func createServeMux(app *app.AppState) *http.ServeMux { +func createServeMux(app *model.AppState) *http.ServeMux { mux := http.NewServeMux() mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app))) @@ -548,7 +568,7 @@ var PoweredByStrings = []string{ "30 billion dollars in VC funding", } -func CheckRequest(app *app.AppState, log *log.Logger, next http.Handler) http.Handler { +func CheckRequest(app *model.AppState, 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. @@ -565,7 +585,8 @@ func CheckRequest(app *app.AppState, log *log.Logger, next http.Handler) http.Ha if strings.HasSuffix(r.URL.Path, ".php") || strings.HasSuffix(r.URL.Path, ".php7") { http.NotFound(w, r) - log.Printf( + fmt.Fprintf( + os.Stderr, "WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n", r.URL.Path, r.RemoteAddr, @@ -615,7 +636,7 @@ func (lrw *LoggingResponseWriter) WriteHeader(status int) { lrw.ResponseWriter.WriteHeader(status) } -func HTTPLog(log *log.Logger, next http.Handler) http.Handler { +func HTTPLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -637,7 +658,7 @@ func HTTPLog(log *log.Logger, next http.Handler) http.Handler { if lrw.Status - 400 <= 0 { statusColour = colour.White } if lrw.Status - 300 <= 0 { statusColour = colour.Green } - log.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", + fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", after.Format(time.UnixDate), r.Method, r.URL.Path, diff --git a/model/app/appstate.go b/model/appstate.go similarity index 83% rename from model/app/appstate.go rename to model/appstate.go index bc8d69b..1a13be9 100644 --- a/model/app/appstate.go +++ b/model/appstate.go @@ -1,13 +1,11 @@ -package app +package model import ( "embed" "github.com/jmoiron/sqlx" - "arimelody-web/model/twitch" - accountService "arimelody-web/service/account" - "arimelody-web/service/log" + "arimelody-web/log" ) type ( @@ -45,10 +43,8 @@ type ( AppState struct { DB *sqlx.DB Config Config - Log *log.LogService - Twitch *twitch.State + Log log.Logger + Twitch *TwitchState PublicFS embed.FS - - AccountService *accountService.AccountService } ) diff --git a/model/log.go b/model/log.go deleted file mode 100644 index af4df5e..0000000 --- a/model/log.go +++ /dev/null @@ -1,31 +0,0 @@ -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/twitch.go b/model/twitch.go similarity index 82% rename from model/twitch/twitch.go rename to model/twitch.go index 4902403..6bca17d 100644 --- a/model/twitch/twitch.go +++ b/model/twitch.go @@ -1,4 +1,4 @@ -package twitch +package model import ( "fmt" @@ -7,17 +7,17 @@ import ( ) type ( - OAuthToken struct { + TwitchOAuthToken struct { AccessToken string ExpiresAt time.Time TokenType string } - State struct { - Token *OAuthToken + TwitchState struct { + Token *TwitchOAuthToken } - StreamInfo struct { + TwitchStreamInfo struct { ID string `json:"id"` UserID string `json:"user_id"` UserLogin string `json:"user_login"` @@ -36,7 +36,7 @@ type ( } ) -func (info *StreamInfo) Thumbnail(width int, height int) string { +func (info *TwitchStreamInfo) 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 deleted file mode 100644 index 5e047d6..0000000 --- a/repository/account/interface.go +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index f38cf3e..0000000 --- a/repository/account/postgres.go +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index 232a005..0000000 --- a/repository/log/interface.go +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index ba1246a..0000000 --- a/repository/log/postgres.go +++ /dev/null @@ -1,110 +0,0 @@ -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/service/account/account.go b/service/account/account.go deleted file mode 100644 index 29be2cc..0000000 --- a/service/account/account.go +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index 21f1650..0000000 --- a/service/log/log.go +++ /dev/null @@ -1,57 +0,0 @@ -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/templates/html/music-gateway.html b/templates/html/music-gateway.html index 19049e8..9ad212e 100644 --- a/templates/html/music-gateway.html +++ b/templates/html/music-gateway.html @@ -83,11 +83,10 @@ {{else if .IsSingle}} - {{with index .Tracks 0}} + {{index .Tracks 0}} {{if .Description}}

{{.Description}}

{{end}} - {{end}} {{end}} diff --git a/view/index.go b/view/index.go index 4e7092e..0b334ef 100644 --- a/view/index.go +++ b/view/index.go @@ -2,15 +2,14 @@ package view import ( "arimelody-web/controller" - "arimelody-web/model/app" - "arimelody-web/model/twitch" + "arimelody-web/model" "arimelody-web/templates" "fmt" "net/http" "os" ) -func IndexHandler(app *app.AppState) http.Handler { +func IndexHandler(app *model.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -19,10 +18,10 @@ func IndexHandler(app *app.AppState) http.Handler { if r.URL.Path == "/" || r.URL.Path == "/index.html" { type IndexData struct { - TwitchStatus *twitch.StreamInfo + TwitchStatus *model.TwitchStreamInfo } var err error - var twitchStatus *twitch.StreamInfo = nil + var twitchStatus *model.TwitchStreamInfo = 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 9cb4e59..fa466d9 100644 --- a/view/music.go +++ b/view/music.go @@ -1,19 +1,18 @@ package view import ( - "fmt" - "net/http" - "os" + "fmt" + "net/http" + "os" - "arimelody-web/controller" - "arimelody-web/model" - "arimelody-web/model/app" - "arimelody-web/templates" + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" ) // HTTP HANDLER METHODS -func MusicHandler(app *app.AppState) http.Handler { +func MusicHandler(app *model.AppState) http.Handler { mux := http.NewServeMux() mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -34,7 +33,7 @@ func MusicHandler(app *app.AppState) http.Handler { return mux } -func ServeCatalog(app *app.AppState) http.Handler { +func ServeCatalog(app *model.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 { @@ -56,7 +55,7 @@ func ServeCatalog(app *app.AppState) http.Handler { }) } -func ServeGateway(app *app.AppState, release *model.Release) http.Handler { +func ServeGateway(app *model.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