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 }