diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go index 94a8929..f9039c6 100644 --- a/server/cmd/tenant-api/main.go +++ b/server/cmd/tenant-api/main.go @@ -44,7 +44,7 @@ func main() { tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx) mediaSupplyService := app.MediaSupplyService if mediaSupplyService == nil { - mediaSupplyService = tenantapp.NewMediaSupplyService(app.DB, app.Redis, app.Logger, app.ConfigStore) + mediaSupplyService = tenantapp.NewMediaSupplyService(app.DB, app.Redis, app.Logger, app.ConfigStore).WithObjectStorage(app.ObjectStorage) } tenantapp.NewMediaSupplyWorker(mediaSupplyService, app.DB, app.Logger).Start(workerCtx) knowledgeCleanupService := tenantapp.NewKnowledgeService( diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index 1cbfd22..def4825 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -169,7 +169,7 @@ func New(configPath string) (*App, error) { WithCache(appCache). WithIPRegionResolver(ipRegions) monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb) - mediaSupplyService := tenantapp.NewMediaSupplyService(pool, rdb, logger, configStore) + mediaSupplyService := tenantapp.NewMediaSupplyService(pool, rdb, logger, configStore).WithObjectStorage(objectStorageClient) kolProfiles := repository.NewKolProfileRepository(pool) kolPackages := repository.NewKolPackageRepository(pool) kolMarketplace := repository.NewKolMarketplaceRepository(pool) diff --git a/server/internal/shared/publicasset/token.go b/server/internal/shared/publicasset/token.go new file mode 100644 index 0000000..86a9fa5 --- /dev/null +++ b/server/internal/shared/publicasset/token.go @@ -0,0 +1,45 @@ +package publicasset + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" +) + +func SignObjectKey(objectKey string, secret string) string { + encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey)) + mac := hmac.New(sha256.New, []byte(strings.TrimSpace(secret))) + _, _ = mac.Write([]byte(objectKey)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return fmt.Sprintf("%s.%s", encodedKey, signature) +} + +func ParseObjectKeyToken(token string, secret string) (string, bool) { + objectKey, ok := ObjectKeyFromToken(token) + if !ok { + return "", false + } + expected := SignObjectKey(objectKey, secret) + if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(token))) { + return "", false + } + return objectKey, true +} + +func ObjectKeyFromToken(token string) (string, bool) { + parts := strings.Split(strings.TrimSpace(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 +} diff --git a/server/internal/tenant/app/media_supply_images.go b/server/internal/tenant/app/media_supply_images.go new file mode 100644 index 0000000..fda88bf --- /dev/null +++ b/server/internal/tenant/app/media_supply_images.go @@ -0,0 +1,379 @@ +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 +} diff --git a/server/internal/tenant/app/media_supply_service.go b/server/internal/tenant/app/media_supply_service.go index 2f33c10..0e241c9 100644 --- a/server/internal/tenant/app/media_supply_service.go +++ b/server/internal/tenant/app/media_supply_service.go @@ -17,15 +17,17 @@ import ( "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" ) type MediaSupplyService struct { - pool *pgxpool.Pool - redis *goredis.Client - logger *zap.Logger - cfg config.Provider - client *MeijiequanClient + pool *pgxpool.Pool + redis *goredis.Client + logger *zap.Logger + cfg config.Provider + client *MeijiequanClient + objectStorage objectstorage.Client } func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService { @@ -43,6 +45,13 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za } } +func (s *MediaSupplyService) WithObjectStorage(storage objectstorage.Client) *MediaSupplyService { + if s != nil { + s.objectStorage = storage + } + return s +} + func (s *MediaSupplyService) mediaSupplyConfig() config.MediaSupplyConfig { if s != nil && s.cfg != nil && s.cfg.Current() != nil { cfg := s.cfg.Current().MediaSupply diff --git a/server/internal/tenant/app/media_supply_test.go b/server/internal/tenant/app/media_supply_test.go index df1a0bd..853c312 100644 --- a/server/internal/tenant/app/media_supply_test.go +++ b/server/internal/tenant/app/media_supply_test.go @@ -1,8 +1,16 @@ package app import ( + "bytes" "context" + "encoding/json" "errors" + "image" + "image/color" + "image/png" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -11,12 +19,39 @@ import ( goredis "github.com/redis/go-redis/v9" "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/shared/publicasset" ) type mediaSupplyOrderScanStub struct { values []any } +type mediaSupplyImageObjectStorage struct { + content []byte + gotKey string +} + +func (s *mediaSupplyImageObjectStorage) Validate() error { return nil } + +func (s *mediaSupplyImageObjectStorage) PutBytes(context.Context, string, []byte, string) error { + return nil +} + +func (s *mediaSupplyImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) { + s.gotKey = objectKey + return s.content, nil +} + +func (s *mediaSupplyImageObjectStorage) Exists(context.Context, string) (bool, error) { + return true, nil +} + +func (s *mediaSupplyImageObjectStorage) Delete(context.Context, string) error { return nil } + +func (s *mediaSupplyImageObjectStorage) PublicURL(string) (string, error) { return "", nil } + +func (s *mediaSupplyImageObjectStorage) DownloadURL(string) (string, error) { return "", nil } + func (s mediaSupplyOrderScanStub) Scan(dest ...any) error { for i, value := range s.values { switch target := dest[i].(type) { @@ -41,6 +76,66 @@ func (s mediaSupplyOrderScanStub) Scan(dest ...any) error { return nil } +func testMediaSupplyPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + img.Set(1, 0, color.RGBA{G: 255, A: 255}) + img.Set(0, 1, color.RGBA{B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 255, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func testMeijiequanClientWithUploadServer(t *testing.T, handler http.HandlerFunc) (*MeijiequanClient, func()) { + t.Helper() + server := httptest.NewServer(handler) + redisServer, err := miniredis.Run() + if err != nil { + server.Close() + t.Fatalf("miniredis: %v", err) + } + rdb := goredis.NewClient(&goredis.Options{Addr: redisServer.Addr()}) + now := time.Now().UTC() + expiresAt := now.Add(time.Hour) + session := meijiequanStoredSession{ + Cookies: []meijiequanStoredCookie{{ + Name: "laravel_session", + Value: "test-session", + Domain: strings.TrimPrefix(server.URL, "http://"), + Path: "/", + }}, + CSRFToken: "csrf-token", + ExpiresAt: &expiresAt, + UpdatedAt: now, + } + raw, err := json.Marshal(session) + if err != nil { + server.Close() + redisServer.Close() + t.Fatalf("marshal session: %v", err) + } + if err := rdb.Set(context.Background(), meijiequanSessionKey, raw, time.Hour).Err(); err != nil { + server.Close() + redisServer.Close() + t.Fatalf("set session: %v", err) + } + client := NewMeijiequanClient(rdb, config.MeijiequanConfig{ + BaseURL: server.URL, + RequestTimeout: 3 * time.Second, + UpstreamMinInterval: time.Millisecond, + }) + cleanup := func() { + _ = rdb.Close() + redisServer.Close() + server.Close() + } + return client, cleanup +} + func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) { got := mediaSupplySellPrice(1000, 900, true, 0, 0) if got != 1000 { @@ -340,6 +435,127 @@ func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) { } } +func TestMeijiequanUploadEditorImageUsesUEditorEndpoint(t *testing.T) { + var gotPath string + var gotAction string + var gotCategory string + var gotFieldContent []byte + var gotFilename string + var gotContentType string + client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAction = r.URL.Query().Get("action") + gotCategory = r.URL.Query().Get("category") + reader, err := r.MultipartReader() + if err != nil { + t.Fatalf("multipart reader: %v", err) + } + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("multipart part: %v", err) + } + if part.FormName() != "upfile" { + continue + } + gotFilename = part.FileName() + gotContentType = part.Header.Get("Content-Type") + gotFieldContent, err = io.ReadAll(part) + if err != nil { + t.Fatalf("read part: %v", err) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png","type":".png","size":12}`)) + }) + defer cleanup() + + resp, err := client.UploadEditorImage(context.Background(), MeijiequanUploadImageRequest{ + Filename: "body.png", + ContentType: "image/png", + Content: []byte("image-bytes"), + }) + if err != nil { + t.Fatalf("upload image: %v", err) + } + if resp.URL != "http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png" { + t.Fatalf("unexpected uploaded url: %#v", resp) + } + if gotPath != "/vendor/ueditor/php/controller.php" || gotAction != "uploadimage" || gotCategory != "yhdoc" { + t.Fatalf("unexpected endpoint path=%s action=%s category=%s", gotPath, gotAction, gotCategory) + } + if gotFilename != "body.png" || gotContentType != "image/png" || string(gotFieldContent) != "image-bytes" { + t.Fatalf("unexpected multipart field filename=%s contentType=%s content=%q", gotFilename, gotContentType, gotFieldContent) + } +} + +func TestPrepareMeijiequanArticleContentUploadsSaaSImages(t *testing.T) { + objectKey := "tenants/4/images/body.webp" + token := publicasset.SignObjectKey(objectKey, "test-secret") + storage := &mediaSupplyImageObjectStorage{content: testMediaSupplyPNG(t)} + var uploadedBytes []byte + var uploadedFilename string + client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) { + reader, err := r.MultipartReader() + if err != nil { + t.Fatalf("multipart reader: %v", err) + } + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("multipart part: %v", err) + } + if part.FormName() != "upfile" { + continue + } + uploadedFilename = part.FileName() + uploadedBytes, err = io.ReadAll(part) + if err != nil { + t.Fatalf("read part: %v", err) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png","type":".png","size":99}`)) + }) + defer cleanup() + + svc := (&MediaSupplyService{ + client: client, + objectStorage: storage, + cfg: config.NewStaticProvider(&config.Config{ + JWT: config.JWTConfig{Secret: "test-secret"}, + }), + }).WithObjectStorage(storage) + content := `

` + + got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, content) + if err != nil { + t.Fatalf("prepare content: %v", err) + } + if storage.gotKey != objectKey { + t.Fatalf("expected object key %s, got %s", objectKey, storage.gotKey) + } + if uploadedFilename != "body.png" { + t.Fatalf("expected webp to upload as png filename, got %s", uploadedFilename) + } + if len(uploadedBytes) == 0 { + t.Fatal("expected uploaded bytes") + } + if strings.Contains(got, "/api/public/assets/") || strings.Contains(got, "data-asset-id") { + t.Fatalf("expected SaaS image attrs to be removed, got %s", got) + } + if !strings.Contains(got, `src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) || + !strings.Contains(got, `_src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) { + t.Fatalf("expected meijiequan image url in src and _src, got %s", got) + } +} + func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) { extra := mediaSupplySubmitOrderExtra([]byte(`{ "title": "ignored", diff --git a/server/internal/tenant/app/media_supply_worker.go b/server/internal/tenant/app/media_supply_worker.go index bd58fae..b0725d5 100644 --- a/server/internal/tenant/app/media_supply_worker.go +++ b/server/internal/tenant/app/media_supply_worker.go @@ -164,11 +164,15 @@ func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error { if err := w.refreshOrderCostsBeforeSubmit(ctx, order, items); err != nil { return w.failOrder(ctx, order, err) } + content, err := w.service.prepareMeijiequanArticleContent(ctx, order.TenantID, order.ContentSnapshot) + if err != nil { + return w.failOrder(ctx, order, err) + } req := MeijiequanCreateOrderRequest{ UID: userID, ModelID: order.ModelID, Title: order.Title, - Content: order.ContentSnapshot, + Content: content, Remark: mediaSupplyStringPtrValue(order.Remark), OrderBrand: mediaSupplyStringPtrValue(order.OrderBrand), ResourceIDArr: make([]string, 0, len(items)), diff --git a/server/internal/tenant/app/meijiequan_client.go b/server/internal/tenant/app/meijiequan_client.go index 8f9093e..d556c2b 100644 --- a/server/internal/tenant/app/meijiequan_client.go +++ b/server/internal/tenant/app/meijiequan_client.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "mime" + "mime/multipart" "net/http" "net/http/cookiejar" + "net/textproto" "net/url" "regexp" "strconv" @@ -215,6 +217,22 @@ type meijiequanLoginResponse struct { Raw json.RawMessage `json:"-"` } +type MeijiequanUploadImageRequest struct { + Filename string + ContentType string + Content []byte +} + +type MeijiequanUploadImageResponse struct { + State string `json:"state"` + URL string `json:"url"` + Title string `json:"title"` + Original string `json:"original"` + Type string `json:"type"` + Size int64 `json:"size"` + Raw json.RawMessage `json:"-"` +} + func NewMeijiequanClient(redis *goredis.Client, cfg config.MeijiequanConfig) *MeijiequanClient { client := &MeijiequanClient{ redis: redis, @@ -795,6 +813,87 @@ func (c *MeijiequanClient) CreateOrder(ctx context.Context, req MeijiequanCreate return result, nil } +func (c *MeijiequanClient) UploadEditorImage(ctx context.Context, req MeijiequanUploadImageRequest) (*MeijiequanUploadImageResponse, error) { + content := req.Content + if len(content) == 0 { + return nil, fmt.Errorf("meijiequan image content is empty") + } + var result *MeijiequanUploadImageResponse + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + cfg := c.config() + client, csrf, err := c.httpClient(lockCtx) + if err != nil { + return err + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + filename := strings.TrimSpace(req.Filename) + if filename == "" { + filename = "image.png" + } + partHeaders := make(textproto.MIMEHeader) + partHeaders.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{ + "name": "upfile", + "filename": filename, + })) + if contentType := strings.TrimSpace(req.ContentType); contentType != "" { + partHeaders.Set("Content-Type", contentType) + } + part, err := writer.CreatePart(partHeaders) + if err != nil { + return err + } + if _, err := part.Write(content); err != nil { + return err + } + if err := writer.Close(); err != nil { + return err + } + + httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodPost, cfg.BaseURL+"/vendor/ueditor/php/controller.php?action=uploadimage&category=yhdoc", &body) + if err != nil { + return err + } + c.applyHeaders(httpReq, csrf) + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + httpReq.Header.Set("Referer", cfg.BaseURL+"/advSupply/article/fillContent?is_clear=1") + resp, err := client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if isMeijiequanAuthResponse(resp) { + return errMeijiequanAuthRequired + } + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("meijiequan upload image http %d: %s", resp.StatusCode, string(responseBody)) + } + var parsed MeijiequanUploadImageResponse + if err := json.Unmarshal(responseBody, &parsed); err != nil { + return fmt.Errorf("meijiequan upload image decode: %w", err) + } + parsed.Raw = append(parsed.Raw[:0], responseBody...) + if !strings.EqualFold(strings.TrimSpace(parsed.State), "SUCCESS") || strings.TrimSpace(parsed.URL) == "" { + message := strings.TrimSpace(parsed.State) + if message == "" { + message = "empty image url" + } + return fmt.Errorf("meijiequan upload image failed: %s", message) + } + parsed.URL = strings.TrimSpace(parsed.URL) + result = &parsed + return nil + }); err != nil { + return nil, err + } + return result, nil +} + func (c *MeijiequanClient) ListPublishedArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { return c.listArticles(ctx, userID, "2", page, pageSize) } diff --git a/server/internal/tenant/transport/asset_handler.go b/server/internal/tenant/transport/asset_handler.go index 97e652d..99f4504 100644 --- a/server/internal/tenant/transport/asset_handler.go +++ b/server/internal/tenant/transport/asset_handler.go @@ -2,10 +2,6 @@ package transport import ( "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "fmt" "image" "image/jpeg" "image/png" @@ -17,6 +13,7 @@ import ( _ "golang.org/x/image/webp" "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/publicasset" ) type AssetHandler struct { @@ -104,11 +101,7 @@ func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bo } 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) + return publicasset.SignObjectKey(objectKey, h.secret()) } func (h *AssetHandler) secret() string { @@ -119,43 +112,11 @@ func (h *AssetHandler) secret() string { } 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 + return publicasset.ParseObjectKeyToken(token, h.secret()) } 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 + return publicasset.ObjectKeyFromToken(token) } func isDesktopClientAssetKey(objectKey string, tenantID int64) bool { diff --git a/server/internal/tenant/transport/media_supply_handler.go b/server/internal/tenant/transport/media_supply_handler.go index 7b2412e..c23f16b 100644 --- a/server/internal/tenant/transport/media_supply_handler.go +++ b/server/internal/tenant/transport/media_supply_handler.go @@ -19,7 +19,7 @@ type MediaSupplyHandler struct { func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler { svc := a.MediaSupplyService if svc == nil { - svc = app.NewMediaSupplyService(a.DB, a.Redis, a.Logger, a.ConfigStore) + svc = app.NewMediaSupplyService(a.DB, a.Redis, a.Logger, a.ConfigStore).WithObjectStorage(a.ObjectStorage) } return &MediaSupplyHandler{ svc: svc,