feat(media-supply): integrate object storage for media supply service and enhance image upload handling
Backend CI / Backend (push) Successful in 16m49s
Backend CI / Backend (push) Successful in 16m49s
This commit is contained in:
@@ -44,7 +44,7 @@ func main() {
|
|||||||
tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx)
|
tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx)
|
||||||
mediaSupplyService := app.MediaSupplyService
|
mediaSupplyService := app.MediaSupplyService
|
||||||
if mediaSupplyService == nil {
|
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)
|
tenantapp.NewMediaSupplyWorker(mediaSupplyService, app.DB, app.Logger).Start(workerCtx)
|
||||||
knowledgeCleanupService := tenantapp.NewKnowledgeService(
|
knowledgeCleanupService := tenantapp.NewKnowledgeService(
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func New(configPath string) (*App, error) {
|
|||||||
WithCache(appCache).
|
WithCache(appCache).
|
||||||
WithIPRegionResolver(ipRegions)
|
WithIPRegionResolver(ipRegions)
|
||||||
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
|
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)
|
kolProfiles := repository.NewKolProfileRepository(pool)
|
||||||
kolPackages := repository.NewKolPackageRepository(pool)
|
kolPackages := repository.NewKolPackageRepository(pool)
|
||||||
kolMarketplace := repository.NewKolMarketplaceRepository(pool)
|
kolMarketplace := repository.NewKolMarketplaceRepository(pool)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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), "<img") {
|
||||||
|
return fragment, nil
|
||||||
|
}
|
||||||
|
contextNode := &nethtml.Node{
|
||||||
|
Type: nethtml.ElementNode,
|
||||||
|
Data: "body",
|
||||||
|
DataAtom: atom.Body,
|
||||||
|
}
|
||||||
|
nodes, err := nethtml.ParseFragment(strings.NewReader(fragment), contextNode)
|
||||||
|
if err != nil {
|
||||||
|
return fragment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := make(map[string]string)
|
||||||
|
for _, node := range nodes {
|
||||||
|
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, node, cache); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, node := range nodes {
|
||||||
|
if err := nethtml.Render(&builder, node); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) uploadMeijiequanArticleImageNode(ctx context.Context, tenantID int64, node *nethtml.Node, cache map[string]string) error {
|
||||||
|
if node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if node.Type == nethtml.ElementNode && strings.EqualFold(node.Data, "img") {
|
||||||
|
if err := s.uploadMeijiequanArticleImageAttrs(ctx, tenantID, node, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
|
if err := s.uploadMeijiequanArticleImageNode(ctx, tenantID, child, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) uploadMeijiequanArticleImageAttrs(ctx context.Context, tenantID int64, node *nethtml.Node, cache map[string]string) error {
|
||||||
|
src := mediaSupplyHTMLAttrValue(node.Attr, "src")
|
||||||
|
originalSrc := mediaSupplyHTMLAttrValue(node.Attr, "_src")
|
||||||
|
dataSrc := mediaSupplyHTMLAttrValue(node.Attr, "data-src")
|
||||||
|
assetID := mediaSupplyHTMLInt64AttrValue(node.Attr, "data-asset-id")
|
||||||
|
currentURL := firstNonEmptyText(src, originalSrc, dataSrc)
|
||||||
|
|
||||||
|
upload, resolved, err := s.resolveMeijiequanArticleImageUpload(ctx, tenantID, currentURL, assetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resolved {
|
||||||
|
if mediaSupplyImageURLRequiresUpload(currentURL) {
|
||||||
|
return errMediaSupplyArticleImageUpload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(upload.URL)
|
||||||
|
if cacheKey == "" && assetID > 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
|
||||||
|
}
|
||||||
@@ -17,15 +17,17 @@ import (
|
|||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"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/config"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaSupplyService struct {
|
type MediaSupplyService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
redis *goredis.Client
|
redis *goredis.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
cfg config.Provider
|
cfg config.Provider
|
||||||
client *MeijiequanClient
|
client *MeijiequanClient
|
||||||
|
objectStorage objectstorage.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService {
|
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 {
|
func (s *MediaSupplyService) mediaSupplyConfig() config.MediaSupplyConfig {
|
||||||
if s != nil && s.cfg != nil && s.cfg.Current() != nil {
|
if s != nil && s.cfg != nil && s.cfg.Current() != nil {
|
||||||
cfg := s.cfg.Current().MediaSupply
|
cfg := s.cfg.Current().MediaSupply
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,12 +19,39 @@ import (
|
|||||||
goredis "github.com/redis/go-redis/v9"
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mediaSupplyOrderScanStub struct {
|
type mediaSupplyOrderScanStub struct {
|
||||||
values []any
|
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 {
|
func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
|
||||||
for i, value := range s.values {
|
for i, value := range s.values {
|
||||||
switch target := dest[i].(type) {
|
switch target := dest[i].(type) {
|
||||||
@@ -41,6 +76,66 @@ func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
|
|||||||
return nil
|
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) {
|
func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) {
|
||||||
got := mediaSupplySellPrice(1000, 900, true, 0, 0)
|
got := mediaSupplySellPrice(1000, 900, true, 0, 0)
|
||||||
if got != 1000 {
|
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 := `<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/` + token + `" alt="" data-asset-id="5" /></p>`
|
||||||
|
|
||||||
|
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) {
|
func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) {
|
||||||
extra := mediaSupplySubmitOrderExtra([]byte(`{
|
extra := mediaSupplySubmitOrderExtra([]byte(`{
|
||||||
"title": "ignored",
|
"title": "ignored",
|
||||||
|
|||||||
@@ -164,11 +164,15 @@ func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
|
|||||||
if err := w.refreshOrderCostsBeforeSubmit(ctx, order, items); err != nil {
|
if err := w.refreshOrderCostsBeforeSubmit(ctx, order, items); err != nil {
|
||||||
return w.failOrder(ctx, order, err)
|
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{
|
req := MeijiequanCreateOrderRequest{
|
||||||
UID: userID,
|
UID: userID,
|
||||||
ModelID: order.ModelID,
|
ModelID: order.ModelID,
|
||||||
Title: order.Title,
|
Title: order.Title,
|
||||||
Content: order.ContentSnapshot,
|
Content: content,
|
||||||
Remark: mediaSupplyStringPtrValue(order.Remark),
|
Remark: mediaSupplyStringPtrValue(order.Remark),
|
||||||
OrderBrand: mediaSupplyStringPtrValue(order.OrderBrand),
|
OrderBrand: mediaSupplyStringPtrValue(order.OrderBrand),
|
||||||
ResourceIDArr: make([]string, 0, len(items)),
|
ResourceIDArr: make([]string, 0, len(items)),
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -215,6 +217,22 @@ type meijiequanLoginResponse struct {
|
|||||||
Raw json.RawMessage `json:"-"`
|
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 {
|
func NewMeijiequanClient(redis *goredis.Client, cfg config.MeijiequanConfig) *MeijiequanClient {
|
||||||
client := &MeijiequanClient{
|
client := &MeijiequanClient{
|
||||||
redis: redis,
|
redis: redis,
|
||||||
@@ -795,6 +813,87 @@ func (c *MeijiequanClient) CreateOrder(ctx context.Context, req MeijiequanCreate
|
|||||||
return result, nil
|
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) {
|
func (c *MeijiequanClient) ListPublishedArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||||||
return c.listArticles(ctx, userID, "2", page, pageSize)
|
return c.listArticles(ctx, userID, "2", page, pageSize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ package transport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
@@ -17,6 +13,7 @@ import (
|
|||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AssetHandler struct {
|
type AssetHandler struct {
|
||||||
@@ -104,11 +101,7 @@ func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AssetHandler) signObjectKey(objectKey string) string {
|
func (h *AssetHandler) signObjectKey(objectKey string) string {
|
||||||
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey))
|
return publicasset.SignObjectKey(objectKey, h.secret())
|
||||||
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 {
|
func (h *AssetHandler) secret() string {
|
||||||
@@ -119,43 +112,11 @@ func (h *AssetHandler) secret() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AssetHandler) parseToken(token string) (string, bool) {
|
func (h *AssetHandler) parseToken(token string) (string, bool) {
|
||||||
parts := strings.Split(token, ".")
|
return publicasset.ParseObjectKeyToken(token, h.secret())
|
||||||
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) {
|
func objectKeyFromAssetToken(token string) (string, bool) {
|
||||||
parts := strings.Split(token, ".")
|
return publicasset.ObjectKeyFromToken(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 {
|
func isDesktopClientAssetKey(objectKey string, tenantID int64) bool {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type MediaSupplyHandler struct {
|
|||||||
func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler {
|
func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler {
|
||||||
svc := a.MediaSupplyService
|
svc := a.MediaSupplyService
|
||||||
if svc == nil {
|
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{
|
return &MediaSupplyHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
|
|||||||
Reference in New Issue
Block a user