diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c6ff5d2 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module selfhst-icons + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 +) \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..9dee973 --- /dev/null +++ b/main.go @@ -0,0 +1,482 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" +) + +type Config struct { + Port string + IconSource string + JSDelivrURL string + LocalPath string + StandardIconFormat string + CacheTTL time.Duration + CacheSize int +} + +type CacheItem struct { + Content string + Timestamp time.Time +} + +type Cache struct { + items map[string]CacheItem + mutex sync.RWMutex + ttl time.Duration + max int +} + +func NewCache(ttl time.Duration, maxSize int) *Cache { + return &Cache{ + items: make(map[string]CacheItem), + ttl: ttl, + max: maxSize, + } +} + +func (c *Cache) Get(key string) (string, bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + item, exists := c.items[key] + if !exists { + return "", false + } + + if time.Since(item.Timestamp) > c.ttl { + delete(c.items, key) + return "", false + } + + return item.Content, true +} + +func (c *Cache) Set(key, value string) { + c.mutex.Lock() + defer c.mutex.Unlock() + + // Remove oldest item if cache is full + if len(c.items) >= c.max { + var oldestKey string + var oldestTime time.Time + first := true + + for k, v := range c.items { + if first || v.Timestamp.Before(oldestTime) { + oldestKey = k + oldestTime = v.Timestamp + first = false + } + } + delete(c.items, oldestKey) + } + + c.items[key] = CacheItem{ + Content: value, + Timestamp: time.Now(), + } +} + +var ( + config *Config + cache *Cache +) + +func loadConfig() *Config { + port := os.Getenv("PORT") + if port == "" { + port = "4050" + } + + iconSource := os.Getenv("ICON_SOURCE") + if iconSource == "" { + iconSource = "remote" + } + + standardFormat := os.Getenv("STANDARD_ICON_FORMAT") + if standardFormat == "" { + standardFormat = "svg" + } + // Validate format + if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" { + standardFormat = "svg" + } + + return &Config{ + Port: port, + IconSource: iconSource, + JSDelivrURL: "https://cdn.jsdelivr.net/gh/ryuupendragon/icons", + LocalPath: "/app/icons", + StandardIconFormat: standardFormat, + CacheTTL: time.Hour, + CacheSize: 500, + } +} + +func isValidHexColor(color string) bool { + matched, _ := regexp.MatchString("^[0-9A-Fa-f]{6}$", color) + return matched +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func urlExists(url string) bool { + resp, err := http.Head(url) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func readLocalFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +func fetchRemoteFile(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(data), nil +} + +func applySVGColor(svgContent, colorCode string) string { + color := "#" + colorCode + + // Replace style fill attributes + 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) + }) + + // Replace direct fill attributes + re3 := regexp.MustCompile(`fill="#fff"`) + svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`) + + return svgContent +} + +func getContentType(format string) string { + switch format { + case "png": + return "image/png" + case "webp": + return "image/webp" + case "svg": + return "image/svg+xml" + default: + return "image/svg+xml" + } +} + +func getCacheKey(iconName, colorCode string) string { + if colorCode == "" { + return iconName + ":default" + } + return iconName + ":" + colorCode +} + +func handleIcon(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + iconName := vars["iconname"] + colorCode := vars["colorcode"] + + if iconName == "" { + http.Error(w, "Icon name is required", http.StatusBadRequest) + return + } + + // Validate color if provided + if colorCode != "" && !isValidHexColor(colorCode) { + log.Printf("[ERROR] Invalid color code for icon \"%s\": %s", iconName, colorCode) + http.Error(w, "Invalid color code. Use 6-digit hex without #", http.StatusBadRequest) + return + } + + cacheKey := getCacheKey(iconName, colorCode) + + // Determine content type and format to serve + var contentType string + var formatToServe string + + if colorCode != "" { + // Always use SVG for colorization + contentType = "image/svg+xml" + formatToServe = "svg" + } else { + // Use standard format when no color specified + contentType = getContentType(config.StandardIconFormat) + formatToServe = config.StandardIconFormat + } + + // Check cache first + 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) + w.Header().Set("Content-Type", contentType) + w.Write([]byte(cached)) + return + } + + var iconContent string + var err error + + if config.IconSource == "local" { + // Use local volume + if colorCode != "" { + // Try to find -light version for colorization (always SVG) + lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg") + if fileExists(lightPath) { + iconContent, err = readLocalFile(lightPath) + if err == nil { + iconContent = applySVGColor(iconContent, colorCode) + } + } + } else { + // No color - try to serve standard format + var standardPath string + if formatToServe == "svg" { + standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg") + } else { + // For PNG/WebP, use format-specific directories + standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe) + } + + if fileExists(standardPath) { + iconContent, err = readLocalFile(standardPath) + } + } + + // Fall back to SVG if standard format not found + if iconContent == "" { + svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg") + if fileExists(svgPath) { + iconContent, err = readLocalFile(svgPath) + contentType = "image/svg+xml" + formatToServe = "svg" + } + } + } else { + // Use remote CDN + if colorCode != "" { + // Try to find -light version for colorization (always SVG) + lightURL := config.JSDelivrURL + "/svg/" + iconName + "-light.svg" + if urlExists(lightURL) { + iconContent, err = fetchRemoteFile(lightURL) + if err == nil { + iconContent = applySVGColor(iconContent, colorCode) + } + } + } else { + // No color - try to serve standard format + var standardURL string + if formatToServe == "svg" { + standardURL = config.JSDelivrURL + "/svg/" + iconName + ".svg" + } else { + standardURL = config.JSDelivrURL + "/" + formatToServe + "/" + iconName + "." + formatToServe + } + + if urlExists(standardURL) { + iconContent, err = fetchRemoteFile(standardURL) + } + } + + // Fall back to SVG if standard format not found + if iconContent == "" { + svgURL := config.JSDelivrURL + "/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) + http.Error(w, "Icon not found", http.StatusNotFound) + return + } + + // Cache the result + cache.Set(cacheKey, iconContent) + + log.Printf("[SUCCESS] Serving icon: \"%s\"%s (%s, source: %s)", iconName, + func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(), + formatToServe, config.IconSource) + + w.Header().Set("Content-Type", contentType) + w.Write([]byte(iconContent)) +} + +func handleLegacyIcon(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + colorQuery := r.URL.Query().Get("color") + + var colorCode string + if colorQuery != "" { + cleanColor := strings.TrimPrefix(colorQuery, "#") + if isValidHexColor(cleanColor) { + colorCode = cleanColor + } + } + + // Redirect internally to new handler + vars["colorcode"] = colorCode + handleIcon(w, r) +} + +func handleCustomIcon(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + filename := vars["filename"] + + if filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + // Build the path to the custom icon file + customPath := filepath.Join("/app/icons/custom", filename) + + // Debug logging + log.Printf("[DEBUG] Looking for custom icon at: %s", customPath) + + // Check if file exists + if !fileExists(customPath) { + // List directory contents for debugging + 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) + } else { + log.Printf("[DEBUG] Failed to read /app/icons/custom directory: %v", err) + } + log.Printf("[ERROR] Custom icon not found: \"%s\" at path: %s", filename, customPath) + http.Error(w, "Custom icon not found", http.StatusNotFound) + return + } + + // Read the file + data, err := os.ReadFile(customPath) + if err != nil { + log.Printf("[ERROR] Failed to read custom icon \"%s\": %v", filename, err) + http.Error(w, "Failed to read custom icon", http.StatusInternalServerError) + return + } + + // Determine content type based on file extension + 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 ".ico": + contentType = "image/x-icon" + default: + contentType = "application/octet-stream" + } + + log.Printf("[SUCCESS] Serving custom icon: \"%s\" (%s)", filename, contentType) + + 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.JSDelivrURL + }(), + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(configInfo) +} + +func main() { + config = loadConfig() + cache = NewCache(config.CacheTTL, config.CacheSize) + + r := mux.NewRouter() + + // Custom icons route: /custom/filename + r.HandleFunc("/custom/{filename}", handleCustomIcon).Methods("GET") + + // Main route: /iconname or /iconname/colorcode + r.HandleFunc("/{iconname}", handleIcon).Methods("GET") + r.HandleFunc("/{iconname}/{colorcode}", handleIcon).Methods("GET") + + // Legacy route: /iconname.svg?color=colorcode + r.HandleFunc("/{iconname}.svg", handleLegacyIcon).Methods("GET") + + // Root endpoint + r.HandleFunc("/", handleRoot).Methods("GET") + + log.Printf("Icon server listening on port %s", config.Port) + log.Printf("Icon source: %s", func() string { + if config.IconSource == "local" { + return "Local volume" + } + return "Remote CDN" + }()) + log.Printf("Cache settings: TTL %ds, Max %d items", int(config.CacheTTL.Seconds()), config.CacheSize) + + log.Fatal(http.ListenAndServe(":"+config.Port, r)) +} \ No newline at end of file