API / internal /infrastructure /persistence /log_repository.go
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
Raw
History Blame Contribute Delete
14.9 kB
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)