diff --git a/database/audit.go b/database/audit.go index aa2d4f4..22a34e7 100644 --- a/database/audit.go +++ b/database/audit.go @@ -11,6 +11,7 @@ package database import ( "context" + "database/sql" _ "embed" "fmt" "time" @@ -126,6 +127,45 @@ const ( // auditWriteQueue is a channel to store audit records in the background. var auditWriteQueue chan *AuditRecord +// auditWriter is the routine that stores audit records in trhe background. +func auditWriter(workChan chan *AuditRecord, doneChan chan bool) { + for ar := range workChan { + err := ar.Store(context.Background()) + if err != nil { + log.Errorf("dropped audit record (%+v) on the floor: %v", *ar, err) + } + } + doneChan <- true +} + +// setupAuditWriter sets up the background audit writer. +func setupAuditWriter() func() { + auditWriteQueue = make(chan *AuditRecord, config.GlobalConfig.Tuning.Queues.AuditWrites) + doneChan := make(chan bool) + go auditWriter(auditWriteQueue, doneChan) + return func() { + close(auditWriteQueue) + <-doneChan + } +} + +// Store stores the audit record in the database. +func (ar *AuditRecord) Store(ctx context.Context) error { + if ar.Record > 0 { + return fmt.Errorf("audit record %d already stored", ar.Record) + } + moment := time.Now().UTC() + rs, err := amdb.ExecContext(ctx, `INSERT INTO audit (on_date, event, uid, commid, ip, data1, data2, data3, data4) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, moment, ar.Event, ar.Uid, ar.CommId, ar.IP, + ar.Data1, ar.Data2, ar.Data3, ar.Data4) + if err != nil { + return err + } + ar.Record, _ = rs.LastInsertId() + ar.OnDate = moment + return nil +} + /* AmNewAudit creates a new audit record. * Parameters: * rectype - Audit record type. @@ -191,34 +231,6 @@ func AmNewCommAudit(rectype int32, uid int32, commid int32, ip string, data ...s return &rc } -// Store stores the audit record in the database. -func (ar *AuditRecord) Store(ctx context.Context) error { - if ar.Record > 0 { - return fmt.Errorf("audit record %d already stored", ar.Record) - } - moment := time.Now().UTC() - rs, err := amdb.ExecContext(ctx, `INSERT INTO audit (on_date, event, uid, commid, ip, data1, data2, data3, data4) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, moment, ar.Event, ar.Uid, ar.CommId, ar.IP, - ar.Data1, ar.Data2, ar.Data3, ar.Data4) - if err != nil { - return err - } - ar.Record, _ = rs.LastInsertId() - ar.OnDate = moment - return nil -} - -// auditWriter is the routine that stores audit records in trhe background. -func auditWriter(workChan chan *AuditRecord, doneChan chan bool) { - for ar := range workChan { - err := ar.Store(context.Background()) - if err != nil { - log.Errorf("dropped audit record (%+v) on the floor: %v", *ar, err) - } - } - doneChan <- true -} - // AmStoreAudit stores the audit record in the background. func AmStoreAudit(rec *AuditRecord) { if rec != nil { @@ -226,13 +238,35 @@ func AmStoreAudit(rec *AuditRecord) { } } -// setupAuditWriter sets up the background audit writer. -func setupAuditWriter() func() { - auditWriteQueue = make(chan *AuditRecord, config.GlobalConfig.Tuning.Queues.AuditWrites) - doneChan := make(chan bool) - go auditWriter(auditWriteQueue, doneChan) - return func() { - close(auditWriteQueue) - <-doneChan +// AmListAuditRecords lists a section of the audit records. +func AmListAuditRecords(ctx context.Context, comm *Community, offset, max int) ([]AuditRecord, int, error) { + var row *sql.Row + if comm != nil { + row = amdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM audit WHERE commid = ?", comm.Id) + } else { + row = amdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM audit") } + var count int + err := row.Scan(&count) + if err != nil { + return nil, -1, err + } + var rc []AuditRecord + if comm != nil { + if offset > 0 { + err = amdb.SelectContext(ctx, &rc, "SELECT * FROM audit WHERE commid = ? ORDER BY on_date DESC LIMIT ? OFFSET ?", comm.Id, max, offset) + } else { + err = amdb.SelectContext(ctx, &rc, "SELECT * FROM audit WHERE commid = ? ORDER BY on_date DESC LIMIT ?", comm.Id, max) + } + } else { + if offset > 0 { + err = amdb.SelectContext(ctx, &rc, "SELECT * FROM audit ORDER BY on_date DESC LIMIT ? OFFSET ?", max, offset) + } else { + err = amdb.SelectContext(ctx, &rc, "SELECT * FROM audit ORDER BY on_date DESC LIMIT ?", max) + } + } + if err != nil { + return nil, count, err + } + return rc, count, nil } diff --git a/database/user.go b/database/user.go index 1b16055..a566246 100644 --- a/database/user.go +++ b/database/user.go @@ -561,17 +561,6 @@ func AmGetAnonUser(ctx context.Context) (*User, error) { return rc, err } -// AmGetBOFH returns the user account of the global system administrator. -func AmGetBOFH(ctx context.Context) (*User, error) { - row := amdb.QueryRowContext(ctx, "SELECT uid FROM users WHERE base_lvl = ?", AmRole("Global.BOFH").Level()) - var uid int32 - err := row.Scan(&uid) - if err != nil { - return nil, err - } - return AmGetUser(ctx, uid) -} - // hashPassword hashes the password value. func hashPassword(password string) string { if len(password) == 0 { diff --git a/docs/MISSINGFUNCS.md b/docs/MISSINGFUNCS.md index 8edcd45..af98a18 100644 --- a/docs/MISSINGFUNCS.md +++ b/docs/MISSINGFUNCS.md @@ -16,7 +16,7 @@ _(italicized items can be deferred)_ - ~~Edit Global Properties~~ - ~~View/Edit IP Address Bans~~ - ~~User Account Management~~ - - System Audit Logs + - ~~System Audit Logs~~ - Import User Accounts - ~~Conferences list:~~ - ~~Find~~ diff --git a/main.go b/main.go index 29a4421..80091c8 100644 --- a/main.go +++ b/main.go @@ -86,6 +86,7 @@ func setupEcho() *echo.Echo { e.POST("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhoto)) e.GET("/sysadmin/ipban", ui.AmWrap(IPBanList)) e.GET("/sysadmin/ipban/add", ui.AmWrap(AddIPBanForm)) + e.Match(GetAndPost, "/sysadmin/audit", ui.AmWrap(SystemAudit)) e.POST("/sysadmin/ipban/add", ui.AmWrap(AddIPBan)) e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) e.POST("/create_comm", ui.AmWrap(CreateCommunity)) @@ -195,10 +196,6 @@ func main() { if err != nil { panic(err) } - bofh, err := database.AmGetBOFH(context.Background()) - if err != nil { - panic(err) - } // Set up to trap SIGINT/SIGTERM and shut down gracefully ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -215,7 +212,7 @@ func main() { e := setupEcho() // Audit the startup - database.AmStoreAudit(database.AmNewAudit(database.AuditStartup, bofh.Uid, myIP.String(), + database.AmStoreAudit(database.AmNewAudit(database.AuditStartup, 0, myIP.String(), fmt.Sprintf("version=%s", config.AMSTERDAM_VERSION))) // Start server @@ -234,5 +231,5 @@ func main() { } // Audit the shutdown - database.AmStoreAudit(database.AmNewAudit(database.AuditShutdown, bofh.Uid, myIP.String())) + database.AmStoreAudit(database.AmNewAudit(database.AuditShutdown, 0, myIP.String())) } diff --git a/sysadmin.go b/sysadmin.go index 7e9826b..85cf50f 100644 --- a/sysadmin.go +++ b/sysadmin.go @@ -683,3 +683,66 @@ func AddIPBan(ctxt ui.AmContext) (string, any) { } return dlg.RenderError(ctxt, err.Error()) } + +func SystemAudit(ctxt ui.AmContext) (string, any) { + if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { + return "error", ENOACCESS + } + + ofs := 0 + maxRecs := ctxt.Globals().NumAuditPage + if ctxt.Verb() == "POST" { + if ctxt.FormFieldIsSet("prev") { + ofs = min(0, ofs-int(maxRecs)) + } else if ctxt.FormFieldIsSet("next") { + ofs += int(maxRecs) + } + } + + auditRecs, total, err := database.AmListAuditRecords(ctxt.Ctx(), nil, ofs, int(maxRecs)) + if err != nil { + return "error", err + } + + descr := make([]string, len(auditRecs)) + userName := make([]string, len(auditRecs)) + communityName := make([]string, len(auditRecs)) + for i, ar := range auditRecs { + descr[i] = database.AmAuditText(int(ar.Event)) + if ar.Uid > 0 { + user, err := database.AmGetUser(ctxt.Ctx(), ar.Uid) + if err != nil { + userName[i] = fmt.Sprintf("<<%v>>", err) + } else { + userName[i] = user.Username + } + } else { + userName[i] = "" + } + if ar.CommId > 0 { + comm, err := database.AmGetCommunity(ctxt.Ctx(), ar.CommId) + if err != nil { + communityName[i] = fmt.Sprintf("<<%v>>", err) + } else { + communityName[i] = comm.Name + } + } else { + communityName[i] = "" + } + } + + ctxt.VarMap().Set("total", total) + ctxt.VarMap().Set("ofs", ofs) + ctxt.VarMap().Set("auditRecords", auditRecs) + ctxt.VarMap().Set("descr", descr) + ctxt.VarMap().Set("user", userName) + ctxt.VarMap().Set("community", communityName) + if ofs > 0 { + ctxt.VarMap().Set("showPrev", true) + } + if ofs+int(maxRecs) < total { + ctxt.VarMap().Set("showNext", true) + } + ctxt.SetFrameTitle("System Audit Records") + return "framed", "audit.jet" +} diff --git a/ui/menudefs.yaml b/ui/menudefs.yaml index 16f52e3..08c9134 100644 --- a/ui/menudefs.yaml +++ b/ui/menudefs.yaml @@ -47,7 +47,7 @@ menudefs: link: "/sysadmin/users" permission: "Global.SysAdminAccess" - text: "System Audit Logs" - link: "/TODO/sysadmin/audit" + link: "/sysadmin/audit" permission: "Global.SysAdminAccess" - text: "Import User Accounts" link: "/TODO/sysadmin/import" diff --git a/ui/views/audit.jet b/ui/views/audit.jet new file mode 100644 index 0000000..0d4d96e --- /dev/null +++ b/ui/views/audit.jet @@ -0,0 +1,82 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + *} +
+ +
+

System Audit Records

+
+
+ + +
+ + + Return to System Administration Menu + +
+ + +
+
+
Displaying records {{ ofs + 1 }} to {{ ofs + len(auditRecords) }} of {{ total }}
+
+ +
+
+ + + + + + + + + + + + + {{ range i, a := auditRecords }} + + + + + + + + + + + + {{ end }} + +
Date/TimeDescriptionUserCommunityIP AddressAdditional Data
{{ DisplayDateTime( a.OnDate, .) }}{{ descr[i] }}{{ user[i] }}{{ community[i] }}{{ iif(isset(a.IP), a.IP, "") }}{{ iif(isset(a.Data1), a.Data1, "") }}{{ iif(isset(a.Data2), a.Data2, "") }}{{ iif(isset(a.Data3), a.Data3, "") }}{{ iif(isset(a.Data4), a.Data4, "") }}
+
+
+ + {{ if isset(showPrev) || isset(showNext) }} + +
+
+ + {{ if isset(showPrev) }} + + {{ end }} + {{ if isset(showNext) }} + + {{ end }} +
+
+ {{ end }} +