Files
ntfywui/internal/store/audit.go
2026-01-12 13:51:52 +01:00

101 lines
2.0 KiB
Go

package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
type AuditEvent struct {
Time string `json:"time"`
Actor string `json:"actor"`
IP string `json:"ip,omitempty"`
UA string `json:"ua,omitempty"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type AuditLog struct {
mu sync.Mutex
path string
fh *os.File
}
func NewAuditLog(path string) (*AuditLog, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
return &AuditLog{path: path, fh: f}, nil
}
func (a *AuditLog) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.fh != nil {
return a.fh.Close()
}
return nil
}
func (a *AuditLog) Append(ev AuditEvent) {
ev.Time = time.Now().UTC().Format(time.RFC3339Nano)
a.mu.Lock()
defer a.mu.Unlock()
if a.fh == nil {
return
}
b, _ := json.Marshal(ev)
_, _ = a.fh.Write(append(b, '\n'))
}
func (a *AuditLog) Tail(max int) ([]AuditEvent, error) {
// Simple tail by reading whole file (OK for small audit logs).
// You can replace with a smarter tail if needed.
a.mu.Lock()
defer a.mu.Unlock()
if a.fh == nil {
return nil, nil
}
_ = a.fh.Sync()
b, err := os.ReadFile(a.path)
if err != nil {
return nil, err
}
lines := splitLines(b)
if max > 0 && len(lines) > max {
lines = lines[len(lines)-max:]
}
out := make([]AuditEvent, 0, len(lines))
for _, ln := range lines {
var ev AuditEvent
if json.Unmarshal(ln, &ev) == nil {
out = append(out, ev)
}
}
return out, nil
}
func splitLines(b []byte) [][]byte {
var out [][]byte
start := 0
for i := 0; i < len(b); i++ {
if b[i] == '\n' {
if i > start {
out = append(out, b[start:i])
}
start = i + 1
}
}
if start < len(b) {
out = append(out, b[start:])
}
return out
}