diff --git a/admin/accounthttp.go b/admin/accounthttp.go index 098eb59..fc03d77 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -6,9 +6,9 @@ import ( "net/http" "net/url" "os" - "time" "arimelody-web/controller" + "arimelody-web/log" "arimelody-web/model" "golang.org/x/crypto/bcrypt" @@ -115,6 +115,8 @@ func changePasswordHandler(app *model.AppState) http.Handler { return } + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(r)) + controller.SetSessionError(app.DB, session, "") controller.SetSessionMessage(app.DB, session, "Password updated successfully.") http.Redirect(w, r, "/admin/account", http.StatusFound) @@ -143,11 +145,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { // check password if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil { - fmt.Printf( - "[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) + app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(r)) controller.SetSessionError(app.DB, session, "Incorrect password.") http.Redirect(w, r, "/admin/account", http.StatusFound) return @@ -161,11 +159,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "[%s] INFO: Account \"%s\" deleted by user request.\n", - time.Now().Format(time.UnixDate), - session.Account.Username, - ) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(r)) controller.SetSessionAccount(app.DB, session, nil) controller.SetSessionError(app.DB, session, "") @@ -324,6 +318,8 @@ func totpConfirmHandler(app *model.AppState) http.Handler { return } + 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)) http.Redirect(w, r, "/admin/account", http.StatusFound) @@ -365,6 +361,8 @@ func totpDeleteHandler(app *model.AppState) http.Handler { return } + 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)) http.Redirect(w, r, "/admin/account", http.StatusFound) diff --git a/admin/http.go b/admin/http.go index 0ca61a3..c70dd1d 100644 --- a/admin/http.go +++ b/admin/http.go @@ -11,6 +11,7 @@ import ( "time" "arimelody-web/controller" + "arimelody-web/log" "arimelody-web/model" "golang.org/x/crypto/bcrypt" @@ -39,6 +40,8 @@ func Handler(app *model.AppState) http.Handler { mux.Handle("/account", requireAccount(accountIndexHandler(app))) mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app)))) + mux.Handle("/logs", requireAccount(logsHandler(app))) + mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app)))) mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app)))) mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app)))) @@ -198,15 +201,12 @@ func registerAccountHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "[%s]: Account registered: %s (%s)\n", - time.Now().Format(time.UnixDate), - account.Username, - account.ID, - ) + app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(r)) err = controller.DeleteInvite(app.DB, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + if err != nil { + app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err) + } // registration success! controller.SetSessionAccount(app.DB, session, &account) @@ -277,11 +277,7 @@ func loginHandler(app *model.AppState) http.Handler { err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) if err != nil { - fmt.Printf( - "[%s] INFO: Account \"%s\" attempted login with incorrect password.\n", - time.Now().Format(time.UnixDate), - account.Username, - ) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(r)) controller.SetSessionError(app.DB, session, "Invalid username or password.") render() return @@ -307,15 +303,11 @@ func loginHandler(app *model.AppState) http.Handler { return } - fmt.Printf( - "[%s] INFO: Account \"%s\" logged in\n", - time.Now().Format(time.UnixDate), - account.Username, - ) - - // TODO: log login activity to user - // login success! + // TODO: log login activity to user + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(r)) + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username) + err = controller.SetSessionAccount(app.DB, session, account) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err) @@ -371,6 +363,7 @@ func loginTOTPHandler(app *model.AppState) http.Handler { totpCode := r.FormValue("totp") if len(totpCode) != controller.TOTP_CODE_LENGTH { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return @@ -384,17 +377,13 @@ func loginTOTPHandler(app *model.AppState) http.Handler { return } if totpMethod == nil { + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(r)) controller.SetSessionError(app.DB, session, "Invalid TOTP.") render() return } - fmt.Printf( - "[%s] INFO: Account \"%s\" logged in with method \"%s\"\n", - time.Now().Format(time.UnixDate), - session.AttemptAccount.Username, - totpMethod.Name, - ) + app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(r)) err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount) if err != nil { diff --git a/admin/logshttp.go b/admin/logshttp.go new file mode 100644 index 0000000..93dc5b7 --- /dev/null +++ b/admin/logshttp.go @@ -0,0 +1,67 @@ +package admin + +import ( + "arimelody-web/log" + "arimelody-web/model" + "fmt" + "net/http" + "os" + "strings" +) + +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) + return + } + + session := r.Context().Value("session").(*model.Session) + + 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]log.LogLevel{ + "info": log.LEVEL_INFO, + "warn": log.LEVEL_WARN, + } + level, ok := m[strings.TrimPrefix(key, "level-")] + if ok { + levelFilter = append(levelFilter, level) + } + continue + } + + if strings.HasPrefix(key, "type-") && value[0] == "on" { + typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-"))) + continue + } + } + + logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + type LogsResponse struct { + Session *model.Session + Logs []*log.Log + } + + err = logsTemplate.Execute(w, LogsResponse{ + Session: session, + Logs: logs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} diff --git a/admin/static/logs.css b/admin/static/logs.css new file mode 100644 index 0000000..6ed91b5 --- /dev/null +++ b/admin/static/logs.css @@ -0,0 +1,85 @@ +main { + width: min(1080px, calc(100% - 2em))!important +} + +form { + margin: 1em 0; +} + +div#search { + display: flex; +} + +#search input { + margin: 0; + flex-grow: 1; + + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +#search button { + padding: 0 .5em; + + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +form #filters p { + margin: .5em 0 0 0; +} +form #filters label { + display: inline; +} +form #filters input { + margin-right: 1em; + display: inline; +} + +#logs { + width: 100%; + border-collapse: collapse; +} + +#logs tr { +} + +#logs tr td { + border-bottom: 1px solid #8888; +} + +#logs tr td:nth-child(even) { + background: #00000004; +} + +#logs th, #logs td { + padding: .4em .8em; +} + +td, th { + width: 1%; + text-align: left; + white-space: nowrap; +} +td.log-level, +th.log-level, +td.log-type, +th.log-type { + text-align: center; +} +td.log-content, +td.log-content { + width: 100%; +} + +.log:hover { + background: #fff8; +} + +.log.warn { + background: #ffe86a; +} +.log.warn:hover { + background: #ffec81; +} diff --git a/admin/templates.go b/admin/templates.go index 49c118b..12cdf08 100644 --- a/admin/templates.go +++ b/admin/templates.go @@ -1,8 +1,12 @@ package admin import ( - "html/template" - "path/filepath" + "arimelody-web/log" + "fmt" + "html/template" + "path/filepath" + "strings" + "time" ) var indexTemplate = template.Must(template.ParseFiles( @@ -48,6 +52,37 @@ var totpConfirmTemplate = template.Must(template.ParseFiles( filepath.Join("admin", "views", "totp-confirm.html"), )) +var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{ + "parseLevel": func(level log.LogLevel) string { + switch level { + case log.LEVEL_INFO: + return "INFO" + case log.LEVEL_WARN: + return "WARN" + } + return fmt.Sprintf("%d?", level) + }, + "titleCase": func(logType string) string { + runes := []rune(logType) + for i, r := range runes { + if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' { + runes[i] = r + ('A' - 'a') + } + } + return string(runes) + }, + "lower": func(str string) string { return strings.ToLower(str) }, + "prettyTime": func(t time.Time) string { + // return t.Format("2006-01-02 15:04:05") + // return t.Format("15:04:05, 2 Jan 2006") + return t.Format("02 Jan 2006, 15:04:05") + }, +}).ParseFiles( + filepath.Join("admin", "views", "layout.html"), + filepath.Join("views", "prideflag.html"), + filepath.Join("admin", "views", "logs.html"), +)) + var releaseTemplate = template.Must(template.ParseFiles( filepath.Join("admin", "views", "layout.html"), filepath.Join("views", "prideflag.html"), diff --git a/admin/views/layout.html b/admin/views/layout.html index 8c34c8e..52b0620 100644 --- a/admin/views/layout.html +++ b/admin/views/layout.html @@ -23,7 +23,14 @@ + {{if .Session.Account}} + + {{end}} +
+ {{if .Session.Account}}