| package management |
|
|
| import ( |
| "bufio" |
| "fmt" |
| "math" |
| "net/http" |
| "os" |
| "path/filepath" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "github.com/gin-gonic/gin" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" |
| ) |
|
|
| const ( |
| defaultLogFileName = "main.log" |
| logScannerInitialBuffer = 64 * 1024 |
| logScannerMaxBuffer = 8 * 1024 * 1024 |
| ) |
|
|
| |
| func (h *Handler) GetLogs(c *gin.Context) { |
| if h == nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) |
| return |
| } |
| if h.cfg == nil { |
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) |
| return |
| } |
| if !h.cfg.LoggingToFile { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"}) |
| return |
| } |
|
|
| logDir := h.logDirectory() |
| if strings.TrimSpace(logDir) == "" { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) |
| return |
| } |
|
|
| files, err := h.collectLogFiles(logDir) |
| if err != nil { |
| if os.IsNotExist(err) { |
| cutoff := parseCutoff(c.Query("after")) |
| c.JSON(http.StatusOK, gin.H{ |
| "lines": []string{}, |
| "line-count": 0, |
| "latest-timestamp": cutoff, |
| }) |
| return |
| } |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)}) |
| return |
| } |
|
|
| limit, errLimit := parseLimit(c.Query("limit")) |
| if errLimit != nil { |
| c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid limit: %v", errLimit)}) |
| return |
| } |
|
|
| cutoff := parseCutoff(c.Query("after")) |
| acc := newLogAccumulator(cutoff, limit) |
| for i := range files { |
| if errProcess := acc.consumeFile(files[i]); errProcess != nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)}) |
| return |
| } |
| } |
|
|
| lines, total, latest := acc.result() |
| if latest == 0 || latest < cutoff { |
| latest = cutoff |
| } |
| c.JSON(http.StatusOK, gin.H{ |
| "lines": lines, |
| "line-count": total, |
| "latest-timestamp": latest, |
| }) |
| } |
|
|
| |
| func (h *Handler) DeleteLogs(c *gin.Context) { |
| if h == nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) |
| return |
| } |
| if h.cfg == nil { |
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) |
| return |
| } |
| if !h.cfg.LoggingToFile { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"}) |
| return |
| } |
|
|
| dir := h.logDirectory() |
| if strings.TrimSpace(dir) == "" { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) |
| return |
| } |
|
|
| entries, err := os.ReadDir(dir) |
| if err != nil { |
| if os.IsNotExist(err) { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"}) |
| return |
| } |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)}) |
| return |
| } |
|
|
| removed := 0 |
| for _, entry := range entries { |
| if entry.IsDir() { |
| continue |
| } |
| name := entry.Name() |
| fullPath := filepath.Join(dir, name) |
| if name == defaultLogFileName { |
| if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)}) |
| return |
| } |
| continue |
| } |
| if isRotatedLogFile(name) { |
| if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)}) |
| return |
| } |
| removed++ |
| } |
| } |
|
|
| c.JSON(http.StatusOK, gin.H{ |
| "success": true, |
| "message": "Logs cleared successfully", |
| "removed": removed, |
| }) |
| } |
|
|
| |
| |
| func (h *Handler) GetRequestErrorLogs(c *gin.Context) { |
| if h == nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) |
| return |
| } |
| if h.cfg == nil { |
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) |
| return |
| } |
| if h.cfg.RequestLog { |
| c.JSON(http.StatusOK, gin.H{"files": []any{}}) |
| return |
| } |
|
|
| dir := h.logDirectory() |
| if strings.TrimSpace(dir) == "" { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) |
| return |
| } |
|
|
| type errorLog struct { |
| Name string `json:"name"` |
| Size int64 `json:"size"` |
| Modified int64 `json:"modified"` |
| } |
|
|
| genericListFiles(c, dir, func(entry os.DirEntry) (errorLog, bool) { |
| if entry.IsDir() { |
| return errorLog{}, false |
| } |
| name := entry.Name() |
| if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") { |
| return errorLog{}, false |
| } |
| info, errInfo := entry.Info() |
| if errInfo != nil { |
| return errorLog{}, false |
| } |
| return errorLog{ |
| Name: name, |
| Size: info.Size(), |
| Modified: info.ModTime().Unix(), |
| }, true |
| }, func(files []errorLog) { |
| sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified }) |
| }) |
| } |
|
|
| |
| |
| func (h *Handler) GetRequestLogByID(c *gin.Context) { |
| if h == nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) |
| return |
| } |
| if h.cfg == nil { |
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) |
| return |
| } |
|
|
| dir := h.logDirectory() |
| if strings.TrimSpace(dir) == "" { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) |
| return |
| } |
|
|
| requestID := strings.TrimSpace(c.Param("id")) |
| if requestID == "" { |
| requestID = strings.TrimSpace(c.Query("id")) |
| } |
| if requestID == "" { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "missing request ID"}) |
| return |
| } |
| if strings.ContainsAny(requestID, "/\\") { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request ID"}) |
| return |
| } |
|
|
| entries, err := os.ReadDir(dir) |
| if err != nil { |
| if os.IsNotExist(err) { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"}) |
| return |
| } |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)}) |
| return |
| } |
|
|
| 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 == "" { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log file not found for the given request ID"}) |
| return |
| } |
|
|
| dirAbs, errAbs := filepath.Abs(dir) |
| if errAbs != nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)}) |
| return |
| } |
| fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile)) |
| prefix := dirAbs + string(os.PathSeparator) |
| if !strings.HasPrefix(fullPath, prefix) { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"}) |
| return |
| } |
|
|
| info, errStat := os.Stat(fullPath) |
| if errStat != nil { |
| if os.IsNotExist(errStat) { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"}) |
| return |
| } |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)}) |
| return |
| } |
| if info.IsDir() { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"}) |
| return |
| } |
|
|
| c.FileAttachment(fullPath, matchedFile) |
| } |
|
|
| |
| func (h *Handler) DownloadRequestErrorLog(c *gin.Context) { |
| if h == nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) |
| return |
| } |
| if h.cfg == nil { |
| c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) |
| return |
| } |
|
|
| dir := h.logDirectory() |
| if strings.TrimSpace(dir) == "" { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) |
| return |
| } |
|
|
| name := strings.TrimSpace(c.Param("name")) |
| if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file name"}) |
| return |
| } |
| if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"}) |
| return |
| } |
|
|
| dirAbs, errAbs := filepath.Abs(dir) |
| if errAbs != nil { |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)}) |
| return |
| } |
| fullPath := filepath.Clean(filepath.Join(dirAbs, name)) |
| prefix := dirAbs + string(os.PathSeparator) |
| if !strings.HasPrefix(fullPath, prefix) { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"}) |
| return |
| } |
|
|
| info, errStat := os.Stat(fullPath) |
| if errStat != nil { |
| if os.IsNotExist(errStat) { |
| c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"}) |
| return |
| } |
| c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)}) |
| return |
| } |
| if info.IsDir() { |
| c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"}) |
| return |
| } |
|
|
| c.FileAttachment(fullPath, name) |
| } |
|
|
| func (h *Handler) logDirectory() string { |
| if h == nil { |
| return "" |
| } |
| if h.logDir != "" { |
| return h.logDir |
| } |
| return logging.ResolveLogDirectory(h.cfg) |
| } |
|
|
| func (h *Handler) 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 := 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 |
| } |
|
|
| 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 func() { |
| _ = file.Close() |
| }() |
|
|
| scanner := bufio.NewScanner(file) |
| buf := make([]byte, 0, logScannerInitialBuffer) |
| scanner.Buffer(buf, logScannerMaxBuffer) |
| for scanner.Scan() { |
| acc.addLine(scanner.Text()) |
| } |
| if errScan := scanner.Err(); errScan != nil { |
| return errScan |
| } |
| return nil |
| } |
|
|
| 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 parseCutoff(raw string) int64 { |
| value := strings.TrimSpace(raw) |
| if value == "" { |
| return 0 |
| } |
| ts, err := strconv.ParseInt(value, 10, 64) |
| if err != nil || ts <= 0 { |
| return 0 |
| } |
| return ts |
| } |
|
|
| func parseLimit(raw string) (int, error) { |
| value := strings.TrimSpace(raw) |
| if value == "" { |
| return 0, nil |
| } |
| limit, err := strconv.Atoi(value) |
| if err != nil { |
| return 0, fmt.Errorf("must be a positive integer") |
| } |
| if limit <= 0 { |
| return 0, fmt.Errorf("must be greater than zero") |
| } |
| return limit, nil |
| } |
|
|
| 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() |
| } |
|
|
| func isRotatedLogFile(name string) bool { |
| if _, ok := rotationOrder(name); ok { |
| return true |
| } |
| return false |
| } |
|
|
| func rotationOrder(name string) (int64, bool) { |
| if order, ok := numericRotationOrder(name); ok { |
| return order, true |
| } |
| if order, ok := timestampRotationOrder(name); ok { |
| return order, true |
| } |
| return 0, false |
| } |
|
|
| func 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 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 |
| } |
|
|