package handlers import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) // fileIcon holds the two icon filenames for a file type: the standard (modern) // icon and the retro (Win98) icon. The filenames are resolved against // static/file-icons/standard and static/file-icons/retro respectively. type fileIcon struct { Standard string `json:"standard"` Retro string `json:"retro"` } type iconType struct { Mime string `json:"mime"` Standard string `json:"standard"` Retro string `json:"retro"` Extensions []string `json:"extensions"` } type iconMapFile struct { Default iconType `json:"default"` Types []iconType `json:"types"` } type mimeRule struct { pattern string // exact mime ("application/pdf") or major prefix ("image/") prefix bool icon fileIcon } // fileIconSet is the loaded icon map: an extension lookup plus content-type // rules and a fallback. It is built once at startup from icon-map.json. type fileIconSet struct { byExt map[string]fileIcon byMime []mimeRule fallback fileIcon } // loadFileIcons reads static/file-icons/icon-map.json and indexes it by // extension and content type so icons can be assigned at render time. func loadFileIcons(staticDir string) (*fileIconSet, error) { data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json")) if err != nil { return nil, err } var raw iconMapFile if err := json.Unmarshal(data, &raw); err != nil { return nil, err } set := &fileIconSet{ byExt: make(map[string]fileIcon), fallback: fileIcon{Standard: raw.Default.Standard, Retro: raw.Default.Retro}, } if err := validateFileIcon(staticDir, set.fallback); err != nil { return nil, err } for _, t := range raw.Types { icon := fileIcon{Standard: t.Standard, Retro: t.Retro} if err := validateFileIcon(staticDir, icon); err != nil { return nil, err } for _, ext := range t.Extensions { set.byExt[strings.ToLower(strings.TrimPrefix(ext, "."))] = icon } if t.Mime == "" { continue } if strings.HasSuffix(t.Mime, "/*") { set.byMime = append(set.byMime, mimeRule{pattern: strings.TrimSuffix(t.Mime, "*"), prefix: true, icon: icon}) } else { set.byMime = append(set.byMime, mimeRule{pattern: strings.ToLower(t.Mime), icon: icon}) } } return set, nil } func validateFileIcon(staticDir string, icon fileIcon) error { if icon.Standard != "" { if err := validateFileIconPath(staticDir, "standard", icon.Standard); err != nil { return err } } if icon.Retro != "" { if err := validateFileIconPath(staticDir, "retro", icon.Retro); err != nil { return err } } return nil } func validateFileIconPath(staticDir, theme, name string) error { if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") { return fmt.Errorf("invalid %s file icon path %q", theme, name) } path := filepath.Join(staticDir, "file-icons", theme, name) info, err := os.Stat(path) if err != nil { return fmt.Errorf("missing %s file icon %q: %w", theme, name, err) } if info.IsDir() { return fmt.Errorf("%s file icon %q is a directory", theme, name) } return nil } // lookup resolves a file's icon from its name (extension) first, falling back to // its content type, then to the default icon. Extension wins because stored // content types are often the generic application/octet-stream. func (s *fileIconSet) lookup(name, contentType string) fileIcon { if s == nil { return fileIcon{} } if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")); ext != "" { if icon, ok := s.byExt[ext]; ok { return icon } } ct := strings.ToLower(strings.TrimSpace(contentType)) if i := strings.IndexByte(ct, ';'); i >= 0 { ct = strings.TrimSpace(ct[:i]) } if ct != "" && ct != "application/octet-stream" { for _, rule := range s.byMime { // exact matches first if !rule.prefix && rule.pattern == ct { return rule.icon } } for _, rule := range s.byMime { // then major-type prefixes if rule.prefix && strings.HasPrefix(ct, rule.pattern) { return rule.icon } } } return s.fallback } // fileIconURL builds the /static URL for an icon filename in the given theme // directory ("standard" or "retro"). func fileIconURL(theme, name string) string { if name == "" { return "" } return "/static/file-icons/" + theme + "/" + name }