| 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 |
| ) |
|
|
| |
| type LogRepository struct { |
| cfg *config.Config |
| logDir string |
| mu sync.RWMutex |
| } |
|
|
| |
| func NewLogRepository(cfg *config.Config) *LogRepository { |
| return &LogRepository{ |
| cfg: cfg, |
| logDir: logging.ResolveLogDirectory(cfg), |
| } |
| } |
|
|
| |
| 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.Slice(files, func(i, j int) bool { |
| return files[i].Modified.After(files[j].Modified) |
| }) |
|
|
| return files, nil |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 { |
| |
| 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 |
| } |
|
|
| |
| func (r *LogRepository) GetRequestErrorLogs(ctx context.Context) ([]*ports.LogFileInfo, error) { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| |
| 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.Slice(files, func(i, j int) bool { |
| return files[i].Modified.After(files[j].Modified) |
| }) |
|
|
| return files, nil |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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") |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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") |
| } |
|
|
| |
| 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") |
| } |
|
|
| |
| data, err := os.ReadFile(fullPath) |
| if err != nil { |
| return nil, errors.Wrap(errors.InternalError, "failed to read log file", err) |
| } |
|
|
| return data, nil |
| } |
|
|
| |
| 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 "" |
| } |
|
|
| |
| func (r *LogRepository) IsLoggingEnabled() bool { |
| r.mu.RLock() |
| defer r.mu.RUnlock() |
|
|
| if r.cfg == nil { |
| return false |
| } |
| return r.cfg.LoggingToFile |
| } |
|
|
| |
| func (r *LogRepository) SetConfig(cfg *config.Config) { |
| r.mu.Lock() |
| defer r.mu.Unlock() |
| r.cfg = cfg |
| r.logDir = logging.ResolveLogDirectory(cfg) |
| } |
|
|
| |
| 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.Slice(cands, func(i, j int) bool { |
| return cands[i].order < cands[j].order |
| }) |
|
|
| |
| paths := make([]string, 0, len(cands)) |
| for i := len(cands) - 1; i >= 0; i-- { |
| paths = append(paths, cands[i].path) |
| } |
|
|
| return paths, nil |
| } |
|
|
| |
| func (r *LogRepository) isRotatedLogFile(name string) bool { |
| _, ok := r.rotationOrder(name) |
| return ok |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| type logAccumulator struct { |
| cutoff int64 |
| limit int |
| lines []string |
| total int |
| latest int64 |
| include bool |
| } |
|
|
| |
| 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), |
| } |
| } |
|
|
| |
| 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() |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| 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:] |
| } |
| } |
|
|
| |
| func (acc *logAccumulator) result() ([]string, int, int64) { |
| if acc.lines == nil { |
| acc.lines = []string{} |
| } |
| return acc.lines, acc.total, acc.latest |
| } |
|
|
| |
| 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() |
| } |
|
|
| |
| var _ ports.LogRepository = (*LogRepository)(nil) |
|
|