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:
@@ -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"]
|
||||
@@ -1 +1 @@
|
||||
3.2.0
|
||||
4.0.0
|
||||
@@ -1,3 +1,3 @@
|
||||
module selfhst-icons
|
||||
module github.com/selfhst/icons
|
||||
|
||||
go 1.26
|
||||
582
build/main.go
582
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))
|
||||
}
|
||||
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