437 lines
10 KiB
Go
437 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
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()
|
|
|
|
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"
|
|
}
|
|
|
|
if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" && standardFormat != "avif" && standardFormat != "ico" {
|
|
standardFormat = "svg"
|
|
}
|
|
|
|
return &Config{
|
|
Port: port,
|
|
IconSource: iconSource,
|
|
JSDelivrURL: "https://cdn.jsdelivr.net/gh/ryuupendragon/icons@main",
|
|
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
|
|
|
|
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)
|
|
})
|
|
|
|
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 "avif":
|
|
return "image/avif"
|
|
case "ico":
|
|
return "image/x-icon"
|
|
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) {
|
|
iconName := r.PathValue("iconname")
|
|
colorCode := r.PathValue("colorcode")
|
|
|
|
if iconName == "" {
|
|
http.Error(w, "Icon name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
var contentType string
|
|
var formatToServe string
|
|
|
|
if colorCode != "" {
|
|
contentType = "image/svg+xml"
|
|
formatToServe = "svg"
|
|
} else {
|
|
contentType = getContentType(config.StandardIconFormat)
|
|
formatToServe = config.StandardIconFormat
|
|
}
|
|
|
|
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" {
|
|
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)
|
|
}
|
|
}
|
|
} else {
|
|
var standardPath string
|
|
if formatToServe == "svg" {
|
|
standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg")
|
|
} else {
|
|
standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe)
|
|
}
|
|
|
|
if fileExists(standardPath) {
|
|
iconContent, err = readLocalFile(standardPath)
|
|
}
|
|
}
|
|
|
|
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 colorCode != "" {
|
|
lightURL := config.JSDelivrURL + "/svg/" + iconName + "-light.svg"
|
|
if urlExists(lightURL) {
|
|
iconContent, err = fetchRemoteFile(lightURL)
|
|
if err == nil {
|
|
iconContent = applySVGColor(iconContent, colorCode)
|
|
}
|
|
}
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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.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 handleCustomIcon(w http.ResponseWriter, r *http.Request) {
|
|
filename := r.PathValue("filename")
|
|
|
|
if filename == "" {
|
|
http.Error(w, "Filename is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
customPath := filepath.Join("/app/icons/custom", filename)
|
|
|
|
log.Printf("[DEBUG] Looking for custom icon at: %s", customPath)
|
|
|
|
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)
|
|
} 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
|
|
|
|
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" {
|
|
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, mux))
|
|
} |