package service import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "io" "log" "os" "path/filepath" "strings" "time" "github.com/freefire/jiang13-bbs/model" ) const ( BrandLogoMaxBytes = 2 << 20 // 2 MiB BrandFaviconMaxBytes = 512 << 10 // 512 KiB brandMaxDim = 2048 ) // ErrBrandFileMissing 设置了合法 URL 但磁盘上没有对应文件 var ErrBrandFileMissing = errors.New("品牌图文件不存在") type brandFormat struct { ext string mime string svg bool ico bool } func brandMaxBytes(slot string) int64 { if slot == BrandSlotFavicon { return BrandFaviconMaxBytes } return BrandLogoMaxBytes } func detectBrandFormat(data []byte, slot string) (brandFormat, error) { logo := slot != BrandSlotFavicon too := "仅支持 PNG / WebP / SVG" if !logo { too = "仅支持 PNG / WebP / SVG / ICO" } fail := errors.New(too) if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" { return brandFormat{ext: ".png", mime: "image/png"}, nil } if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { return brandFormat{ext: ".webp", mime: "image/webp"}, nil } if !logo && len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 { return brandFormat{ext: ".ico", mime: "image/x-icon", ico: true}, nil } if looksLikeSVG(data) { return brandFormat{ext: ".svg", mime: "image/svg+xml", svg: true}, nil } return brandFormat{}, fail } func looksLikeSVG(data []byte) bool { s := strings.ToLower(string(data)) if !strings.Contains(s, " maxB { if slot == BrandSlotFavicon { return "", errors.New("Favicon 不能超过 512KB") } return "", errors.New("Logo 不能超过 2MB") } format, err := detectBrandFormat(data, slot) if err != nil { return "", err } if !format.svg && !format.ico { w, h, derr := decodeImageSizeReader(bytes.NewReader(data), format.mime) if derr != nil { return "", derr } if w < 1 || h < 1 || w > brandMaxDim || h > brandMaxDim { return "", errors.New("图片尺寸超出限制") } } if err := os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755); err != nil { return "", err } nameBytes := make([]byte, 16) if _, err := rand.Read(nameBytes); err != nil { return "", err } filename := hex.EncodeToString(nameBytes) + format.ext fullPath := filepath.Join(s.dir, "brand", filename) if err := os.WriteFile(fullPath, data, 0o644); err != nil { return "", err } return "/uploads/brand/" + filename, nil } // CopyBrandFromMedia 把当前用户媒体库里的一张图复制进品牌目录,不写站点设置 func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot string) (string, error) { if _, ok := NormalizeBrandSlot(slot); !ok { return "", errors.New("无效的品牌图位置") } var att model.Attachment if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil { return "", errors.New("图片不存在或不属于你") } if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true) if e != nil { return "", e } defer r.Close() return s.SaveBrand(slot, r) } abs, ok := s.safeUploadPath(att.URL) if !ok { return "", errors.New("无效的图片地址") } f, err := os.Open(abs) if err != nil { return "", errors.New("读取图片失败") } defer f.Close() return s.SaveBrand(slot, f) } func (s *UploadService) safeUploadPath(url string) (string, bool) { rel := strings.TrimPrefix(strings.TrimSpace(url), "/uploads/") if rel == "" || rel == url || strings.Contains(rel, "..") { return "", false } abs := filepath.Join(s.dir, filepath.FromSlash(rel)) root := filepath.Clean(s.dir) clean := filepath.Clean(abs) if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { return "", false } return clean, true } // BrandFileExists 确认 URL 对应文件在 brand 目录内 func (s *UploadService) BrandFileExists(url string) bool { rel := BrandRelPath(url) if rel == "" { return false } abs := filepath.Join(s.dir, filepath.FromSlash(rel)) info, err := os.Stat(abs) return err == nil && !info.IsDir() } // RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除 func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) { if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon { return } rel := BrandRelPath(oldURL) if rel == "" { return } abs := filepath.Join(s.dir, filepath.FromSlash(rel)) if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { log.Printf("[upload] 删除品牌图失败 path=%s: %v", abs, err) } }