380 lines
11 KiB
Go
380 lines
11 KiB
Go
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
|
|
}
|