mirror of
https://github.com/selfhst/icons.git
synced 2026-04-30 13:26:18 -04:00
v4.0.0 (Icon extensions in URLs, hybrid option)
This commit is contained in:
37
CHANGELOG.md
37
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
|
# v3.2.0
|
||||||
|
|
||||||
## What's Changed
|
## 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 ```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 ```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
|
* Reduced remote icon load time by removing redundant existence checks
|
||||||
* Updated Go version to [v1.26](https://go.dev/doc/go1.26)
|
* Updated Go version to [v1.26](https://go.dev/doc/go1.26)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY go.mod main.go ./
|
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
|
FROM scratch
|
||||||
|
|
||||||
@@ -15,4 +15,7 @@ USER 65534:65534
|
|||||||
|
|
||||||
EXPOSE 4050
|
EXPOSE 4050
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/server", "-healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/server"]
|
ENTRYPOINT ["/server"]
|
||||||
@@ -1 +1 @@
|
|||||||
3.2.0
|
4.0.0
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
module selfhst-icons
|
module github.com/selfhst/icons
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
582
build/main.go
582
build/main.go
@@ -1,28 +1,41 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"compress/gzip"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
logLevelDebug = 0
|
||||||
|
logLevelInfo = 1
|
||||||
|
logLevelError = 2
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
IconSource string
|
IconSource string
|
||||||
RemoteURL string
|
RemoteURL string
|
||||||
LocalPath string
|
LocalPath string
|
||||||
StandardIconFormat string
|
PrimaryColor string
|
||||||
PrimaryColor string
|
CacheTTL time.Duration
|
||||||
CacheTTL time.Duration
|
CacheSize int
|
||||||
CacheSize int
|
RemoteTimeout time.Duration
|
||||||
|
CORSOrigins []string
|
||||||
|
LogLevel int
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheItem struct {
|
type CacheItem struct {
|
||||||
@@ -47,14 +60,19 @@ func NewCache(ttl time.Duration, maxSize int) *Cache {
|
|||||||
|
|
||||||
func (c *Cache) Get(key string) (string, bool) {
|
func (c *Cache) Get(key string) (string, bool) {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
item, exists := c.items[key]
|
item, exists := c.items[key]
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Since(item.Timestamp) > c.ttl {
|
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
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +105,40 @@ func (c *Cache) Set(key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
config *Config
|
config *Config
|
||||||
cache *Cache
|
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 {
|
func loadConfig() *Config {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
@@ -102,15 +150,6 @@ func loadConfig() *Config {
|
|||||||
iconSource = "remote"
|
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")
|
remoteURL := os.Getenv("REMOTE_URL")
|
||||||
if remoteURL == "" {
|
if remoteURL == "" {
|
||||||
remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
|
remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
|
||||||
@@ -118,29 +157,132 @@ func loadConfig() *Config {
|
|||||||
|
|
||||||
primaryColor := strings.TrimPrefix(os.Getenv("PRIMARY_COLOR"), "#")
|
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{
|
return &Config{
|
||||||
Port: port,
|
Port: port,
|
||||||
IconSource: iconSource,
|
IconSource: iconSource,
|
||||||
RemoteURL: remoteURL,
|
RemoteURL: remoteURL,
|
||||||
LocalPath: "/app/icons",
|
LocalPath: "/app/icons",
|
||||||
StandardIconFormat: standardFormat,
|
PrimaryColor: primaryColor,
|
||||||
PrimaryColor: primaryColor,
|
CacheTTL: cacheTTL,
|
||||||
CacheTTL: time.Hour,
|
CacheSize: cacheSize,
|
||||||
CacheSize: 500,
|
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 {
|
func isValidHexColor(color string) bool {
|
||||||
matched, _ := regexp.MatchString("^[0-9A-Fa-f]{6}$", color)
|
return hexColorRe.MatchString(color)
|
||||||
return matched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileExists(path string) bool {
|
func parseIconName(iconName string) (string, string) {
|
||||||
_, err := os.Stat(path)
|
ext := strings.ToLower(filepath.Ext(iconName))
|
||||||
return err == nil
|
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) {
|
func readLocalFile(path string) (string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,7 +292,7 @@ func readLocalFile(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchRemoteFile(url string) (string, error) {
|
func fetchRemoteFile(url string) (string, error) {
|
||||||
resp, err := http.Get(url)
|
resp, err := httpClient.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -171,28 +313,34 @@ func applySVGColor(svgContent, colorCode string) string {
|
|||||||
color := "#" + colorCode
|
color := "#" + colorCode
|
||||||
|
|
||||||
// Replace fill:#fff
|
// Replace fill:#fff
|
||||||
re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`)
|
svgContent = reFillStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
|
||||||
svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string {
|
return reFillInner.ReplaceAllString(match, "fill:"+color)
|
||||||
re2 := regexp.MustCompile(`fill:\s*#fff`)
|
|
||||||
return re2.ReplaceAllString(match, "fill:"+color)
|
|
||||||
})
|
})
|
||||||
|
svgContent = reFillAttr.ReplaceAllString(svgContent, `fill="`+color+`"`)
|
||||||
re3 := regexp.MustCompile(`fill="#fff"`)
|
|
||||||
svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`)
|
|
||||||
|
|
||||||
// Replace stop-color:#fff in gradients
|
// Replace stop-color:#fff in gradients
|
||||||
re4 := regexp.MustCompile(`style="[^"]*stop-color:\s*#fff[^"]*"`)
|
svgContent = reStopStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
|
||||||
svgContent = re4.ReplaceAllStringFunc(svgContent, func(match string) string {
|
return reStopInner.ReplaceAllString(match, "stop-color:"+color)
|
||||||
re5 := regexp.MustCompile(`stop-color:\s*#fff`)
|
|
||||||
return re5.ReplaceAllString(match, "stop-color:"+color)
|
|
||||||
})
|
})
|
||||||
|
svgContent = reStopAttr.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
|
||||||
re6 := regexp.MustCompile(`stop-color="#fff"`)
|
|
||||||
svgContent = re6.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
|
|
||||||
|
|
||||||
return svgContent
|
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 {
|
func getContentType(format string) string {
|
||||||
switch format {
|
switch format {
|
||||||
case "png":
|
case "png":
|
||||||
@@ -205,8 +353,12 @@ func getContentType(format string) string {
|
|||||||
return "image/x-icon"
|
return "image/x-icon"
|
||||||
case "svg":
|
case "svg":
|
||||||
return "image/svg+xml"
|
return "image/svg+xml"
|
||||||
|
case "jpg", "jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case "gif":
|
||||||
|
return "image/gif"
|
||||||
default:
|
default:
|
||||||
return "image/svg+xml"
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +370,7 @@ func getCacheKey(iconName, colorCode string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleIcon(w http.ResponseWriter, r *http.Request) {
|
func handleIcon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
iconName := r.PathValue("iconname")
|
iconName := r.PathValue("iconname")
|
||||||
colorCode := r.PathValue("colorcode")
|
colorCode := r.PathValue("colorcode")
|
||||||
|
|
||||||
@@ -226,6 +379,19 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
primaryFallback := false
|
||||||
if colorCode == "primary" {
|
if colorCode == "primary" {
|
||||||
if config.PrimaryColor == "" {
|
if config.PrimaryColor == "" {
|
||||||
@@ -235,124 +401,138 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if colorCode != "" && !isValidHexColor(colorCode) {
|
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)
|
http.Error(w, "Invalid color code. Use 6-digit hex without #", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := getCacheKey(iconName, colorCode)
|
|
||||||
|
|
||||||
var contentType string
|
var contentType string
|
||||||
var formatToServe string
|
var formatToServe string
|
||||||
|
|
||||||
if colorCode != "" {
|
if colorCode != "" {
|
||||||
contentType = "image/svg+xml"
|
contentType = "image/svg+xml"
|
||||||
formatToServe = "svg"
|
formatToServe = "svg"
|
||||||
} else {
|
} else {
|
||||||
contentType = getContentType(config.StandardIconFormat)
|
formatToServe = format
|
||||||
formatToServe = config.StandardIconFormat
|
contentType = getContentType(formatToServe)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := getCacheKey(baseName+"."+formatToServe, colorCode)
|
||||||
|
|
||||||
|
var colorSuffix string
|
||||||
|
if colorCode != "" {
|
||||||
|
colorSuffix = " with color " + colorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if cached, found := cache.Get(cacheKey); found {
|
if cached, found := cache.Get(cacheKey); found {
|
||||||
log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName,
|
logf(logLevelDebug, "[CACHE] Serving cached icon: \"%s\"%s (%s) %v", baseName, colorSuffix, formatToServe, formatDuration(time.Since(start)))
|
||||||
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
|
etag := computeETag(cached)
|
||||||
formatToServe)
|
if r.Header.Get("If-None-Match") == etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", contentType)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconContent string
|
var iconContent string
|
||||||
var err error
|
var servedFrom string
|
||||||
|
|
||||||
if config.IconSource == "local" {
|
if config.IconSource == "local" || config.IconSource == "hybrid" {
|
||||||
if colorCode != "" {
|
if colorCode != "" {
|
||||||
lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg")
|
lightPath := filepath.Join(config.LocalPath, "svg", baseName+"-light.svg")
|
||||||
if fileExists(lightPath) {
|
if content, err := readLocalFile(lightPath); err == nil {
|
||||||
iconContent, err = readLocalFile(lightPath)
|
iconContent = applySVGColor(content, colorCode)
|
||||||
if err == nil {
|
servedFrom = "local"
|
||||||
iconContent = applySVGColor(iconContent, colorCode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var standardPath string
|
var standardPath string
|
||||||
if formatToServe == "svg" {
|
if formatToServe == "svg" {
|
||||||
standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg")
|
standardPath = filepath.Join(config.LocalPath, "svg", baseName+".svg")
|
||||||
} else {
|
} else {
|
||||||
standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe)
|
standardPath = filepath.Join(config.LocalPath, formatToServe, baseName+"."+formatToServe)
|
||||||
}
|
}
|
||||||
|
iconContent, _ = readLocalFile(standardPath)
|
||||||
if fileExists(standardPath) {
|
|
||||||
iconContent, err = 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 iconContent == "" && (config.IconSource == "remote" || config.IconSource == "hybrid") {
|
||||||
if fileExists(svgPath) {
|
|
||||||
iconContent, err = readLocalFile(svgPath)
|
|
||||||
contentType = "image/svg+xml"
|
|
||||||
formatToServe = "svg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if colorCode != "" {
|
if colorCode != "" {
|
||||||
lightURL := config.RemoteURL + "/svg/" + iconName + "-light.svg"
|
lightURL := config.RemoteURL + "/svg/" + baseName + "-light.svg"
|
||||||
iconContent, err = fetchRemoteFile(lightURL)
|
if content, err := fetchRemoteFile(lightURL); err == nil {
|
||||||
if err == nil {
|
iconContent = applySVGColor(content, colorCode)
|
||||||
iconContent = applySVGColor(iconContent, colorCode)
|
servedFrom = "remote"
|
||||||
} else {
|
|
||||||
iconContent = ""
|
|
||||||
err = nil
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var standardURL string
|
var standardURL string
|
||||||
if formatToServe == "svg" {
|
if formatToServe == "svg" {
|
||||||
standardURL = config.RemoteURL + "/svg/" + iconName + ".svg"
|
standardURL = config.RemoteURL + "/svg/" + baseName + ".svg"
|
||||||
} else {
|
} else {
|
||||||
standardURL = config.RemoteURL + "/" + formatToServe + "/" + iconName + "." + formatToServe
|
standardURL = config.RemoteURL + "/" + formatToServe + "/" + baseName + "." + formatToServe
|
||||||
}
|
}
|
||||||
|
iconContent, _ = fetchRemoteFile(standardURL)
|
||||||
|
|
||||||
iconContent, err = fetchRemoteFile(standardURL)
|
if iconContent == "" && formatToServe != "webp" {
|
||||||
if err != nil {
|
webpURL := config.RemoteURL + "/webp/" + baseName + ".webp"
|
||||||
iconContent = ""
|
if content, err := fetchRemoteFile(webpURL); err == nil {
|
||||||
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 {
|
if iconContent == "" {
|
||||||
log.Printf("[ERROR] Icon not found: \"%s\"%s (source: %s)", iconName,
|
logf(logLevelError, "[ERROR] Icon not found: \"%s\"%s (source: %s) %v", baseName, colorSuffix, config.IconSource, formatDuration(time.Since(start)))
|
||||||
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
|
|
||||||
config.IconSource)
|
|
||||||
http.Error(w, "Icon not found", http.StatusNotFound)
|
http.Error(w, "Icon not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cacheKey = getCacheKey(baseName+"."+formatToServe, colorCode)
|
||||||
cache.Set(cacheKey, iconContent)
|
cache.Set(cacheKey, iconContent)
|
||||||
|
|
||||||
log.Printf("[%s] Serving icon: \"%s\"%s (%s, source: %s)",
|
level := "SUCCESS"
|
||||||
func() string { if primaryFallback { return "WARN" } else { return "SUCCESS" } }(),
|
detail := colorSuffix
|
||||||
iconName,
|
if primaryFallback {
|
||||||
func() string {
|
level = "WARN"
|
||||||
if colorCode != "" { return " with color " + colorCode }
|
detail = " (PRIMARY_COLOR not set, using default format)"
|
||||||
if primaryFallback { return " (PRIMARY_COLOR not set, using standard format)" }
|
}
|
||||||
return ""
|
logf(logLevelInfo, "[%s] Serving icon: \"%s\"%s (%s, source: %s) %v", level, baseName, detail, formatToServe, servedFrom, formatDuration(time.Since(start)))
|
||||||
}(),
|
|
||||||
formatToServe, config.IconSource)
|
|
||||||
|
|
||||||
|
etag := computeETag(iconContent)
|
||||||
|
if r.Header.Get("If-None-Match") == etag {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", contentType)
|
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) {
|
func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
filename := r.PathValue("filename")
|
filename := r.PathValue("filename")
|
||||||
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
@@ -360,91 +540,91 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) {
|
customPath := filepath.Join(config.LocalPath, "custom", filename)
|
||||||
if files, err := os.ReadDir("/app/icons/custom"); err == nil {
|
|
||||||
var fileList []string
|
stat, err := os.Stat(customPath)
|
||||||
for _, file := range files {
|
if err != nil {
|
||||||
fileList = append(fileList, file.Name())
|
if os.IsNotExist(err) {
|
||||||
}
|
logf(logLevelError, "[ERROR] Custom icon not found: \"%s\" %v", filename, formatDuration(time.Since(start)))
|
||||||
log.Printf("[DEBUG] Files in /app/icons/custom: %v", fileList)
|
http.Error(w, "Custom icon not found", http.StatusNotFound)
|
||||||
} else {
|
} 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)
|
return
|
||||||
http.Error(w, "Custom icon not found", http.StatusNotFound)
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(customPath)
|
data, err := os.ReadFile(customPath)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Failed to read custom icon", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
cache.Set(cacheKey, string(data))
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
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.Header().Set("Content-Type", contentType)
|
||||||
w.Write(data)
|
w.Header().Set("ETag", etag)
|
||||||
}
|
w.Header().Set("X-Cache", "MISS")
|
||||||
|
serveContent(w, r, contentType, string(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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()
|
config = loadConfig()
|
||||||
|
validateConfig(config)
|
||||||
cache = NewCache(config.CacheTTL, config.CacheSize)
|
cache = NewCache(config.CacheTTL, config.CacheSize)
|
||||||
|
httpClient = &http.Client{Timeout: config.RemoteTimeout}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
|
mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
|
||||||
|
|
||||||
// Suppress favicon load error message in logs when viewing via browser
|
// 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}/{colorcode}", handleIcon)
|
||||||
mux.HandleFunc("GET /{iconname}", handleIcon)
|
mux.HandleFunc("GET /{iconname}", handleIcon)
|
||||||
|
|
||||||
mux.HandleFunc("GET /", handleRoot)
|
|
||||||
|
|
||||||
log.Printf("Icon server listening on port %s", config.Port)
|
log.Printf("Icon server listening on port %s", config.Port)
|
||||||
log.Printf("Icon source: %s", func() string {
|
log.Printf("Icon source: %s", func() string {
|
||||||
if config.IconSource == "local" {
|
switch config.IconSource {
|
||||||
|
case "local":
|
||||||
return "Local volume"
|
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("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))
|
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!")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user