diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e3eef25..8bf3b5e5 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,42 @@ +# v4.0.0 + +This release initially set out to address a request to support icon extensions in URLs, but quickly grew into a number of under the hood optimization/security fixes and a new ```hybrid``` mode that allows users with local collections to set remote icons as a fallback. + +## Breaking Changes + +The ```STANDARD_ICON_FORMAT``` variable has been deprecated and users now have the option to specify their desired icon extension in the URL. If an extension is not specified, the server will default to WebP. + +Users should review the updated methods for referencing icons in the [project's wiki](https://github.com/selfhst/icons/wiki#building-links) when upgrading. + +## What's Changed + +* [Feature] Added support for icon extensions in the URL (```example.svg```, ```example.png```, etc.) ([#718](https://github.com/selfhst/icons/issues/718), [#737](https://github.com/selfhst/icons/issues/737)) +* [Feature] Added support for custom color URL parameters (```?color=2d2d2d```) as an alternative to paths (```icon/2d2d2d```) ([#737](https://github.com/selfhst/icons/issues/737)) +* [Feature] Added a new ```hybrid``` source option that prioritizes local collections and falls back to remote when missing +* [Feature] Added an optional ```LOG_LEVEL``` variable to control log verbosity +* [Feature] Added an optional ```CORS_ALLOWED_ORIGINS``` variable (defaults to all or ```*```) +* [Feature] Added optional variables for configurable cache times (```CACHE_TTL```) and size (```CACHE_SIZE```) +* [Feature] Added an optional ```REMOTE_TIMEOUT``` variable to prevent the server from hanging when requests take too long (default: ```10``` seconds) +* Configured WebP as the global default when no extension is specified or an unsupported extension is requested +* Added Docker health checks via the `-healthcheck` flag to accommodate scratch's lack of tooling (curl/wget) +* Added startup validations and logging to warn users of misconfigured variables +* Added graceful shutdowns to allow in-flight requests to complete before exiting +* Added validations to properly identify ```#fff``` and ```#ffffff``` when colorizing icons +* Added request completion time to log messages +* Added case normalization checks to prevent the server from caching the same icon multiple times +* Added GZIP compression for SVG requests with proper proxy headers +* Added a security check for icons names with ```/```, ```\```, or ```..``` (attempted path traversal) +* Added an ```X-Cache``` response header to signal cache status without having to view container logs +* Added a process to regularly clean stale cache entries +* Removed redundant file existence checks for local icons +* Removed unnecessary directory scans when a custom icon is not found (initially included for debugging purposes) + # v3.2.0 ## What's Changed -* [Feature] Added PRIMARY_COLOR variable to easily apply a single custom color to all icons (see Wiki for additional details) -* [Feature] Added REMOTE_URL to allow users to serve icons from their own remote sources (see Wiki for additional details) ([#690](https://github.com/selfhst/icons/issues/690)) +* [Feature] Added ```PRIMARY_COLOR``` variable to easily apply a single custom color to all icons (see Wiki for additional details) +* [Feature] Added ```REMOTE_URL``` to allow users to serve icons from their own remote sources (see Wiki for additional details) ([#690](https://github.com/selfhst/icons/issues/690)) * Reduced remote icon load time by removing redundant existence checks * Updated Go version to [v1.26](https://go.dev/doc/go1.26) diff --git a/build/Dockerfile b/build/Dockerfile index c98140ef..cd210362 100755 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY go.mod main.go ./ -RUN CGO_ENABLED=0 go build -o server . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server . FROM scratch @@ -15,4 +15,7 @@ USER 65534:65534 EXPOSE 4050 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/server", "-healthcheck"] + ENTRYPOINT ["/server"] \ No newline at end of file diff --git a/build/VERSION b/build/VERSION index a4f52a5d..0c89fc92 100755 --- a/build/VERSION +++ b/build/VERSION @@ -1 +1 @@ -3.2.0 \ No newline at end of file +4.0.0 \ No newline at end of file diff --git a/build/go.mod b/build/go.mod index 2f53663b..15b1aacb 100755 --- a/build/go.mod +++ b/build/go.mod @@ -1,3 +1,3 @@ -module selfhst-icons +module github.com/selfhst/icons go 1.26 \ No newline at end of file diff --git a/build/main.go b/build/main.go index 53fe7da9..8f4fc7e8 100755 --- a/build/main.go +++ b/build/main.go @@ -1,28 +1,41 @@ package main import ( - "encoding/json" + "context" "fmt" + "hash/fnv" + "compress/gzip" "io" "log" "net/http" "os" + "os/signal" "path/filepath" "regexp" + "strconv" "strings" "sync" + "syscall" "time" ) +const ( + logLevelDebug = 0 + logLevelInfo = 1 + logLevelError = 2 +) + type Config struct { - Port string - IconSource string - RemoteURL string - LocalPath string - StandardIconFormat string - PrimaryColor string - CacheTTL time.Duration - CacheSize int + Port string + IconSource string + RemoteURL string + LocalPath string + PrimaryColor string + CacheTTL time.Duration + CacheSize int + RemoteTimeout time.Duration + CORSOrigins []string + LogLevel int } type CacheItem struct { @@ -47,14 +60,19 @@ func NewCache(ttl time.Duration, maxSize int) *Cache { func (c *Cache) Get(key string) (string, bool) { c.mutex.RLock() - defer c.mutex.RUnlock() - item, exists := c.items[key] + c.mutex.RUnlock() + if !exists { return "", false } if time.Since(item.Timestamp) > c.ttl { + c.mutex.Lock() + if current, exists := c.items[key]; exists && time.Since(current.Timestamp) > c.ttl { + delete(c.items, key) + } + c.mutex.Unlock() return "", false } @@ -87,10 +105,40 @@ func (c *Cache) Set(key, value string) { } var ( - config *Config - cache *Cache + config *Config + cache *Cache + httpClient *http.Client ) +var ( + hexColorRe = regexp.MustCompile(`^[0-9A-Fa-f]{6}$`) + reFillStyle = regexp.MustCompile(`style="[^"]*fill:\s*#fff(?:fff)?[^"]*"`) + reFillInner = regexp.MustCompile(`fill:\s*#fff(?:fff)?`) + reFillAttr = regexp.MustCompile(`fill="#fff(?:fff)?"`) + reStopStyle = regexp.MustCompile(`style="[^"]*stop-color:\s*#fff(?:fff)?[^"]*"`) + reStopInner = regexp.MustCompile(`stop-color:\s*#fff(?:fff)?`) + reStopAttr = regexp.MustCompile(`stop-color="#fff(?:fff)?"`) +) + +func logf(level int, format string, args ...any) { + if level >= config.LogLevel { + log.Printf(format, args...) + } +} + +func formatDuration(d time.Duration) string { + if d >= time.Second { + return fmt.Sprintf("%.2fs", d.Seconds()) + } + return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) +} + +func computeETag(content string) string { + h := fnv.New64a() + h.Write([]byte(content)) + return fmt.Sprintf(`"%x"`, h.Sum64()) +} + func loadConfig() *Config { port := os.Getenv("PORT") if port == "" { @@ -102,15 +150,6 @@ func loadConfig() *Config { iconSource = "remote" } - standardFormat := os.Getenv("STANDARD_ICON_FORMAT") - if standardFormat == "" { - standardFormat = "svg" - } - - if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" && standardFormat != "avif" && standardFormat != "ico" { - standardFormat = "svg" - } - remoteURL := os.Getenv("REMOTE_URL") if remoteURL == "" { remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main" @@ -118,29 +157,132 @@ func loadConfig() *Config { primaryColor := strings.TrimPrefix(os.Getenv("PRIMARY_COLOR"), "#") + cacheTTL := time.Hour + if v := os.Getenv("CACHE_TTL"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cacheTTL = time.Duration(n) * time.Second + } else { + log.Printf("[WARN] Invalid CACHE_TTL value \"%s\", using default (3600)", v) + } + } + + cacheSize := 500 + if v := os.Getenv("CACHE_SIZE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cacheSize = n + } else { + log.Printf("[WARN] Invalid CACHE_SIZE value \"%s\", using default (500)", v) + } + } + + remoteTimeout := 10 * time.Second + if v := os.Getenv("REMOTE_TIMEOUT"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + remoteTimeout = time.Duration(n) * time.Second + } else { + log.Printf("[WARN] Invalid REMOTE_TIMEOUT value \"%s\", using default (10)", v) + } + } + + corsAllowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS") + if corsAllowedOrigins == "" { + corsAllowedOrigins = "*" + } + var corsOrigins []string + for _, o := range strings.Split(corsAllowedOrigins, ",") { + if trimmed := strings.TrimSpace(o); trimmed != "" { + corsOrigins = append(corsOrigins, trimmed) + } + } + + logLevel := logLevelInfo + if v := os.Getenv("LOG_LEVEL"); v != "" { + switch strings.ToLower(v) { + case "debug": + logLevel = logLevelDebug + case "info": + logLevel = logLevelInfo + case "error": + logLevel = logLevelError + default: + log.Printf("[WARN] Invalid LOG_LEVEL value \"%s\", using default (info)", v) + } + } + return &Config{ - Port: port, - IconSource: iconSource, - RemoteURL: remoteURL, - LocalPath: "/app/icons", - StandardIconFormat: standardFormat, - PrimaryColor: primaryColor, - CacheTTL: time.Hour, - CacheSize: 500, + Port: port, + IconSource: iconSource, + RemoteURL: remoteURL, + LocalPath: "/app/icons", + PrimaryColor: primaryColor, + CacheTTL: cacheTTL, + CacheSize: cacheSize, + RemoteTimeout: remoteTimeout, + CORSOrigins: corsOrigins, + LogLevel: logLevel, + } +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(config.CORSOrigins) == 1 && config.CORSOrigins[0] == "*" { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + origin := r.Header.Get("Origin") + for _, allowed := range config.CORSOrigins { + if origin == allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + break + } + } + } + next.ServeHTTP(w, r) + }) +} + +func validateConfig(cfg *Config) { + if cfg.IconSource != "local" && cfg.IconSource != "remote" && cfg.IconSource != "hybrid" { + log.Fatalf("[ERROR] Invalid ICON_SOURCE \"%s\": must be \"local\", \"remote\", or \"hybrid\"", cfg.IconSource) + } + if cfg.IconSource == "local" || cfg.IconSource == "hybrid" { + info, err := os.Stat(cfg.LocalPath) + if err != nil { + log.Fatalf("[ERROR] Icon path \"%s\" is not accessible: %v", cfg.LocalPath, err) + } + if !info.IsDir() { + log.Fatalf("[ERROR] Icon path \"%s\" is not a directory", cfg.LocalPath) + } + } + if cfg.PrimaryColor != "" && !isValidHexColor(cfg.PrimaryColor) { + log.Fatalf("[ERROR] PRIMARY_COLOR \"%s\" is not a valid 6-digit hex color", cfg.PrimaryColor) + } + for _, origin := range cfg.CORSOrigins { + if origin != "*" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { + log.Printf("[WARN] CORS_ALLOWED_ORIGINS entry \"%s\" is missing a scheme — did you mean \"https://%s\"?", origin, origin) + } } } func isValidHexColor(color string) bool { - matched, _ := regexp.MatchString("^[0-9A-Fa-f]{6}$", color) - return matched + return hexColorRe.MatchString(color) } -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil +func parseIconName(iconName string) (string, string) { + ext := strings.ToLower(filepath.Ext(iconName)) + if ext != "" { + format := strings.TrimPrefix(ext, ".") + switch format { + case "svg", "png", "webp", "avif", "ico": + return strings.TrimSuffix(iconName, filepath.Ext(iconName)), format + default: + logf(logLevelError, "[WARN] Unrecognized extension \"%s\" in icon name \"%s\", serving webp instead", ext, iconName) + return strings.TrimSuffix(iconName, filepath.Ext(iconName)), "webp" + } + } + return iconName, "webp" } - func readLocalFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { @@ -150,7 +292,7 @@ func readLocalFile(path string) (string, error) { } func fetchRemoteFile(url string) (string, error) { - resp, err := http.Get(url) + resp, err := httpClient.Get(url) if err != nil { return "", err } @@ -171,28 +313,34 @@ func applySVGColor(svgContent, colorCode string) string { color := "#" + colorCode // Replace fill:#fff - re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`) - svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string { - re2 := regexp.MustCompile(`fill:\s*#fff`) - return re2.ReplaceAllString(match, "fill:"+color) + svgContent = reFillStyle.ReplaceAllStringFunc(svgContent, func(match string) string { + return reFillInner.ReplaceAllString(match, "fill:"+color) }) - - re3 := regexp.MustCompile(`fill="#fff"`) - svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`) + svgContent = reFillAttr.ReplaceAllString(svgContent, `fill="`+color+`"`) // Replace stop-color:#fff in gradients - re4 := regexp.MustCompile(`style="[^"]*stop-color:\s*#fff[^"]*"`) - svgContent = re4.ReplaceAllStringFunc(svgContent, func(match string) string { - re5 := regexp.MustCompile(`stop-color:\s*#fff`) - return re5.ReplaceAllString(match, "stop-color:"+color) + svgContent = reStopStyle.ReplaceAllStringFunc(svgContent, func(match string) string { + return reStopInner.ReplaceAllString(match, "stop-color:"+color) }) - - re6 := regexp.MustCompile(`stop-color="#fff"`) - svgContent = re6.ReplaceAllString(svgContent, `stop-color="`+color+`"`) + svgContent = reStopAttr.ReplaceAllString(svgContent, `stop-color="`+color+`"`) return svgContent } +func serveContent(w http.ResponseWriter, r *http.Request, contentType, content string) { + if contentType == "image/svg+xml" && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) + if err == nil { + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + defer gz.Close() + io.WriteString(gz, content) + return + } + } + io.WriteString(w, content) +} + func getContentType(format string) string { switch format { case "png": @@ -205,8 +353,12 @@ func getContentType(format string) string { return "image/x-icon" case "svg": return "image/svg+xml" + case "jpg", "jpeg": + return "image/jpeg" + case "gif": + return "image/gif" default: - return "image/svg+xml" + return "" } } @@ -218,6 +370,7 @@ func getCacheKey(iconName, colorCode string) string { } func handleIcon(w http.ResponseWriter, r *http.Request) { + start := time.Now() iconName := r.PathValue("iconname") colorCode := r.PathValue("colorcode") @@ -226,6 +379,19 @@ func handleIcon(w http.ResponseWriter, r *http.Request) { return } + baseName, format := parseIconName(iconName) + baseName = strings.ToLower(baseName) + + if strings.Contains(baseName, "..") || strings.Contains(baseName, "/") || strings.Contains(baseName, "\\") { + logf(logLevelError, "[ERROR] Invalid icon name, path traversal attempt: \"%s\"", iconName) + http.Error(w, "Invalid icon name", http.StatusBadRequest) + return + } + + if colorCode == "" { + colorCode = strings.TrimPrefix(r.URL.Query().Get("color"), "#") + } + primaryFallback := false if colorCode == "primary" { if config.PrimaryColor == "" { @@ -235,124 +401,138 @@ func handleIcon(w http.ResponseWriter, r *http.Request) { } if colorCode != "" && !isValidHexColor(colorCode) { - log.Printf("[ERROR] Invalid color code for icon \"%s\": %s", iconName, colorCode) + logf(logLevelError, "[ERROR] Invalid color code for icon \"%s\": %s", baseName, colorCode) http.Error(w, "Invalid color code. Use 6-digit hex without #", http.StatusBadRequest) return } - cacheKey := getCacheKey(iconName, colorCode) - var contentType string var formatToServe string - + if colorCode != "" { contentType = "image/svg+xml" formatToServe = "svg" } else { - contentType = getContentType(config.StandardIconFormat) - formatToServe = config.StandardIconFormat + formatToServe = format + contentType = getContentType(formatToServe) + } + + cacheKey := getCacheKey(baseName+"."+formatToServe, colorCode) + + var colorSuffix string + if colorCode != "" { + colorSuffix = " with color " + colorCode } if cached, found := cache.Get(cacheKey); found { - log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName, - func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(), - formatToServe) + logf(logLevelDebug, "[CACHE] Serving cached icon: \"%s\"%s (%s) %v", baseName, colorSuffix, formatToServe, formatDuration(time.Since(start))) + etag := computeETag(cached) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } w.Header().Set("Content-Type", contentType) - w.Write([]byte(cached)) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", etag) + w.Header().Set("X-Cache", "HIT") + serveContent(w, r, contentType, cached) return } var iconContent string - var err error + var servedFrom string - if config.IconSource == "local" { + if config.IconSource == "local" || config.IconSource == "hybrid" { if colorCode != "" { - lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg") - if fileExists(lightPath) { - iconContent, err = readLocalFile(lightPath) - if err == nil { - iconContent = applySVGColor(iconContent, colorCode) - } + lightPath := filepath.Join(config.LocalPath, "svg", baseName+"-light.svg") + if content, err := readLocalFile(lightPath); err == nil { + iconContent = applySVGColor(content, colorCode) + servedFrom = "local" } } else { var standardPath string if formatToServe == "svg" { - standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg") + standardPath = filepath.Join(config.LocalPath, "svg", baseName+".svg") } else { - standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe) + standardPath = filepath.Join(config.LocalPath, formatToServe, baseName+"."+formatToServe) } - - if fileExists(standardPath) { - iconContent, err = readLocalFile(standardPath) + iconContent, _ = readLocalFile(standardPath) + + if iconContent == "" && formatToServe != "webp" { + webpPath := filepath.Join(config.LocalPath, "webp", baseName+".webp") + if content, err := readLocalFile(webpPath); err == nil { + iconContent = content + contentType = "image/webp" + formatToServe = "webp" + } + } + if iconContent != "" { + servedFrom = "local" } } - - if iconContent == "" { - svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg") - if fileExists(svgPath) { - iconContent, err = readLocalFile(svgPath) - contentType = "image/svg+xml" - formatToServe = "svg" - } - } - } else { + } + + if iconContent == "" && (config.IconSource == "remote" || config.IconSource == "hybrid") { if colorCode != "" { - lightURL := config.RemoteURL + "/svg/" + iconName + "-light.svg" - iconContent, err = fetchRemoteFile(lightURL) - if err == nil { - iconContent = applySVGColor(iconContent, colorCode) - } else { - iconContent = "" - err = nil + lightURL := config.RemoteURL + "/svg/" + baseName + "-light.svg" + if content, err := fetchRemoteFile(lightURL); err == nil { + iconContent = applySVGColor(content, colorCode) + servedFrom = "remote" } } else { var standardURL string if formatToServe == "svg" { - standardURL = config.RemoteURL + "/svg/" + iconName + ".svg" + standardURL = config.RemoteURL + "/svg/" + baseName + ".svg" } else { - standardURL = config.RemoteURL + "/" + formatToServe + "/" + iconName + "." + formatToServe + standardURL = config.RemoteURL + "/" + formatToServe + "/" + baseName + "." + formatToServe } + iconContent, _ = fetchRemoteFile(standardURL) - iconContent, err = fetchRemoteFile(standardURL) - if err != nil { - iconContent = "" - err = nil + if iconContent == "" && formatToServe != "webp" { + webpURL := config.RemoteURL + "/webp/" + baseName + ".webp" + if content, err := fetchRemoteFile(webpURL); err == nil { + iconContent = content + contentType = "image/webp" + formatToServe = "webp" + } + } + if iconContent != "" { + servedFrom = "remote" } - } - - if iconContent == "" { - svgURL := config.RemoteURL + "/svg/" + iconName + ".svg" - iconContent, err = fetchRemoteFile(svgURL) - contentType = "image/svg+xml" - formatToServe = "svg" } } - if iconContent == "" || err != nil { - log.Printf("[ERROR] Icon not found: \"%s\"%s (source: %s)", iconName, - func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(), - config.IconSource) + if iconContent == "" { + logf(logLevelError, "[ERROR] Icon not found: \"%s\"%s (source: %s) %v", baseName, colorSuffix, config.IconSource, formatDuration(time.Since(start))) http.Error(w, "Icon not found", http.StatusNotFound) return } + cacheKey = getCacheKey(baseName+"."+formatToServe, colorCode) cache.Set(cacheKey, iconContent) - log.Printf("[%s] Serving icon: \"%s\"%s (%s, source: %s)", - func() string { if primaryFallback { return "WARN" } else { return "SUCCESS" } }(), - iconName, - func() string { - if colorCode != "" { return " with color " + colorCode } - if primaryFallback { return " (PRIMARY_COLOR not set, using standard format)" } - return "" - }(), - formatToServe, config.IconSource) + level := "SUCCESS" + detail := colorSuffix + if primaryFallback { + level = "WARN" + detail = " (PRIMARY_COLOR not set, using default format)" + } + logf(logLevelInfo, "[%s] Serving icon: \"%s\"%s (%s, source: %s) %v", level, baseName, detail, formatToServe, servedFrom, formatDuration(time.Since(start))) + etag := computeETag(iconContent) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } w.Header().Set("Content-Type", contentType) - w.Write([]byte(iconContent)) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", etag) + w.Header().Set("X-Cache", "MISS") + serveContent(w, r, contentType, iconContent) } func handleCustomIcon(w http.ResponseWriter, r *http.Request) { + start := time.Now() filename := r.PathValue("filename") if filename == "" { @@ -360,91 +540,91 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) { return } - customPath := filepath.Join("/app/icons/custom", filename) + if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + logf(logLevelError, "[ERROR] Invalid custom icon filename, path traversal attempt: \"%s\"", filename) + http.Error(w, "Invalid filename", http.StatusBadRequest) + return + } - log.Printf("[DEBUG] Looking for custom icon at: %s", customPath) + filename = strings.ToLower(filename) - if !fileExists(customPath) { - if files, err := os.ReadDir("/app/icons/custom"); err == nil { - var fileList []string - for _, file := range files { - fileList = append(fileList, file.Name()) - } - log.Printf("[DEBUG] Files in /app/icons/custom: %v", fileList) + customPath := filepath.Join(config.LocalPath, "custom", filename) + + stat, err := os.Stat(customPath) + if err != nil { + if os.IsNotExist(err) { + logf(logLevelError, "[ERROR] Custom icon not found: \"%s\" %v", filename, formatDuration(time.Since(start))) + http.Error(w, "Custom icon not found", http.StatusNotFound) } else { - log.Printf("[DEBUG] Failed to read /app/icons/custom directory: %v", err) + logf(logLevelError, "[ERROR] Failed to read custom icon \"%s\": %v (%v)", filename, err, formatDuration(time.Since(start))) + http.Error(w, "Failed to read custom icon", http.StatusInternalServerError) } - log.Printf("[ERROR] Custom icon not found: \"%s\" at path: %s", filename, customPath) - http.Error(w, "Custom icon not found", http.StatusNotFound) + return + } + + etag := fmt.Sprintf(`"%d-%d"`, stat.ModTime().Unix(), stat.Size()) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + ext := strings.ToLower(filepath.Ext(filename)) + contentType := getContentType(strings.TrimPrefix(ext, ".")) + if contentType == "" { + contentType = "application/octet-stream" + } + + cacheKey := "custom:" + filename + ":" + etag + if cached, found := cache.Get(cacheKey); found { + logf(logLevelDebug, "[CACHE] Serving cached custom icon: \"%s\" %v", filename, formatDuration(time.Since(start))) + w.Header().Set("Content-Type", contentType) + w.Header().Set("ETag", etag) + w.Header().Set("X-Cache", "HIT") + serveContent(w, r, contentType, cached) return } data, err := os.ReadFile(customPath) if err != nil { - log.Printf("[ERROR] Failed to read custom icon \"%s\": %v", filename, err) + logf(logLevelError, "[ERROR] Failed to read custom icon \"%s\": %v (%v)", filename, err, formatDuration(time.Since(start))) http.Error(w, "Failed to read custom icon", http.StatusInternalServerError) return } - ext := strings.ToLower(filepath.Ext(filename)) - var contentType string - switch ext { - case ".png": - contentType = "image/png" - case ".jpg", ".jpeg": - contentType = "image/jpeg" - case ".gif": - contentType = "image/gif" - case ".svg": - contentType = "image/svg+xml" - case ".webp": - contentType = "image/webp" - case ".avif": - contentType = "image/avif" - case ".ico": - contentType = "image/x-icon" - default: - contentType = "application/octet-stream" - } + cache.Set(cacheKey, string(data)) - log.Printf("[SUCCESS] Serving custom icon: \"%s\" (%s)", filename, contentType) + logf(logLevelInfo, "[SUCCESS] Serving custom icon: \"%s\" (%s) %v", filename, contentType, formatDuration(time.Since(start))) w.Header().Set("Content-Type", contentType) - w.Write(data) -} - -func handleRoot(w http.ResponseWriter, r *http.Request) { - configInfo := map[string]interface{}{ - "server": "Self-hosted icon server", - "urlFormat": "https://subdomain.example.com/iconname/colorcode", - "features": map[string]interface{}{ - "iconSource": func() string { - if config.IconSource == "local" { - return "Local volume" - } - return "Remote CDN" - }(), - "standardFormat": config.StandardIconFormat, - "caching": fmt.Sprintf("TTL: %ds, Max items: %d", int(config.CacheTTL.Seconds()), config.CacheSize), - "baseUrl": func() string { - if config.IconSource == "local" { - return config.LocalPath - } - return config.RemoteURL - }(), - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(configInfo) + w.Header().Set("ETag", etag) + w.Header().Set("X-Cache", "MISS") + serveContent(w, r, contentType, string(data)) } func main() { + if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { + port := os.Getenv("PORT") + if port == "" { + port = "4050" + } + resp, err := http.Get("http://localhost:" + port + "/health") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + os.Exit(0) + } + config = loadConfig() + validateConfig(config) cache = NewCache(config.CacheTTL, config.CacheSize) + httpClient = &http.Client{Timeout: config.RemoteTimeout} mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /custom/{filename}", handleCustomIcon) // Suppress favicon load error message in logs when viewing via browser @@ -455,16 +635,46 @@ func main() { mux.HandleFunc("GET /{iconname}/{colorcode}", handleIcon) mux.HandleFunc("GET /{iconname}", handleIcon) - mux.HandleFunc("GET /", handleRoot) - log.Printf("Icon server listening on port %s", config.Port) log.Printf("Icon source: %s", func() string { - if config.IconSource == "local" { + switch config.IconSource { + case "local": return "Local volume" + case "hybrid": + return "Hybrid (local with remote fallback: " + config.RemoteURL + ")" + default: + return "Remote: " + config.RemoteURL } - return "Remote: " + config.RemoteURL }()) log.Printf("Cache settings: TTL %ds, Max %d items", int(config.CacheTTL.Seconds()), config.CacheSize) + log.Printf("Log level: %s", []string{"debug", "info", "error"}[config.LogLevel]) - log.Fatal(http.ListenAndServe(":"+config.Port, mux)) -} \ No newline at end of file + server := &http.Server{ + Addr: ":" + config.Port, + Handler: corsMiddleware(mux), + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + <-quit + log.Println("Shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server stopped. Bye for now!") +}