From ace2d6405c630983f956330913cc6be420a2e6f1 Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Mon, 17 Aug 2026 10:42:43 +0800 Subject: [PATCH] fix: bound webhook and dashboard memory usage --- cmd/feishu-github-tracker/main.go | 15 ++- internal/handler/handler.go | 99 ++++++++++++-- internal/handler/handler_test.go | 33 +++++ internal/logger/logger.go | 87 +++++++++--- internal/logger/logger_test.go | 2 + internal/notifier/notifier.go | 32 +++-- internal/notifier/notifier_test.go | 14 ++ internal/panel/app.go | 163 ++++++++++++++++++++++- internal/panel/dashboard_metrics_test.go | 31 +++++ internal/panel/handlers_auth.go | 4 +- internal/panel/handlers_dashboard.go | 18 +++ internal/panel/middleware.go | 8 +- 12 files changed, 454 insertions(+), 52 deletions(-) diff --git a/cmd/feishu-github-tracker/main.go b/cmd/feishu-github-tracker/main.go index 76c4461..8f3438e 100644 --- a/cmd/feishu-github-tracker/main.go +++ b/cmd/feishu-github-tracker/main.go @@ -157,8 +157,8 @@ func main() { if err := srv.Shutdown(ctx); err != nil { logger.Error("Server forced to shutdown: %v", err) } - logger.Info("Server stopped") + _ = logger.Close() } // initializeConfigDir copies default configuration files that do not yet exist. @@ -244,12 +244,15 @@ func initializeConfigDir(defaultConfigDir, configDir string) error { // NewServer creates an *http.Server configured from cfg and handler. func NewServer(cfg *config.Config, handler http.Handler) *http.Server { addr := fmt.Sprintf("%s:%d", cfg.Server.Server.Host, cfg.Server.Server.Port) + timeout := time.Duration(cfg.Server.Server.Timeout) * time.Second return &http.Server{ - Addr: addr, - Handler: handler, - ReadTimeout: time.Duration(cfg.Server.Server.Timeout) * time.Second, - WriteTimeout: time.Duration(cfg.Server.Server.Timeout) * time.Second, - IdleTimeout: 60 * time.Second, + Addr: addr, + Handler: handler, + ReadTimeout: timeout, + ReadHeaderTimeout: timeout, + WriteTimeout: timeout, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, } } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 4281b77..cf27b42 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,11 +5,14 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "strconv" "strings" + "sync" "github.com/hnrobert/feishu-github-tracker/internal/config" "github.com/hnrobert/feishu-github-tracker/internal/logger" @@ -20,8 +23,11 @@ import ( // Handler handles GitHub webhook requests type Handler struct { + mu sync.RWMutex + reloadMu sync.Mutex config *config.Config notifier *notifier.Notifier + maxBytes int64 hotReload bool configDir string // OnReload, if set, is invoked after a successful hot-reload of config (e.g. @@ -31,9 +37,14 @@ type Handler struct { // New creates a new Handler func New(cfg *config.Config, n *notifier.Notifier) *Handler { + maxBytes := defaultMaxPayloadBytes + if cfg != nil { + maxBytes = parseMaxPayloadBytes(cfg.Server.Server.MaxPayloadSize) + } return &Handler{ config: cfg, notifier: n, + maxBytes: maxBytes, hotReload: false, configDir: "", } @@ -41,8 +52,10 @@ func New(cfg *config.Config, n *notifier.Notifier) *Handler { // EnableHotReload enables configuration hot reload on each webhook request func (h *Handler) EnableHotReload(configDir string) { + h.mu.Lock() h.hotReload = true h.configDir = configDir + h.mu.Unlock() logger.Info("Hot reload enabled for config directory: %s", configDir) } @@ -51,18 +64,26 @@ func (h *Handler) EnableHotReload(configDir string) { // webhook when hot reload is enabled, and also by the management panel after a // configuration edit so that changes take effect immediately without a restart. func (h *Handler) Reload() { - if h.configDir == "" { + h.reloadMu.Lock() + defer h.reloadMu.Unlock() + h.mu.RLock() + configDir := h.configDir + h.mu.RUnlock() + if configDir == "" { return } - logger.Debug("Reloading configuration from %s", h.configDir) - cfg, err := config.Load(h.configDir) + logger.Debug("Reloading configuration from %s", configDir) + cfg, err := config.Load(configDir) if err != nil { logger.Error("Failed to reload configuration: %v", err) return } changed := false - if h.config != nil { - oldB, _ := json.Marshal(h.config) + h.mu.RLock() + oldConfig := h.config + h.mu.RUnlock() + if oldConfig != nil { + oldB, _ := json.Marshal(oldConfig) newB, _ := json.Marshal(cfg) if string(oldB) != string(newB) { logger.Info("Configuration changes detected, applying new configuration") @@ -73,11 +94,14 @@ func (h *Handler) Reload() { changed = true } + h.mu.Lock() h.config = cfg h.notifier = notifier.New(cfg.FeishuBots) + h.maxBytes = parseMaxPayloadBytes(cfg.Server.Server.MaxPayloadSize) + h.mu.Unlock() if h.OnReload != nil { - h.OnReload(h.configDir) + h.OnReload(configDir) } if !changed { @@ -97,15 +121,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Read body + // Read body within the configured limit so an unauthenticated webhook cannot + // force an unbounded allocation before signature verification. + r.Body = http.MaxBytesReader(w, r.Body, h.maxPayloadBytes()) + defer r.Body.Close() body, err := io.ReadAll(r.Body) if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "Webhook payload too large", http.StatusRequestEntityTooLarge) + return + } logger.Error("Failed to read request body: %v", err) http.Error(w, "Failed to read request body", http.StatusBadRequest) return } - defer r.Body.Close() - // Get event type eventType := r.Header.Get("X-GitHub-Event") if eventType == "" { @@ -149,13 +179,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - logger.Debug("Received %s event", eventType) - logger.Debug("Payload: %v", payload) + logger.Debug("Received %s event (%d bytes)", eventType, len(body)) // Verify signature. The signing secret is resolved per-request: the global // server.secret plus any secret configured on the repo/org rule this // webhook matches (so each GitHub-side webhook can use its own secret). If // no secret is configured anywhere, verification is skipped (as before). + h.mu.RLock() + defer h.mu.RUnlock() secrets := h.candidateSecrets(payload) if len(secrets) > 0 { if !h.verifySignatureAny(r.Header.Get("X-Hub-Signature-256"), body, secrets) { @@ -176,6 +207,52 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } +const defaultMaxPayloadBytes int64 = 5 << 20 + +func (h *Handler) maxPayloadBytes() int64 { + h.mu.RLock() + defer h.mu.RUnlock() + if h.maxBytes > 0 { + return h.maxBytes + } + return defaultMaxPayloadBytes +} + +func parseMaxPayloadBytes(value string) int64 { + value = strings.TrimSpace(strings.ToUpper(value)) + if value == "" { + return defaultMaxPayloadBytes + } + index := 0 + for index < len(value) && value[index] >= '0' && value[index] <= '9' { + index++ + } + if index == 0 { + return defaultMaxPayloadBytes + } + number, err := strconv.ParseInt(value[:index], 10, 64) + if err != nil || number <= 0 { + return defaultMaxPayloadBytes + } + suffix := strings.TrimSpace(value[index:]) + multiplier := int64(1) + switch suffix { + case "B", "": + case "KB", "KIB": + multiplier = 1 << 10 + case "MB", "MIB": + multiplier = 1 << 20 + case "GB", "GIB": + multiplier = 1 << 30 + default: + return defaultMaxPayloadBytes + } + if number > (int64(^uint64(0)>>1) / multiplier) { + return defaultMaxPayloadBytes + } + return number * multiplier +} + // candidateSecrets returns the webhook signing secrets that may apply to this // request: the global server.secret, plus any secret configured on the repo (or // org) rule(s) the payload matches. Deduplicated. Empty (and thus no signature diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 3cbcbc4..a2134f0 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -37,6 +37,7 @@ func TestPrepareTemplateData_IncludesNestedObjects(t *testing.T) { func TestProcessWebhookMatchAllRules(t *testing.T) { logger.Init("error", os.TempDir()) + defer logger.Close() received := make(map[string]int) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { received[r.URL.Path]++ @@ -93,6 +94,7 @@ func TestProcessWebhookMatchAllRules(t *testing.T) { func TestServeHTTP_FormEncodedPayload(t *testing.T) { // Initialize logger for tests logger.Init("info", "/tmp") + defer logger.Close() // Create a minimal config and handler cfg := &config.Config{ @@ -162,6 +164,7 @@ func TestServeHTTP_FormEncodedPayload(t *testing.T) { func TestServeHTTP_FormEncodedMissingPayload(t *testing.T) { // Initialize logger for tests logger.Init("info", "/tmp") + defer logger.Close() cfg := &config.Config{ Server: config.ServerConfig{ @@ -200,6 +203,36 @@ func TestServeHTTP_FormEncodedMissingPayload(t *testing.T) { } } +func TestServeHTTP_RejectsOversizedPayload(t *testing.T) { + cfg := &config.Config{} + cfg.Server.Server.MaxPayloadSize = "1KB" + h := New(cfg, notifier.New(config.FeishuBotsConfig{})) + req := httptest.NewRequest("POST", "/webhook", strings.NewReader(strings.Repeat("x", 2048))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Event", "push") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized payload status = %d, want %d", w.Code, http.StatusRequestEntityTooLarge) + } +} + +func TestParseMaxPayloadBytes(t *testing.T) { + tests := map[string]int64{ + "1KB": 1 << 10, + "5 MB": 5 << 20, + "2MiB": 2 << 20, + "4096": 4096, + "bad": defaultMaxPayloadBytes, + } + for input, want := range tests { + if got := parseMaxPayloadBytes(input); got != want { + t.Errorf("parseMaxPayloadBytes(%q) = %d, want %d", input, got, want) + } + } +} + func TestPrepareTemplateData_PushLinks(t *testing.T) { cfg := &config.Config{} n := notifier.New(config.FeishuBotsConfig{}) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4b4273f..518b234 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" ) @@ -27,23 +28,64 @@ var ( ERROR: "ERROR", } currentLevel = INFO - logger *log.Logger + logger = log.New(io.Discard, "", log.LstdFlags) + stateMu sync.RWMutex + fileWriter *dailyWriter ) +type dailyWriter struct { + mu sync.Mutex + dir string + date string + file *os.File +} + +func (w *dailyWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + date := time.Now().Format("2006-01-02") + if w.file == nil || w.date != date { + if w.file != nil { + _ = w.file.Close() + w.file = nil + } + file, err := os.OpenFile(filepath.Join(w.dir, "feishu-github-tracker-"+date+".log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return 0, err + } + w.date = date + w.file = file + } + return w.file.Write(p) +} + +func (w *dailyWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + // Init initializes the logger with the specified level and log directory func Init(levelStr string, logDir string) error { // Parse log level + level := INFO switch strings.ToLower(levelStr) { case "debug": - currentLevel = DEBUG + level = DEBUG case "info": - currentLevel = INFO + level = INFO case "warn": - currentLevel = WARN + level = WARN case "error": - currentLevel = ERROR + level = ERROR default: - currentLevel = INFO + level = INFO } // Create log directory if it doesn't exist @@ -51,21 +93,36 @@ func Init(levelStr string, logDir string) error { return fmt.Errorf("failed to create log directory: %w", err) } - // Create log file with date - logFile := filepath.Join(logDir, fmt.Sprintf("feishu-github-tracker-%s.log", time.Now().Format("2006-01-02"))) - file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) + stateMu.Lock() + defer stateMu.Unlock() + currentLevel = level + if fileWriter != nil { + _ = fileWriter.Close() } - - // Write to both file and stdout - multiWriter := io.MultiWriter(os.Stdout, file) - logger = log.New(multiWriter, "", log.LstdFlags) + fileWriter = &dailyWriter{dir: logDir} + // Write to both file and stdout. The file is opened lazily so rotation can + // switch to the next date without restarting the process. + logger = log.New(io.MultiWriter(os.Stdout, fileWriter), "", log.LstdFlags) return nil } +// Close releases the current log file. It is safe to call more than once. +func Close() error { + stateMu.Lock() + defer stateMu.Unlock() + if fileWriter == nil { + return nil + } + err := fileWriter.Close() + fileWriter = nil + logger = log.New(io.Discard, "", log.LstdFlags) + return err +} + func logMessage(level Level, format string, v ...any) { + stateMu.RLock() + defer stateMu.RUnlock() if level < currentLevel { return } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index ed88ec7..91483ca 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -14,6 +14,7 @@ func TestInitCreatesLogFileAndWrites(t *testing.T) { if err := Init("debug", dir); err != nil { t.Fatalf("Init failed: %v", err) } + defer Close() // write different level logs Debug("debug message %s", "d") Info("info message %s", "i") @@ -51,6 +52,7 @@ func TestLevelFiltering(t *testing.T) { if err := Init("warn", dir); err != nil { t.Fatalf("Init failed: %v", err) } + defer Close() // Reset logger output capture by creating and reading file Debug("should not appear") Info("should not appear") diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index 7794bdb..f60c005 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -3,6 +3,7 @@ package notifier import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -19,6 +20,8 @@ type Notifier struct { client *http.Client } +const maxWebhookResponseBytes int64 = 1 << 20 + // New creates a new Notifier func New(botsConfig config.FeishuBotsConfig) *Notifier { bots := make(map[string]string) @@ -46,10 +49,10 @@ func (n *Notifier) Send(targets []string, payload map[string]any) error { } if err := n.sendToWebhook(url, payload); err != nil { - logger.Error("Failed to send notification to %s: %v", url, err) + logger.Error("Failed to send notification to target %s: %v", targetLabel(target), err) errs = append(errs, err.Error()) } else { - logger.Info("Successfully sent notification to %s", target) + logger.Info("Successfully sent notification to %s", targetLabel(target)) } } @@ -80,27 +83,40 @@ func (n *Notifier) sendToWebhook(url string, payload map[string]any) error { return fmt.Errorf("failed to marshal payload: %w", err) } - logger.Debug("Sending payload to %s: %s", url, string(jsonData)) + logger.Debug("Sending %d-byte webhook payload", len(jsonData)) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return errors.New("failed to create webhook request") } req.Header.Set("Content-Type", "application/json") resp, err := n.client.Do(req) if err != nil { - return fmt.Errorf("failed to send request: %w", err) + return errors.New("failed to send webhook request") } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxWebhookResponseBytes+1)) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + if int64(len(body)) > maxWebhookResponseBytes { + return fmt.Errorf("webhook response exceeds %d bytes", maxWebhookResponseBytes) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("received non-2xx status code %d: %s", resp.StatusCode, string(body)) + return fmt.Errorf("received non-2xx status code %d", resp.StatusCode) } - logger.Debug("Response from webhook: %s", string(body)) + logger.Debug("Webhook response was %d bytes", len(body)) return nil } + +func targetLabel(target string) string { + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + return "direct webhook" + } + return target +} diff --git a/internal/notifier/notifier_test.go b/internal/notifier/notifier_test.go index 1e732fd..0d613b7 100644 --- a/internal/notifier/notifier_test.go +++ b/internal/notifier/notifier_test.go @@ -13,6 +13,7 @@ import ( func TestResolveURL(t *testing.T) { // initialize logger for tests _ = logger.Init("debug", t.TempDir()) + defer logger.Close() cfg := config.FeishuBotsConfig{ FeishuBots: []config.FeishuBot{{Alias: "dev", URL: "https://example.com/webhook"}}, @@ -35,6 +36,7 @@ func TestResolveURL(t *testing.T) { func TestSend_SuccessAndFailure(t *testing.T) { // initialize logger for tests _ = logger.Init("debug", t.TempDir()) + defer logger.Close() // Success server srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -63,3 +65,15 @@ func TestSend_SuccessAndFailure(t *testing.T) { t.Fatalf("expected error when server returns non-2xx") } } + +func TestSend_RejectsOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(make([]byte, maxWebhookResponseBytes+1)) + })) + defer server.Close() + + n := &Notifier{bots: map[string]string{}, client: server.Client()} + if err := n.Send([]string{server.URL}, map[string]any{"hello": "world"}); err == nil { + t.Fatal("expected oversized response to fail") + } +} diff --git a/internal/panel/app.go b/internal/panel/app.go index 8660282..bc5d1b3 100644 --- a/internal/panel/app.go +++ b/internal/panel/app.go @@ -10,9 +10,14 @@ import ( "embed" "encoding/json" "html/template" + "io" "net/http" "os" "path/filepath" + "runtime" + "runtime/debug" + "sort" + "strconv" "strings" "time" @@ -399,7 +404,7 @@ func readRecentLogLines(logDir string, n int) []string { } var newest os.DirEntry for _, e := range entries { - if e.IsDir() { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") { continue } if newest == nil || e.Name() > newest.Name() { @@ -409,11 +414,16 @@ func readRecentLogLines(logDir string, n int) []string { if newest == nil { return nil } - data, err := os.ReadFile(filepath.Join(logDir, newest.Name())) + info, err := newest.Info() if err != nil { return nil } - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + budget := dashboardLogByteBudget() + readSize := info.Size() + if readSize > budget { + readSize = budget + } + lines := readLogTail(filepath.Join(logDir, newest.Name()), info.Size()-readSize, readSize) var kept []string for _, l := range lines { if strings.Contains(l, "Successfully sent") || strings.Contains(l, "Failed") || strings.Contains(l, "notification") { @@ -432,20 +442,161 @@ func readRecentLogLines(logDir string, n int) []string { // each line's timestamp — reading only the newest file would put everything on // today's bar. func readDashboardLogLines(logDir string) []string { + return readDashboardLogLinesWithBudget(logDir, dashboardLogByteBudget()) +} + +const ( + minDashboardLogBytes = int64(1 << 20) + maxDashboardLogBytes = int64(16 << 20) + maxDashboardLogLines = 100000 +) + +// dashboardLogByteBudget reserves most of the currently available memory for +// request handling and configuration data. The fixed ceiling is intentional: +// a large host must not make a dashboard request allocate an unbounded slice. +func dashboardLogByteBudget() int64 { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + return dashboardLogByteBudgetFor(availableSystemMemory(), stats.HeapAlloc) +} + +func dashboardLogByteBudgetFor(available, heapAlloc uint64) int64 { + if available > heapAlloc { + available -= heapAlloc + } else { + available = 0 + } + budget := int64(available / 8) + if budget < minDashboardLogBytes { + return minDashboardLogBytes + } + if budget > maxDashboardLogBytes { + return maxDashboardLogBytes + } + return budget +} + +// availableSystemMemory returns MemAvailable on Linux. On other platforms it +// falls back to the Go runtime limit, if one was configured (for example via +// GOMEMLIMIT); the bounded minimum keeps the dashboard safe otherwise. +func availableSystemMemory() uint64 { + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "MemAvailable:" { + continue + } + value, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0 + } + if len(fields) > 2 && fields[2] == "kB" { + value *= 1024 + } + return value + } + } + limit := debug.SetMemoryLimit(-1) + if limit > 0 && limit < 1<<62 { + return uint64(limit) + } + return 0 +} + +type dashboardLogFile struct { + path string + name string + size int64 +} + +// readDashboardLogLinesWithBudget reads only the newest log tails that fit in +// budget. Files are visited newest-first, then chunks are returned chronologically. +func readDashboardLogLinesWithBudget(logDir string, budget int64) []string { + if budget <= 0 { + return nil + } entries, err := os.ReadDir(logDir) if err != nil { return nil } - var lines []string + files := make([]dashboardLogFile, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".log") { continue } - data, err := os.ReadFile(filepath.Join(logDir, entry.Name())) + info, err := entry.Info() if err != nil { continue } - lines = append(lines, strings.Split(strings.TrimSpace(string(data)), "\n")...) + files = append(files, dashboardLogFile{ + path: filepath.Join(logDir, entry.Name()), + name: entry.Name(), + size: info.Size(), + }) + } + sort.Slice(files, func(i, j int) bool { return files[i].name > files[j].name }) + + remainingBytes := budget + remainingLines := maxDashboardLogLines + chunks := make([][]string, 0, len(files)) + for _, file := range files { + if remainingBytes <= 0 || remainingLines <= 0 { + break + } + readSize := file.size + if readSize > remainingBytes { + readSize = remainingBytes + } + if readSize <= 0 { + continue + } + chunk := readLogTail(file.path, file.size-readSize, readSize) + if len(chunk) > remainingLines { + chunk = chunk[len(chunk)-remainingLines:] + } + if len(chunk) == 0 { + continue + } + chunks = append(chunks, chunk) + remainingBytes -= readSize + remainingLines -= len(chunk) + } + + var lines []string + for i := len(chunks) - 1; i >= 0; i-- { + lines = append(lines, chunks[i]...) + } + return lines +} + +func readLogTail(path string, offset, size int64) []string { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return nil + } + partialLine := false + if offset > 0 { + var previous [1]byte + if _, err := file.ReadAt(previous[:], offset-1); err != nil || previous[0] != '\n' { + partialLine = true + } + } + data := make([]byte, size) + if _, err := io.ReadFull(file, data); err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil + } + text := strings.TrimSpace(string(data)) + if text == "" { + return nil + } + lines := strings.Split(text, "\n") + if partialLine && len(lines) > 0 { + // The first byte may be in the middle of a log line. + lines = lines[1:] } return lines } diff --git a/internal/panel/dashboard_metrics_test.go b/internal/panel/dashboard_metrics_test.go index 31ac077..326f788 100644 --- a/internal/panel/dashboard_metrics_test.go +++ b/internal/panel/dashboard_metrics_test.go @@ -2,6 +2,8 @@ package panel import ( "net/http/httptest" + "os" + "path/filepath" "regexp" "testing" "time" @@ -29,6 +31,35 @@ func TestSummarizeDeliveries(t *testing.T) { } } +func TestReadDashboardLogLinesWithBudget(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "feishu-github-tracker-2026-08-16.log"), []byte("old-1\nold-2\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "feishu-github-tracker-2026-08-17.log"), []byte("new-1\nnew-2\n"), 0o600); err != nil { + t.Fatal(err) + } + + got := readDashboardLogLinesWithBudget(dir, int64(len("new-1\nnew-2\n"))+int64(len("old-2\n"))) + if len(got) != 3 || got[0] != "old-2" || got[1] != "new-1" || got[2] != "new-2" { + t.Fatalf("bounded dashboard logs = %#v", got) + } + + got = readDashboardLogLinesWithBudget(dir, 9) + if len(got) != 1 || got[0] != "new-2" { + t.Fatalf("partial log line was not discarded: %#v", got) + } +} + +func TestDashboardLogByteBudgetClamps(t *testing.T) { + if got := dashboardLogByteBudgetFor(1024<<20, 0); got != maxDashboardLogBytes { + t.Fatalf("large available memory budget = %d, want %d", got, maxDashboardLogBytes) + } + if got := dashboardLogByteBudgetFor(2<<20, 0); got != minDashboardLogBytes { + t.Fatalf("small available memory budget = %d, want %d", got, minDashboardLogBytes) + } +} + func TestLocaleFromAndTranslate(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) r.Header.Set("Accept-Language", "en-GB,en;q=0.9") diff --git a/internal/panel/handlers_auth.go b/internal/panel/handlers_auth.go index f998c62..c8506fb 100644 --- a/internal/panel/handlers_auth.go +++ b/internal/panel/handlers_auth.go @@ -45,11 +45,11 @@ func (a *App) handleLoginPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to issue session", http.StatusInternalServerError) return } - a.issueCookie(w, tok) + a.issueCookie(w, tok, requestIsSecure(r)) http.Redirect(w, r, "/", http.StatusSeeOther) } func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { - a.clearCookie(w) + a.clearCookie(w, requestIsSecure(r)) http.Redirect(w, r, "/login", http.StatusSeeOther) } diff --git a/internal/panel/handlers_dashboard.go b/internal/panel/handlers_dashboard.go index 3418406..ba8fa1a 100644 --- a/internal/panel/handlers_dashboard.go +++ b/internal/panel/handlers_dashboard.go @@ -119,6 +119,24 @@ func requestScheme(r *http.Request) string { return "https" } +// requestIsSecure reports only explicit HTTPS evidence for cookie flags. It +// intentionally does not use requestScheme's public-URL fallback to preserve +// cookies for deployments that still serve the panel over plain HTTP. +func requestIsSecure(r *http.Request) bool { + if r.TLS != nil { + return true + } + for _, h := range []string{"X-Forwarded-Proto", "X-Forwarded-Scheme", "X-Forwarded-Protocol"} { + if v := strings.ToLower(strings.TrimSpace(r.Header.Get(h))); strings.HasPrefix(v, "https") { + return true + } + } + if browserScheme(r, "Origin") == "https" || browserScheme(r, "Referer") == "https" { + return true + } + return strings.Contains(strings.ToLower(r.Header.Get("CF-Visitor")), `"scheme":"https"`) +} + // browserScheme extracts the scheme from a browser-set header (Origin or // Referer) when it is absolute and same-origin with the request host; "" when // absent, relative, or cross-origin (don't trust a cross-origin origin). diff --git a/internal/panel/middleware.go b/internal/panel/middleware.go index e55f674..94c4847 100644 --- a/internal/panel/middleware.go +++ b/internal/panel/middleware.go @@ -66,26 +66,26 @@ func (a *App) requireAuth(h http.HandlerFunc) http.HandlerFunc { } } -func (a *App) issueCookie(w http.ResponseWriter, token string) { +func (a *App) issueCookie(w http.ResponseWriter, token string, secure bool) { http.SetCookie(w, &http.Cookie{ Name: a.cookieName, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, - Secure: false, + Secure: secure, MaxAge: int(sessionTTL.Seconds()), }) } -func (a *App) clearCookie(w http.ResponseWriter) { +func (a *App) clearCookie(w http.ResponseWriter, secure bool) { http.SetCookie(w, &http.Cookie{ Name: a.cookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, - Secure: false, + Secure: secure, MaxAge: -1, }) }