fix: add image asset ID extraction and filtering functions for markdown content
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
_ "image/png"
|
_ "image/png"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,8 +21,8 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"golang.org/x/sync/singleflight"
|
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
@@ -59,6 +60,9 @@ var supportedArticleImageExtensions = map[string]string{
|
|||||||
"image/webp": "webp",
|
"image/webp": "webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var articleMarkdownImageAssetIDPattern = regexp.MustCompile(`data-asset-id=(?:"|')(\d+)(?:"|')`)
|
||||||
|
var articleMarkdownImageAssetAttributePattern = regexp.MustCompile(`\s*data-asset-id=(?:"|')(\d+)(?:"|')`)
|
||||||
|
|
||||||
type ArticleListParams struct {
|
type ArticleListParams struct {
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
@@ -354,6 +358,29 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
|||||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||||
|
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
|
||||||
|
markdownValue := ""
|
||||||
|
if item.MarkdownContent != nil {
|
||||||
|
markdownValue = *item.MarkdownContent
|
||||||
|
}
|
||||||
|
sanitizedMarkdown, _, sanitizedCoverImageAssetID, err := s.resolveArticleImageMetadata(
|
||||||
|
ctx,
|
||||||
|
s.pool,
|
||||||
|
tenantID,
|
||||||
|
markdownValue,
|
||||||
|
item.CoverImageAssetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, response.ErrInternal(50012, "query_failed", "failed to resolve article image metadata")
|
||||||
|
}
|
||||||
|
if item.MarkdownContent != nil {
|
||||||
|
item.MarkdownContent = &sanitizedMarkdown
|
||||||
|
}
|
||||||
|
item.CoverImageAssetID = sanitizedCoverImageAssetID
|
||||||
|
if item.WizardState != nil && sanitizedCoverImageAssetID == nil {
|
||||||
|
delete(item.WizardState, "cover_image_asset_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
||||||
return &item, true, nil
|
return &item, true, nil
|
||||||
}
|
}
|
||||||
@@ -437,10 +464,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
}
|
}
|
||||||
|
|
||||||
markdown := strings.TrimSpace(req.MarkdownContent)
|
markdown := strings.TrimSpace(req.MarkdownContent)
|
||||||
referencedImageAssetIDs, err := normalizeImageAssetIDs(req.ReferencedImageAssetIDs)
|
var referencedImageAssetIDs []int64
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID)
|
coverImageAssetID, err := normalizeOptionalImageAssetID(req.CoverImageAssetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -468,8 +492,14 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
|
||||||
}
|
}
|
||||||
|
|
||||||
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
|
markdown, referencedImageAssetIDs, coverImageAssetID, err = s.resolveArticleImageMetadata(
|
||||||
if err := s.ensureActiveImageAssets(ctx, tx, actor.TenantID, imageAssetIDsToValidate); err != nil {
|
ctx,
|
||||||
|
tx,
|
||||||
|
actor.TenantID,
|
||||||
|
markdown,
|
||||||
|
coverImageAssetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +514,20 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
|
|
||||||
wordCount := countArticleWords(markdown)
|
wordCount := countArticleWords(markdown)
|
||||||
sourceLabel := "手动编辑"
|
sourceLabel := "手动编辑"
|
||||||
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL, req.CoverImageAssetID)
|
resolvedCoverImageAssetInput := req.CoverImageAssetID
|
||||||
|
if req.CoverImageAssetID.Set {
|
||||||
|
resolvedCoverImageAssetInput = NullableInt64Input{
|
||||||
|
Set: true,
|
||||||
|
Value: coverImageAssetID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextWizardStateJSON, err := mergeArticleWizardState(
|
||||||
|
wizardStateJSON,
|
||||||
|
title,
|
||||||
|
req.Platforms,
|
||||||
|
req.CoverAssetURL,
|
||||||
|
resolvedCoverImageAssetInput,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
||||||
}
|
}
|
||||||
@@ -520,15 +563,13 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
}
|
}
|
||||||
|
|
||||||
imageRefRepo := repository.NewImageReferenceRepository(tx)
|
imageRefRepo := repository.NewImageReferenceRepository(tx)
|
||||||
if req.ReferencedImageAssetIDs != nil {
|
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil {
|
||||||
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_body"); err != nil {
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
||||||
|
}
|
||||||
|
for _, imageAssetID := range referencedImageAssetIDs {
|
||||||
|
if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil {
|
||||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
||||||
}
|
}
|
||||||
for _, imageAssetID := range referencedImageAssetIDs {
|
|
||||||
if err := imageRefRepo.Upsert(ctx, actor.TenantID, imageAssetID, id, "article_body"); err != nil {
|
|
||||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article image references")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if req.CoverImageAssetID.Set {
|
if req.CoverImageAssetID.Set {
|
||||||
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_cover"); err != nil {
|
if err := imageRefRepo.DeleteByArticleScope(ctx, actor.TenantID, id, "article_cover"); err != nil {
|
||||||
@@ -721,6 +762,10 @@ func countArticleWords(input string) int {
|
|||||||
return contentstats.CountWords(input)
|
return contentstats.CountWords(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type imageAssetLookupQueryer interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeImageAssetIDs(ids []int64) ([]int64, error) {
|
func normalizeImageAssetIDs(ids []int64) ([]int64, error) {
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -777,12 +822,109 @@ func collectImageAssetIDsForValidation(bodyImageIDs []int64, coverImageAssetID *
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ArticleService) ensureActiveImageAssets(ctx context.Context, tx pgx.Tx, tenantID int64, imageAssetIDs []int64) error {
|
func extractReferencedImageAssetIDsFromMarkdown(markdown string) []int64 {
|
||||||
if len(imageAssetIDs) == 0 {
|
if strings.TrimSpace(markdown) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.Query(ctx, `
|
matches := articleMarkdownImageAssetIDPattern.FindAllStringSubmatch(markdown, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
id, err := strconv.ParseInt(match[1], 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeImageAssetIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterActiveImageAssetIDs(ids []int64, activeIDs map[int64]struct{}) []int64 {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]int64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, ok := activeIDs[id]; ok {
|
||||||
|
filtered = append(filtered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripInactiveImageAssetIDsFromMarkdown(markdown string, activeIDs map[int64]struct{}) string {
|
||||||
|
if markdown == "" {
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
return articleMarkdownImageAssetAttributePattern.ReplaceAllStringFunc(markdown, func(match string) string {
|
||||||
|
parts := articleMarkdownImageAssetAttributePattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, ok := activeIDs[id]; ok {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) resolveArticleImageMetadata(
|
||||||
|
ctx context.Context,
|
||||||
|
q imageAssetLookupQueryer,
|
||||||
|
tenantID int64,
|
||||||
|
markdown string,
|
||||||
|
coverImageAssetID *int64,
|
||||||
|
) (string, []int64, *int64, error) {
|
||||||
|
referencedImageAssetIDs := extractReferencedImageAssetIDsFromMarkdown(markdown)
|
||||||
|
imageAssetIDsToValidate := collectImageAssetIDsForValidation(referencedImageAssetIDs, coverImageAssetID)
|
||||||
|
activeImageAssetIDs, err := s.loadActiveImageAssetIDSet(ctx, q, tenantID, imageAssetIDsToValidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedCoverImageAssetID := coverImageAssetID
|
||||||
|
if coverImageAssetID != nil {
|
||||||
|
if _, ok := activeImageAssetIDs[*coverImageAssetID]; !ok {
|
||||||
|
resolvedCoverImageAssetID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripInactiveImageAssetIDsFromMarkdown(markdown, activeImageAssetIDs),
|
||||||
|
filterActiveImageAssetIDs(referencedImageAssetIDs, activeImageAssetIDs),
|
||||||
|
resolvedCoverImageAssetID,
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) loadActiveImageAssetIDSet(
|
||||||
|
ctx context.Context,
|
||||||
|
q imageAssetLookupQueryer,
|
||||||
|
tenantID int64,
|
||||||
|
imageAssetIDs []int64,
|
||||||
|
) (map[int64]struct{}, error) {
|
||||||
|
if len(imageAssetIDs) == 0 {
|
||||||
|
return map[int64]struct{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.Query(ctx, `
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM image_assets
|
FROM image_assets
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
@@ -791,7 +933,7 @@ WHERE tenant_id = $1
|
|||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
`, tenantID, imageAssetIDs)
|
`, tenantID, imageAssetIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -799,17 +941,14 @@ WHERE tenant_id = $1
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
if err := rows.Scan(&id); err != nil {
|
if err := rows.Scan(&id); err != nil {
|
||||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||||
}
|
}
|
||||||
validIDs[id] = struct{}{}
|
validIDs[id] = struct{}{}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to validate image assets")
|
||||||
}
|
}
|
||||||
if len(validIDs) != len(imageAssetIDs) {
|
return validIDs, nil
|
||||||
return response.ErrBadRequest(40021, "invalid_image_asset_ids", "one or more image assets are invalid or inactive")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
|
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractReferencedImageAssetIDsFromMarkdown_DeduplicatesInOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
markdown := strings.Join([]string{
|
||||||
|
`before`,
|
||||||
|
`<p><img src="https://example.com/1.webp" data-asset-id="42" /></p>`,
|
||||||
|
`<p><img src="https://example.com/2.webp" data-asset-id='7' /></p>`,
|
||||||
|
`<p><img src="https://example.com/3.webp" data-asset-id="42" /></p>`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got := extractReferencedImageAssetIDsFromMarkdown(markdown)
|
||||||
|
want := []int64{42, 7}
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("expected %d ids, got %d (%v)", len(want), len(got), got)
|
||||||
|
}
|
||||||
|
for idx := range want {
|
||||||
|
if got[idx] != want[idx] {
|
||||||
|
t.Fatalf("expected ids %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripInactiveImageAssetIDsFromMarkdown_RemovesOnlyInactiveAttrs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
markdown := strings.Join([]string{
|
||||||
|
`<p class="article-editor-image"><img src="https://example.com/active.webp" data-asset-id="12" /></p>`,
|
||||||
|
`<p class="article-editor-image"><img src="https://example.com/inactive.webp" data-asset-id="34" /></p>`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got := stripInactiveImageAssetIDsFromMarkdown(markdown, map[int64]struct{}{
|
||||||
|
12: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(got, `data-asset-id="12"`) {
|
||||||
|
t.Fatalf("expected active asset id to remain, got %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `data-asset-id="34"`) {
|
||||||
|
t.Fatalf("expected inactive asset id to be removed, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `inactive.webp`) {
|
||||||
|
t.Fatalf("expected image markup to remain after stripping asset id, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripInactiveImageAssetIDsFromMarkdown_RemovesAllAttrsWhenNoAssetsAreActive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
markdown := `<p><img src="https://example.com/cover.webp" data-asset-id="88" /></p>`
|
||||||
|
|
||||||
|
got := stripInactiveImageAssetIDsFromMarkdown(markdown, map[int64]struct{}{})
|
||||||
|
|
||||||
|
if strings.Contains(got, `data-asset-id="88"`) {
|
||||||
|
t.Fatalf("expected inactive asset id to be removed, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `cover.webp`) {
|
||||||
|
t.Fatalf("expected image src to remain, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user