From e80a6753a54631f7380452c6189c8698d76b3913 Mon Sep 17 00:00:00 2001 From: ari melody Date: Thu, 6 Feb 2025 12:32:51 +0000 Subject: [PATCH 1/4] create log class, edit fatal-but-not-really logs --- admin/releasehttp.go | 8 +++--- api/api.go | 6 ++--- log/log.go | 58 ++++++++++++++++++++++++++++++++++++++++++++ view/music.go | 2 +- 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 log/log.go diff --git a/admin/releasehttp.go b/admin/releasehttp.go index be5052b..7ef4d37 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -22,7 +22,7 @@ func serveRelease(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err) + fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -86,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -113,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler { artistID := strings.Split(r.URL.Path, "/")[3] artist, err := controller.GetArtist(app.DB, artistID) if err != nil { - fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) + fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -166,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) if err != nil { - fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) + fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/api/api.go b/api/api.go index 50b1c63..9a2c340 100644 --- a/api/api.go +++ b/api/api.go @@ -27,7 +27,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err) + fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -69,7 +69,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err) + fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -111,7 +111,7 @@ func Handler(app *model.AppState) http.Handler { http.NotFound(w, r) return } - fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err) + fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..734cc9f --- /dev/null +++ b/log/log.go @@ -0,0 +1,58 @@ +package log + +import ( + "fmt" + "os" + "time" + + "github.com/jmoiron/sqlx" +) + +type ( + Logger struct { + DB *sqlx.DB + } + + Log struct { + ID string `json:"id" db:"id"` + Type string `json:"type" db:"type"` + Content string `json:"content" db:"content"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + } +) + +const ( + TYPE_ACCOUNT = "account" +) + +func (self *Logger) Info(logType string, format string, args ...any) { + fmt.Printf(fmt.Sprintf("[%s] INFO: %s", logType, format), args...) + // TODO: push logs to DB +} + +func (self *Logger) Warn(logType string, format string, args ...any) { + fmt.Fprintf(os.Stderr, fmt.Sprintf("[%s] WARN: %s", logType, format), args...) + // TODO: push logs to DB +} + +func (self *Logger) Fatal(logType string, format string, args ...any) { + fmt.Fprintf(os.Stderr, fmt.Sprintf("[%s] FATAL: %s", logType, format), args...) + // we won't need to push fatal logs to DB, as these usually precede a panic or crash +} + +func (self *Logger) Fetch(id string) *Log { + // TODO: log fetch + return nil +} + +func (self *Logger) Search(typeFilters []string, content string, offset int, limit int) []Log { + // TODO: log search + return []Log{} +} + +func (self *Logger) Delete(id string) error { + // TODO: log deletion + // consider: logging the deletion of logs? + // or just not deleting logs at all + return nil +} diff --git a/view/music.go b/view/music.go index 89e428c..2d40ef0 100644 --- a/view/music.go +++ b/view/music.go @@ -37,7 +37,7 @@ 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 { - fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) + fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } From aa144b719afbb2c3c2b7d8e7725f73e44819d6d3 Mon Sep 17 00:00:00 2001 From: ari melody Date: Thu, 6 Feb 2025 13:45:33 +0000 Subject: [PATCH 2/4] audit log basic db implementation --- controller/migrator.go | 6 ++++- log/log.go | 38 +++++++++++++++++++++++++---- schema-migration/000-init.sql | 14 +++++++++-- schema-migration/002-audit-logs.sql | 12 +++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 schema-migration/002-audit-logs.sql diff --git a/controller/migrator.go b/controller/migrator.go index d0011ee..b053a27 100644 --- a/controller/migrator.go +++ b/controller/migrator.go @@ -8,7 +8,7 @@ import ( "github.com/jmoiron/sqlx" ) -const DB_VERSION int = 2 +const DB_VERSION int = 3 func CheckDBVersionAndMigrate(db *sqlx.DB) { db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") @@ -41,6 +41,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) { ApplyMigration(db, "001-pre-versioning") oldDBVersion = 2 + case 2: + ApplyMigration(db, "002-audit-logs") + oldDBVersion = 3 + } } diff --git a/log/log.go b/log/log.go index 734cc9f..b90a39b 100644 --- a/log/log.go +++ b/log/log.go @@ -22,17 +22,35 @@ type ( ) const ( - TYPE_ACCOUNT = "account" + TYPE_ACCOUNT string = "account" + TYPE_MUSIC string = "music" + TYPE_BLOG string = "blog" + TYPE_ARTWORK string = "artwork" + TYPE_MISC string = "misc" +) + +type LogLevel int +const ( + LEVEL_INFO LogLevel = 0 + LEVEL_WARN LogLevel = 1 ) func (self *Logger) Info(logType string, format string, args ...any) { - fmt.Printf(fmt.Sprintf("[%s] INFO: %s", logType, format), args...) - // TODO: push logs to DB + logString := fmt.Sprintf(format, args...) + fmt.Printf("[%s] INFO: %s", 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) { - fmt.Fprintf(os.Stderr, fmt.Sprintf("[%s] WARN: %s", logType, format), args...) - // TODO: push logs to DB + logString := fmt.Sprintf(format, args...) + fmt.Fprintf(os.Stderr, "[%s] WARN: %s", 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) Fatal(logType string, format string, args ...any) { @@ -56,3 +74,13 @@ func (self *Logger) Delete(id string) error { // or just not deleting logs at all return 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/schema-migration/000-init.sql b/schema-migration/000-init.sql index 42b982a..f70dee6 100644 --- a/schema-migration/000-init.sql +++ b/schema-migration/000-init.sql @@ -2,6 +2,15 @@ -- Tables -- +-- Audit logs +CREATE TABLE arimelody.auditlog ( + id UUID DEFAULT gen_random_uuid(), + level int NOT NULL DEFAULT 0, + type TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + -- Accounts CREATE TABLE arimelody.account ( id UUID DEFAULT gen_random_uuid(), @@ -9,7 +18,7 @@ CREATE TABLE arimelody.account ( password TEXT NOT NULL, email TEXT, avatar_url TEXT, - created_at TIMESTAMP DEFAULT current_timestamp + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp ); ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); @@ -74,7 +83,8 @@ CREATE TABLE arimelody.musicrelease ( buyname text, buylink text, copyright text, - copyrightURL text + copyrightURL text, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp, ); ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); diff --git a/schema-migration/002-audit-logs.sql b/schema-migration/002-audit-logs.sql new file mode 100644 index 0000000..7da43b5 --- /dev/null +++ b/schema-migration/002-audit-logs.sql @@ -0,0 +1,12 @@ +-- Audit logs +CREATE TABLE arimelody.auditlog ( + id UUID DEFAULT gen_random_uuid(), + level int NOT NULL DEFAULT 0, + type TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT current_timestamp +); + +-- Need moar timestamps +ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp; +ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL; From 1397274967f1ac279f695f85126e8f1e31a42e01 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 7 Feb 2025 12:41:25 +0000 Subject: [PATCH 3/4] log search implementation --- log/log.go | 67 ++++++++++++++++++++++++++++++++++++++++++++---------- main.go | 19 +++++++++++++--- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/log/log.go b/log/log.go index b90a39b..3344bbe 100644 --- a/log/log.go +++ b/log/log.go @@ -15,6 +15,7 @@ type ( 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"` @@ -35,6 +36,8 @@ const ( 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] INFO: %s", logType, logString) @@ -58,21 +61,61 @@ func (self *Logger) Fatal(logType string, format string, args ...any) { // we won't need to push fatal logs to DB, as these usually precede a panic or crash } -func (self *Logger) Fetch(id string) *Log { - // TODO: log fetch - return nil +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(typeFilters []string, content string, offset int, limit int) []Log { - // TODO: log search - return []Log{} -} +func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, offset int, limit int) ([]*Log, error) { + logs := []*Log{} -func (self *Logger) Delete(id string) error { - // TODO: log deletion - // consider: logging the deletion of logs? - // or just not deleting logs at all - return nil + params := []any{ limit, offset } + conditions := "" + + if len(content) > 0 { + content = "%" + content + "%" + conditions += " WHERE content LIKE $3" + params = append(params, content) + } + + if len(levelFilters) > 0 { + conditions += " AND 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 { + conditions += " AND 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, + ) + + // TODO: remove after testing + fmt.Println(query) + + 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 { diff --git a/main.go b/main.go index 7e8e06f..b18681b 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,7 @@ package main import ( "errors" "fmt" - "log" + stdLog "log" "math" "math/rand" "net/http" @@ -20,6 +20,7 @@ import ( "arimelody-web/model" "arimelody-web/templates" "arimelody-web/view" + "arimelody-web/log" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -341,7 +342,19 @@ func main() { fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) return - + + case "testLogSearch": + // TODO: rename to "logs"; add parameters + logger := log.Logger { DB: app.DB } + logs, err := logger.Search([]log.LogLevel{ log.LEVEL_INFO, log.LEVEL_WARN }, []string{ log.TYPE_ACCOUNT, log.TYPE_MUSIC }, "ari", 0, 100) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err) + os.Exit(1) + } + for _, log := range(logs) { + fmt.Printf("[%s] [%s] [%d] [%s] %s\n", log.CreatedAt.Format(time.UnixDate), log.ID, log.Level, log.Type, log.Content) + } + return } // command help @@ -401,7 +414,7 @@ func main() { // start the web server! mux := createServeMux(&app) fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port) - log.Fatal( + stdLog.Fatal( http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port), HTTPLog(DefaultHeaders(mux)), )) From d9b71381b02b3ccf421d50413be33b55f7a2102b Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 7 Feb 2025 16:40:58 +0000 Subject: [PATCH 4/4] logs in use; new audit log panel! --- admin/accounthttp.go | 20 +++++----- admin/http.go | 41 ++++++++------------ admin/logshttp.go | 67 ++++++++++++++++++++++++++++++++ admin/static/logs.css | 85 +++++++++++++++++++++++++++++++++++++++++ admin/templates.go | 39 ++++++++++++++++++- admin/views/layout.html | 7 ++++ admin/views/logs.html | 68 +++++++++++++++++++++++++++++++++ api/artist.go | 13 +++++++ api/release.go | 31 +++++++++++---- api/track.go | 20 +++++++--- api/uploads.go | 3 ++ controller/ip.go | 19 +++++++++ log/log.go | 37 ++++++++++++------ main.go | 35 ++++++++++++----- model/appstate.go | 7 +++- model/release.go | 1 + 16 files changed, 418 insertions(+), 75 deletions(-) create mode 100644 admin/logshttp.go create mode 100644 admin/static/logs.css create mode 100644 admin/views/logs.html create mode 100644 controller/ip.go 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}}