package persistence import ( "bufio" "context" "fmt" "math" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors" "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" ) const ( defaultLogFileName = "main.log" logScannerInitialBuffer = 64 * 1024 logScannerMaxBuffer = 8 * 1024 * 1024 ) // LogRepository implements the ports.LogRepository interface type LogRepository struct { cfg *config.Config logDir string mu sync.RWMutex } // NewLogRepository creates a new LogRepository func NewLogRepository(cfg *config.Config) *LogRepository { return &LogRepository{ cfg: cfg, logDir: logging.ResolveLogDirectory(cfg), } } // ListLogFiles retrieves all log files func (r *LogRepository) ListLogFiles(ctx context.Context) ([]*ports.LogFileInfo, error) { r.mu.RLock() defer r.mu.RUnlock() if !r.IsLoggingEnabled() { return []*ports.LogFileInfo{}, nil } logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } entries, err := os.ReadDir(logDir) if err != nil { if os.IsNotExist(err) { return []*ports.LogFileInfo{}, nil } return nil, errors.Wrap(errors.InternalError, "failed to list log directory", err) } files := make([]*ports.LogFileInfo, 0) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") { continue } info, err := entry.Info() if err != nil { continue } files = append(files, &ports.LogFileInfo{ Name: name, Size: info.Size(), Modified: info.ModTime(), }) } // Sort by modified time descending sort.Slice(files, func(i, j int) bool { return files[i].Modified.After(files[j].Modified) }) return files, nil } // ReadLogFile reads a log file with optional filtering func (r *LogRepository) ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*ports.LogContent, error) { r.mu.RLock() defer r.mu.RUnlock() if !r.IsLoggingEnabled() { return nil, errors.ErrLoggingDisabled } logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } // Collect all log files files, err := r.collectLogFiles(logDir) if err != nil { if os.IsNotExist(err) { return &ports.LogContent{ Lines: []string{}, LineCount: 0, TotalLines: 0, LatestTimestamp: after, }, nil } return nil, errors.Wrap(errors.InternalError, "failed to collect log files", err) } // Accumulate lines acc := newLogAccumulator(after, limit) for _, file := range files { if err := acc.consumeFile(file); err != nil { return nil, errors.Wrap(errors.InternalError, fmt.Sprintf("failed to read log file %s", file), err) } } lines, total, latest := acc.result() if latest == 0 || latest < after { latest = after } return &ports.LogContent{ Lines: lines, LineCount: len(lines), TotalLines: total, LatestTimestamp: latest, }, nil } // DeleteLogFiles removes all log files and truncates the active log func (r *LogRepository) DeleteLogFiles(ctx context.Context) (*ports.DeleteLogResult, error) { r.mu.Lock() defer r.mu.Unlock() if !r.IsLoggingEnabled() { return nil, errors.ErrLoggingDisabled } logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } entries, err := os.ReadDir(logDir) if err != nil { if os.IsNotExist(err) { return &ports.DeleteLogResult{ Success: true, Message: "Log directory not found", Removed: 0, }, nil } return nil, errors.Wrap(errors.InternalError, "failed to list log directory", err) } removed := 0 for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() fullPath := filepath.Join(logDir, name) if name == defaultLogFileName { // Truncate active log if err := os.Truncate(fullPath, 0); err != nil && !os.IsNotExist(err) { return nil, errors.Wrap(errors.InternalError, "failed to truncate log file", err) } continue } if r.isRotatedLogFile(name) { if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { return nil, errors.Wrap(errors.InternalError, fmt.Sprintf("failed to remove %s", name), err) } removed++ } } return &ports.DeleteLogResult{ Success: true, Message: "Logs cleared successfully", Removed: removed, }, nil } // GetRequestErrorLogs retrieves error request log files func (r *LogRepository) GetRequestErrorLogs(ctx context.Context) ([]*ports.LogFileInfo, error) { r.mu.RLock() defer r.mu.RUnlock() // Return empty if request logging is enabled if r.cfg != nil && r.cfg.RequestLog { return []*ports.LogFileInfo{}, nil } logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } entries, err := os.ReadDir(logDir) if err != nil { if os.IsNotExist(err) { return []*ports.LogFileInfo{}, nil } return nil, errors.Wrap(errors.InternalError, "failed to list request error logs", err) } files := make([]*ports.LogFileInfo, 0) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") { continue } info, err := entry.Info() if err != nil { continue } files = append(files, &ports.LogFileInfo{ Name: name, Size: info.Size(), Modified: info.ModTime(), }) } // Sort by modified time descending sort.Slice(files, func(i, j int) bool { return files[i].Modified.After(files[j].Modified) }) return files, nil } // GetRequestLogByID retrieves a specific request log by ID func (r *LogRepository) GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error) { r.mu.RLock() defer r.mu.RUnlock() logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } // Validate request ID requestID = strings.TrimSpace(requestID) if requestID == "" { return nil, errors.New(errors.InvalidInput, "request ID is required") } if strings.ContainsAny(requestID, "/\\") { return nil, errors.New(errors.InvalidInput, "invalid request ID") } entries, err := os.ReadDir(logDir) if err != nil { if os.IsNotExist(err) { return nil, errors.NewNotFoundError("log directory", logDir) } return nil, errors.Wrap(errors.InternalError, "failed to list log directory", err) } suffix := "-" + requestID + ".log" var matchedFile string for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if strings.HasSuffix(name, suffix) { matchedFile = name break } } if matchedFile == "" { return nil, errors.NewNotFoundError("log file for request ID", requestID) } // Security check - ensure file is within log directory dirAbs, err := filepath.Abs(logDir) if err != nil { return nil, errors.Wrap(errors.InternalError, "failed to resolve log directory", err) } fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile)) prefix := dirAbs + string(os.PathSeparator) if !strings.HasPrefix(fullPath, prefix) { return nil, errors.New(errors.InvalidInput, "invalid log file path") } // Read file data, err := os.ReadFile(fullPath) if err != nil { if os.IsNotExist(err) { return nil, errors.NewNotFoundError("log file", matchedFile) } return nil, errors.Wrap(errors.InternalError, "failed to read log file", err) } return data, nil } // DownloadRequestErrorLog downloads a specific error log file func (r *LogRepository) DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error) { r.mu.RLock() defer r.mu.RUnlock() logDir := r.GetLogDirectory() if logDir == "" { return nil, errors.ErrLogDirectoryNotConfigured } // Validate filename filename = strings.TrimSpace(filename) if filename == "" || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { return nil, errors.New(errors.InvalidInput, "invalid log file name") } if !strings.HasPrefix(filename, "error-") || !strings.HasSuffix(filename, ".log") { return nil, errors.NewNotFoundError("log file", filename) } // Security check dirAbs, err := filepath.Abs(logDir) if err != nil { return nil, errors.Wrap(errors.InternalError, "failed to resolve log directory", err) } fullPath := filepath.Clean(filepath.Join(dirAbs, filename)) prefix := dirAbs + string(os.PathSeparator) if !strings.HasPrefix(fullPath, prefix) { return nil, errors.New(errors.InvalidInput, "invalid log file path") } // Check if file exists and is not a directory info, err := os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { return nil, errors.NewNotFoundError("log file", filename) } return nil, errors.Wrap(errors.InternalError, "failed to stat log file", err) } if info.IsDir() { return nil, errors.New(errors.InvalidInput, "invalid log file") } // Read file data, err := os.ReadFile(fullPath) if err != nil { return nil, errors.Wrap(errors.InternalError, "failed to read log file", err) } return data, nil } // GetLogDirectory returns the log directory path func (r *LogRepository) GetLogDirectory() string { r.mu.RLock() defer r.mu.RUnlock() if r.logDir != "" { return r.logDir } if r.cfg != nil { return logging.ResolveLogDirectory(r.cfg) } return "" } // IsLoggingEnabled returns whether logging to file is enabled func (r *LogRepository) IsLoggingEnabled() bool { r.mu.RLock() defer r.mu.RUnlock() if r.cfg == nil { return false } return r.cfg.LoggingToFile } // SetConfig updates the configuration func (r *LogRepository) SetConfig(cfg *config.Config) { r.mu.Lock() defer r.mu.Unlock() r.cfg = cfg r.logDir = logging.ResolveLogDirectory(cfg) } // collectLogFiles collects all log files in order func (r *LogRepository) collectLogFiles(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } type candidate struct { path string order int64 } cands := make([]candidate, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if name == defaultLogFileName { cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0}) continue } if order, ok := r.rotationOrder(name); ok { cands = append(cands, candidate{path: filepath.Join(dir, name), order: order}) } } if len(cands) == 0 { return []string{}, nil } // Sort by order sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order }) // Reverse to get newest first paths := make([]string, 0, len(cands)) for i := len(cands) - 1; i >= 0; i-- { paths = append(paths, cands[i].path) } return paths, nil } // isRotatedLogFile checks if a file is a rotated log file func (r *LogRepository) isRotatedLogFile(name string) bool { _, ok := r.rotationOrder(name) return ok } // rotationOrder determines the rotation order of a log file func (r *LogRepository) rotationOrder(name string) (int64, bool) { if order, ok := r.numericRotationOrder(name); ok { return order, true } if order, ok := r.timestampRotationOrder(name); ok { return order, true } return 0, false } // numericRotationOrder extracts numeric rotation order func (r *LogRepository) numericRotationOrder(name string) (int64, bool) { if !strings.HasPrefix(name, defaultLogFileName+".") { return 0, false } suffix := strings.TrimPrefix(name, defaultLogFileName+".") if suffix == "" { return 0, false } n, err := strconv.Atoi(suffix) if err != nil { return 0, false } return int64(n), true } // timestampRotationOrder extracts timestamp rotation order func (r *LogRepository) timestampRotationOrder(name string) (int64, bool) { ext := filepath.Ext(defaultLogFileName) base := strings.TrimSuffix(defaultLogFileName, ext) if base == "" { return 0, false } prefix := base + "-" if !strings.HasPrefix(name, prefix) { return 0, false } clean := strings.TrimPrefix(name, prefix) if strings.HasSuffix(clean, ".gz") { clean = strings.TrimSuffix(clean, ".gz") } if ext != "" { if !strings.HasSuffix(clean, ext) { return 0, false } clean = strings.TrimSuffix(clean, ext) } if clean == "" { return 0, false } // Remove any suffix after timestamp if idx := strings.IndexByte(clean, '.'); idx != -1 { clean = clean[:idx] } parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local) if err != nil { return 0, false } return math.MaxInt64 - parsed.Unix(), true } // logAccumulator accumulates log lines type logAccumulator struct { cutoff int64 limit int lines []string total int latest int64 include bool } // newLogAccumulator creates a new log accumulator func newLogAccumulator(cutoff int64, limit int) *logAccumulator { capacity := 256 if limit > 0 && limit < capacity { capacity = limit } return &logAccumulator{ cutoff: cutoff, limit: limit, lines: make([]string, 0, capacity), } } // consumeFile reads and accumulates lines from a file func (acc *logAccumulator) consumeFile(path string) error { file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil } return err } defer file.Close() scanner := bufio.NewScanner(file) buf := make([]byte, 0, logScannerInitialBuffer) scanner.Buffer(buf, logScannerMaxBuffer) for scanner.Scan() { acc.addLine(scanner.Text()) } return scanner.Err() } // addLine adds a line to the accumulator func (acc *logAccumulator) addLine(raw string) { line := strings.TrimRight(raw, "\r") acc.total++ ts := parseTimestamp(line) if ts > acc.latest { acc.latest = ts } if ts > 0 { acc.include = acc.cutoff == 0 || ts > acc.cutoff if acc.cutoff == 0 || acc.include { acc.append(line) } return } if acc.cutoff == 0 || acc.include { acc.append(line) } } // append adds a line to the lines slice func (acc *logAccumulator) append(line string) { acc.lines = append(acc.lines, line) if acc.limit > 0 && len(acc.lines) > acc.limit { acc.lines = acc.lines[len(acc.lines)-acc.limit:] } } // result returns the accumulated result func (acc *logAccumulator) result() ([]string, int, int64) { if acc.lines == nil { acc.lines = []string{} } return acc.lines, acc.total, acc.latest } // parseTimestamp parses a timestamp from a log line func parseTimestamp(line string) int64 { if strings.HasPrefix(line, "[") { line = line[1:] } if len(line) < 19 { return 0 } candidate := line[:19] t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local) if err != nil { return 0 } return t.Unix() } // Ensure LogRepository implements the interface var _ ports.LogRepository = (*LogRepository)(nil)