package main import ( "fmt" "io" "os" "path/filepath" "sync" "scratchbox/internal/config" ) func newAccessLogWriter(cfg config.Config) (io.Writer, func() error, error) { if !cfg.Server.AccessLog.Enabled { return io.Discard, func() error { return nil }, nil } if cfg.Server.AccessLog.FilePath == "" { return os.Stdout, func() error { return nil }, nil } writer, err := newRollingFileWriter( cfg.Server.AccessLog.FilePath, cfg.Server.AccessLog.MaxSizeBytes, cfg.Server.AccessLog.MaxBackups, ) if err != nil { return nil, nil, err } return io.MultiWriter(os.Stdout, writer), writer.Close, nil } type rollingFileWriter struct { mu sync.Mutex path string maxSize int64 maxBackups int file *os.File size int64 } func newRollingFileWriter(path string, maxSize int64, maxBackups int) (*rollingFileWriter, error) { if maxSize <= 0 { return nil, fmt.Errorf("max size must be > 0") } if maxBackups <= 0 { return nil, fmt.Errorf("max backups must be > 0") } if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create log directory: %w", err) } } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, fmt.Errorf("open log file: %w", err) } info, err := f.Stat() if err != nil { _ = f.Close() return nil, fmt.Errorf("stat log file: %w", err) } return &rollingFileWriter{ path: path, maxSize: maxSize, maxBackups: maxBackups, file: f, size: info.Size(), }, nil } func (w *rollingFileWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() if w.size > 0 && w.size+int64(len(p)) > w.maxSize { if err := w.rotateLocked(); err != nil { return 0, err } } n, err := w.file.Write(p) w.size += int64(n) return n, err } func (w *rollingFileWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.file == nil { return nil } err := w.file.Close() w.file = nil return err } func (w *rollingFileWriter) rotateLocked() error { if err := w.file.Close(); err != nil { return fmt.Errorf("close before rotate: %w", err) } for i := w.maxBackups - 1; i >= 1; i-- { from := rotatedLogPath(w.path, i) to := rotatedLogPath(w.path, i+1) if _, err := os.Stat(from); err != nil { if os.IsNotExist(err) { continue } return fmt.Errorf("stat rotated file %s: %w", from, err) } if err := os.Remove(to); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove target backup slot %s: %w", to, err) } if err := os.Rename(from, to); err != nil { return fmt.Errorf("rotate backup %s -> %s: %w", from, to, err) } } firstBackup := rotatedLogPath(w.path, 1) if err := os.Remove(firstBackup); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove first backup %s: %w", firstBackup, err) } if _, err := os.Stat(w.path); err == nil { if err := os.Rename(w.path, firstBackup); err != nil { return fmt.Errorf("rotate current log to %s: %w", firstBackup, err) } } else if !os.IsNotExist(err) { return fmt.Errorf("stat current log file: %w", err) } f, err := os.OpenFile(w.path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open rotated log file: %w", err) } w.file = f w.size = 0 return nil } func rotatedLogPath(path string, index int) string { return fmt.Sprintf("%s.%d", path, index) }