mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:54:44 +00:00
220 lines
6.4 KiB
Go
220 lines
6.4 KiB
Go
package web
|
|
|
|
import (
|
|
"archive/zip"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/amir20/dozzle/internal/auth"
|
|
"github.com/amir20/dozzle/internal/container"
|
|
container_support "github.com/amir20/dozzle/internal/support/container"
|
|
support_web "github.com/amir20/dozzle/internal/support/web"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
|
hostIds := strings.Split(chi.URLParam(r, "hostIds"), ",")
|
|
if len(hostIds) == 0 {
|
|
log.Error().Msg("no container ids provided")
|
|
http.Error(w, "no container ids provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userLabels := h.config.Labels
|
|
permit := true
|
|
if h.config.Authorization.Provider != NONE {
|
|
user := auth.UserFromContext(r.Context())
|
|
if user.ContainerLabels.Exists() {
|
|
userLabels = user.ContainerLabels
|
|
}
|
|
permit = user.Roles.Has(auth.Download)
|
|
}
|
|
|
|
if !permit {
|
|
log.Warn().Msg("user is not permitted to download logs from container")
|
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
nowFmt := now.Format("2006-01-02T15-04-05")
|
|
|
|
var stdTypes container.StdType
|
|
if r.URL.Query().Has("stdout") {
|
|
stdTypes |= container.STDOUT
|
|
}
|
|
if r.URL.Query().Has("stderr") {
|
|
stdTypes |= container.STDERR
|
|
}
|
|
|
|
if stdTypes == 0 {
|
|
http.Error(w, "stdout or stderr is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse filter regex if provided
|
|
var regex *regexp.Regexp
|
|
var err error
|
|
if r.URL.Query().Has("filter") {
|
|
regex, err = support_web.ParseRegex(r.URL.Query().Get("filter"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Inverse mode excludes lines matching the regex instead of keeping them.
|
|
inverse := r.URL.Query().Get("inverse") == "true"
|
|
|
|
// Parse level filters if provided
|
|
levels := make(map[string]struct{})
|
|
if r.URL.Query().Has("levels") {
|
|
for _, level := range r.URL.Query()["levels"] {
|
|
levels[level] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Validate all containers before starting to write response
|
|
type containerInfo struct {
|
|
hostId string
|
|
host string
|
|
id string
|
|
containerService *container_support.ContainerService
|
|
}
|
|
containers := make([]containerInfo, 0, len(hostIds))
|
|
|
|
for _, hostId := range hostIds {
|
|
parts := strings.Split(hostId, "~")
|
|
if len(parts) != 2 {
|
|
log.Error().Msgf("invalid host id: %s", hostId)
|
|
http.Error(w, fmt.Sprintf("invalid host id: %s", hostId), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
host := parts[0]
|
|
id := parts[1]
|
|
containerService, err := h.hostService.FindContainer(host, id, userLabels)
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error finding container %s", id)
|
|
http.Error(w, fmt.Sprintf("error finding container %s: %v", id, err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
containers = append(containers, containerInfo{
|
|
hostId: hostId,
|
|
host: host,
|
|
id: id,
|
|
containerService: containerService,
|
|
})
|
|
}
|
|
|
|
// Determine zip filename from optional name param or default
|
|
zipName := "container-logs"
|
|
if name := r.URL.Query().Get("name"); name != "" {
|
|
// Sanitize: keep only alphanumeric, hyphens, underscores, dots
|
|
sanitized := strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
|
return r
|
|
}
|
|
return '-'
|
|
}, name)
|
|
if sanitized != "" {
|
|
zipName = sanitized
|
|
}
|
|
}
|
|
|
|
// Set headers for zip file
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s-%s.zip", zipName, nowFmt))
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
|
|
// Create zip writer
|
|
zw := zip.NewWriter(w)
|
|
defer zw.Close()
|
|
|
|
// Process each container - errors after this point are logged only since response has started
|
|
for _, c := range containers {
|
|
// Create new file in zip for this container's logs
|
|
fileName := fmt.Sprintf("%s-%s.log", c.containerService.Container.Name, nowFmt)
|
|
f, err := zw.CreateHeader(&zip.FileHeader{
|
|
Name: fileName,
|
|
Modified: now,
|
|
Method: zip.Deflate,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error creating zip entry for container %s", c.id)
|
|
return
|
|
}
|
|
|
|
// Get container logs - use LogsBetweenDates if filtering is needed, otherwise use RawLogs
|
|
if regex != nil || len(levels) > 0 {
|
|
// Fetch parsed log events for filtering
|
|
events, err := c.containerService.LogsBetweenDates(r.Context(), time.Time{}, now, stdTypes)
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
|
|
return
|
|
}
|
|
|
|
// Filter and write events
|
|
for event := range events {
|
|
// Apply regex filter if provided. In inverse mode a match excludes
|
|
// the line, so skip when the match result equals the inverse flag.
|
|
if regex != nil && inverse == support_web.Search(regex, event) {
|
|
continue
|
|
}
|
|
|
|
// Apply level filter if provided
|
|
if len(levels) > 0 {
|
|
if _, ok := levels[event.Level]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Format timestamp in UTC
|
|
timestamp := time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano)
|
|
|
|
// Handle grouped logs
|
|
if event.Type == container.LogTypeGroup {
|
|
if fragments, ok := event.Message.([]container.LogFragment); ok {
|
|
for _, fragment := range fragments {
|
|
// Strip the search-highlight markers the regex filter
|
|
// injected into grouped fragments; otherwise they leak
|
|
// into the downloaded file as invisible characters.
|
|
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, container.StripHighlightMarkers(fragment.Message))
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Write timestamp followed by message for single/complex logs
|
|
_, err = fmt.Fprintf(f, "%s %s\n", timestamp, event.RawMessage)
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error writing log for container %s", c.id)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No filtering needed, use raw logs for better performance
|
|
reader, err := c.containerService.RawLogs(r.Context(), time.Time{}, now, stdTypes)
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error getting logs for container %s", c.id)
|
|
return
|
|
}
|
|
|
|
// Copy logs to zip file
|
|
_, err = io.Copy(f, reader)
|
|
if err != nil {
|
|
log.Error().Err(err).Msgf("error copying logs for container %s", c.id)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|