package app import ( "bytes" "context" "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" "image/png" "net/http" "net/url" "path/filepath" "strconv" "strings" "github.com/jackc/pgx/v5" _ "golang.org/x/image/webp" nethtml "golang.org/x/net/html" "golang.org/x/net/html/atom" "github.com/geo-platform/tenant-api/internal/shared/publicasset" ) var errMediaSupplyArticleImageUpload = errors.New("media supply article image upload failed") type mediaSupplyArticleImageUpload struct { URL string Filename string ContentType string Content []byte } func (s *MediaSupplyService) prepareMeijiequanArticleContent(ctx context.Context, tenantID int64, content string) (string, error) { html := meijiequanArticleHTML(content) if strings.TrimSpace(html) == "" { return "", nil } return s.uploadMeijiequanArticleImages(ctx, tenantID, html) } func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context, tenantID int64, fragment string) (string, error) { if !strings.Contains(strings.ToLower(fragment), " 0 { cacheKey = "asset:" + strconv.FormatInt(assetID, 10) } meijiequanURL := cache[cacheKey] if meijiequanURL == "" { if s == nil || s.client == nil { return errMediaSupplyArticleImageUpload } resp, err := s.client.UploadEditorImage(ctx, MeijiequanUploadImageRequest{ Filename: upload.Filename, ContentType: upload.ContentType, Content: upload.Content, }) if err != nil { return fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err) } meijiequanURL = strings.TrimSpace(resp.URL) if meijiequanURL == "" { return errMediaSupplyArticleImageUpload } cache[cacheKey] = meijiequanURL } attrs := make([]nethtml.Attribute, 0, len(node.Attr)+2) hasSrc := false hasOriginalSrc := false for _, attr := range node.Attr { key := strings.ToLower(strings.TrimSpace(attr.Key)) switch key { case "src": attr.Val = meijiequanURL hasSrc = true case "_src": attr.Val = meijiequanURL hasOriginalSrc = true case "data-src": attr.Val = meijiequanURL case "data-asset-id": continue } attrs = append(attrs, attr) } if !hasSrc { attrs = append(attrs, nethtml.Attribute{Key: "src", Val: meijiequanURL}) } if !hasOriginalSrc { attrs = append(attrs, nethtml.Attribute{Key: "_src", Val: meijiequanURL}) } node.Attr = attrs return nil } func (s *MediaSupplyService) resolveMeijiequanArticleImageUpload(ctx context.Context, tenantID int64, imageURL string, assetID int64) (mediaSupplyArticleImageUpload, bool, error) { objectKey, internalAssetURL, ok := s.mediaSupplyObjectKeyFromPublicAssetURL(imageURL, tenantID) if internalAssetURL { if !ok { if assetID <= 0 { return mediaSupplyArticleImageUpload{}, false, errMediaSupplyArticleImageUpload } } else { upload, err := s.meijiequanUploadFromObjectKey(ctx, objectKey, imageURL) return upload, err == nil, err } } if assetID > 0 { objectKey, err := s.loadMediaSupplyImageAssetObjectKey(ctx, tenantID, assetID) if err != nil { return mediaSupplyArticleImageUpload{}, false, err } upload, err := s.meijiequanUploadFromObjectKey(ctx, objectKey, imageURL) return upload, err == nil, err } return mediaSupplyArticleImageUpload{}, false, nil } func (s *MediaSupplyService) meijiequanUploadFromObjectKey(ctx context.Context, objectKey, sourceURL string) (mediaSupplyArticleImageUpload, error) { if s == nil || s.objectStorage == nil { return mediaSupplyArticleImageUpload{}, errMediaSupplyArticleImageUpload } content, err := s.objectStorage.GetBytes(ctx, objectKey) if err != nil || len(content) == 0 { if err == nil { err = errMediaSupplyArticleImageUpload } return mediaSupplyArticleImageUpload{}, fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err) } uploadContent, contentType, ext, err := normalizeMeijiequanUploadImage(content, objectKey, sourceURL) if err != nil { return mediaSupplyArticleImageUpload{}, err } filename := mediaSupplyUploadFilename(objectKey, sourceURL, ext) return mediaSupplyArticleImageUpload{ URL: sourceURL, Filename: filename, ContentType: contentType, Content: uploadContent, }, nil } func normalizeMeijiequanUploadImage(content []byte, objectKey, sourceURL string) ([]byte, string, string, error) { contentType := strings.ToLower(strings.TrimSpace(http.DetectContentType(content))) ext := strings.ToLower(filepath.Ext(firstNonEmptyText(objectKey, sourceURL))) if mediaSupplyMeijiequanImageExtAllowed(ext) && mediaSupplyMeijiequanImageContentTypeAllowed(contentType) { return content, contentType, ext, nil } decoded, _, err := image.Decode(bytes.NewReader(content)) if err != nil { return nil, "", "", fmt.Errorf("%w: unsupported image", errMediaSupplyArticleImageUpload) } var output bytes.Buffer if err := png.Encode(&output, decoded); err != nil { return nil, "", "", fmt.Errorf("%w: encode png: %v", errMediaSupplyArticleImageUpload, err) } return output.Bytes(), "image/png", ".png", nil } func mediaSupplyMeijiequanImageExtAllowed(ext string) bool { switch strings.ToLower(strings.TrimSpace(ext)) { case ".png", ".jpg", ".jpeg", ".gif", ".bmp": return true default: return false } } func mediaSupplyMeijiequanImageContentTypeAllowed(contentType string) bool { switch strings.ToLower(strings.TrimSpace(contentType)) { case "image/png", "image/jpeg", "image/gif", "image/bmp", "image/x-ms-bmp": return true default: return false } } func mediaSupplyUploadFilename(objectKey, sourceURL, ext string) string { name := strings.TrimSpace(filepath.Base(strings.TrimRight(firstNonEmptyText(objectKey, sourceURL), "/"))) if name == "" || name == "." || strings.Contains(name, "?") || strings.Contains(name, "#") { name = strings.TrimSpace(filepath.Base(strings.TrimRight(objectKey, "/"))) } if name == "" || name == "." { name = "image" } currentExt := filepath.Ext(name) if strings.TrimSpace(ext) == "" { ext = ".png" } if currentExt == "" || !strings.EqualFold(currentExt, ext) { name = strings.TrimSuffix(name, currentExt) + ext } return name } func (s *MediaSupplyService) mediaSupplyObjectKeyFromPublicAssetURL(raw string, tenantID int64) (string, bool, bool) { token, ok := mediaSupplyPublicAssetTokenFromURL(raw) if !ok { return "", false, false } objectKey, valid := publicasset.ParseObjectKeyToken(token, s.mediaSupplyAssetTokenSecret()) if !valid || !mediaSupplyObjectKeyAllowedForTenant(objectKey, tenantID) { return "", true, false } return objectKey, true, true } func (s *MediaSupplyService) mediaSupplyAssetTokenSecret() string { if s == nil || s.cfg == nil || s.cfg.Current() == nil { return "" } return strings.TrimSpace(s.cfg.Current().JWT.Secret) } func mediaSupplyPublicAssetTokenFromURL(raw string) (string, bool) { raw = strings.TrimSpace(raw) if raw == "" { return "", false } if strings.HasPrefix(raw, "/api/public/assets/") { return mediaSupplyAssetTokenFromPath(raw), true } parsed, err := url.Parse(raw) if err != nil { return "", false } if strings.HasPrefix(parsed.Path, "/api/public/assets/") { return mediaSupplyAssetTokenFromPath(parsed.Path), true } return "", false } func mediaSupplyAssetTokenFromPath(pathValue string) string { token := strings.TrimPrefix(strings.TrimSpace(pathValue), "/api/public/assets/") if slash := strings.Index(token, "/"); slash >= 0 { token = token[:slash] } if idx := strings.IndexAny(token, "?#"); idx >= 0 { token = token[:idx] } token, _ = url.PathUnescape(token) return strings.TrimSpace(token) } func mediaSupplyObjectKeyAllowedForTenant(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/") } func (s *MediaSupplyService) loadMediaSupplyImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, error) { if s == nil || s.pool == nil { return "", errMediaSupplyArticleImageUpload } var objectKey string if err := s.pool.QueryRow(ctx, ` SELECT object_key FROM image_assets WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL `, assetID, tenantID).Scan(&objectKey); err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", errMediaSupplyArticleImageUpload } return "", fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err) } if !mediaSupplyObjectKeyAllowedForTenant(objectKey, tenantID) { return "", errMediaSupplyArticleImageUpload } return objectKey, nil } func mediaSupplyImageURLRequiresUpload(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" || strings.HasPrefix(strings.ToLower(raw), "data:") { return false } if _, ok := mediaSupplyPublicAssetTokenFromURL(raw); ok { return true } parsed, err := url.Parse(raw) if err != nil { return true } if parsed.Scheme == "" && parsed.Host == "" { return strings.HasPrefix(raw, "/") || !strings.Contains(raw, "://") } host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) return host == "localhost" || host == "127.0.0.1" || host == "::1" } func mediaSupplyHTMLAttrValue(attrs []nethtml.Attribute, key string) string { for _, attr := range attrs { if strings.EqualFold(strings.TrimSpace(attr.Key), key) { return strings.TrimSpace(attr.Val) } } return "" } func mediaSupplyHTMLInt64AttrValue(attrs []nethtml.Attribute, key string) int64 { value := mediaSupplyHTMLAttrValue(attrs, key) if value == "" { return 0 } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed <= 0 { return 0 } return parsed }