From 71e98a28081ba62824b16805eae0286da196556b Mon Sep 17 00:00:00 2001 From: selfhst-bot Date: Sat, 9 Aug 2025 14:45:47 -0400 Subject: [PATCH] Update build files for v2.0.0 --- build/Dockerfile | 28 ++++ build/go.mod | 7 + build/main.go | 413 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100755 build/Dockerfile create mode 100755 build/go.mod create mode 100755 build/main.go diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100755 index 00000000..f9bca1e6 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Copy source code first +COPY go.mod main.go ./ + +# Download dependencies and build +RUN go mod tidy && \ + go mod download && \ + CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . + +# Final stage - use scratch for minimal image +FROM scratch + +# Add ca-certificates for HTTPS requests +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# Copy the binary +COPY --from=builder /app/server /server + +# Create non-root user +USER 65534:65534 + +EXPOSE 4050 + +ENTRYPOINT ["/server"] \ No newline at end of file diff --git a/build/go.mod b/build/go.mod new file mode 100755 index 00000000..c6ff5d2b --- /dev/null +++ b/build/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/build/main.go b/build/main.go new file mode 100755 index 00000000..34429efb --- /dev/null +++ b/build/main.go @@ -0,0 +1,413 @@ +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/selfhst/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 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() + + // 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