mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 21:10:51 +08:00
- Introduced `ResolveLogDirectory` function in `logging` package to standardize log directory determination across components. - Replaced redundant logic in `server`, `global_logger`, and `handlers` with the new utility function.
584 lines
14 KiB
Go
584 lines
14 KiB
Go
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
|
|
)
|
|
|
|
// GetLogs returns log lines with optional incremental loading.
|
|
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,
|
|
})
|
|
}
|
|
|
|
// DeleteLogs removes all rotated log files and truncates the active log.
|
|
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,
|
|
})
|
|
}
|
|
|
|
// GetRequestErrorLogs lists error request log files when RequestLog is disabled.
|
|
// It returns an empty list when RequestLog is enabled.
|
|
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
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
c.JSON(http.StatusOK, gin.H{"files": []any{}})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list request error logs: %v", err)})
|
|
return
|
|
}
|
|
|
|
type errorLog struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Modified int64 `json:"modified"`
|
|
}
|
|
|
|
files := make([]errorLog, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, "error-") || !strings.HasSuffix(name, ".log") {
|
|
continue
|
|
}
|
|
info, errInfo := entry.Info()
|
|
if errInfo != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log info for %s: %v", name, errInfo)})
|
|
return
|
|
}
|
|
files = append(files, errorLog{
|
|
Name: name,
|
|
Size: info.Size(),
|
|
Modified: info.ModTime().Unix(),
|
|
})
|
|
}
|
|
|
|
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
|
|
|
|
c.JSON(http.StatusOK, gin.H{"files": files})
|
|
}
|
|
|
|
// GetRequestLogByID finds and downloads a request log file by its request ID.
|
|
// The ID is matched against the suffix of log file names (format: *-{requestID}.log).
|
|
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)
|
|
}
|
|
|
|
// DownloadRequestErrorLog downloads a specific error request log file by name.
|
|
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
|
|
}
|