package transport import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "image" "image/jpeg" "image/png" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" _ "golang.org/x/image/webp" "github.com/geo-platform/tenant-api/internal/bootstrap" ) type AssetHandler struct { app *bootstrap.App } func NewAssetHandler(app *bootstrap.App) *AssetHandler { return &AssetHandler{app: app} } func (h *AssetHandler) BuildArticleImageURL(objectKey string) string { token := h.signObjectKey(objectKey) return "/api/public/assets/" + token } func (h *AssetHandler) Serve(c *gin.Context) { objectKey, ok := h.parseToken(c.Param("token")) if !ok { c.AbortWithStatus(http.StatusNotFound) return } h.serveObject(c, objectKey) } func (h *AssetHandler) ServeDesktop(c *gin.Context) { client := MustDesktopClient(c.Request.Context()) objectKey, ok := objectKeyFromAssetToken(c.Param("token")) if !ok || !isDesktopClientAssetKey(objectKey, client.TenantID) { c.AbortWithStatus(http.StatusNotFound) return } h.serveObject(c, objectKey) } func (h *AssetHandler) serveObject(c *gin.Context, objectKey string) { content, err := h.app.ObjectStorage.GetBytes(c.Request.Context(), objectKey) if err != nil || len(content) == 0 { c.AbortWithStatus(http.StatusNotFound) return } if converted, contentType, ok := convertPublicAssetFormat(content, c.Query("format")); ok { c.Header("Cache-Control", "public, max-age=31536000, immutable") c.Header("Content-Type", contentType) c.Writer.WriteHeader(http.StatusOK) _, _ = c.Writer.Write(converted) return } contentType := http.DetectContentType(content) c.Header("Cache-Control", "public, max-age=31536000, immutable") c.Header("Content-Type", contentType) c.Writer.WriteHeader(http.StatusOK) _, _ = c.Writer.Write(content) } func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bool) { normalized := strings.ToLower(strings.TrimSpace(format)) if normalized == "" { return nil, "", false } if normalized != "png" && normalized != "jpg" && normalized != "jpeg" { return nil, "", false } decoded, _, err := image.Decode(bytes.NewReader(content)) if err != nil { return nil, "", false } var output bytes.Buffer if normalized == "png" { if err := png.Encode(&output, decoded); err != nil { return nil, "", false } return output.Bytes(), "image/png", true } if err := jpeg.Encode(&output, decoded, &jpeg.Options{Quality: 92}); err != nil { return nil, "", false } return output.Bytes(), "image/jpeg", true } func (h *AssetHandler) signObjectKey(objectKey string) string { encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey)) mac := hmac.New(sha256.New, []byte(h.secret())) _, _ = mac.Write([]byte(objectKey)) signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return fmt.Sprintf("%s.%s", encodedKey, signature) } func (h *AssetHandler) secret() string { if h == nil || h.app == nil || h.app.Config() == nil { return "" } return strings.TrimSpace(h.app.Config().JWT.Secret) } func (h *AssetHandler) parseToken(token string) (string, bool) { parts := strings.Split(token, ".") if len(parts) != 2 { return "", false } objectKeyBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", false } objectKey := string(objectKeyBytes) if strings.TrimSpace(objectKey) == "" { return "", false } expected := h.signObjectKey(objectKey) if !hmac.Equal([]byte(expected), []byte(token)) { return "", false } return objectKey, true } func objectKeyFromAssetToken(token string) (string, bool) { parts := strings.Split(token, ".") if len(parts) != 2 { return "", false } objectKeyBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", false } objectKey := strings.TrimSpace(string(objectKeyBytes)) if objectKey == "" { return "", false } return objectKey, true } func isDesktopClientAssetKey(objectKey string, tenantID int64) bool { if tenantID <= 0 { return false } tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/" return strings.HasPrefix(objectKey, tenantPrefix+"images/") || strings.HasPrefix(objectKey, tenantPrefix+"articles/") }