88c37e50b2
Introduce a new enterprise-site publishing channel that lets tenants push articles to self-hosted PbootCMS sites alongside the existing media supply flow. Backend (server/internal/tenant): - enterprise_site_service: CRUD, ping, category sync, and article publish - enterprise_site_pbootcms: PbootCMS API client integration - enterprise_site_crypto: encrypt/decrypt stored site credentials - enterprise_site_handler + routes under /enterprise-sites - migrations for the enterprise site publisher tables - config: add SERVER_PUBLIC_BASE_URL (Server.PublicBaseURL) for callbacks - article/media services adjusted to support the publish flow Frontend (apps/admin-web): - PublishArticleModal & ArticlePublishStatus: enterprise-site publish UI - MediaView: manage enterprise sites and categories - api + shared-types: enterprise site endpoints and types - http-client: add PATCH method support Integrations: - pbootcms GeoPublisher controller plugin + install guide - docs/enterprise-site-publisher-v1.md design doc
2413 lines
79 KiB
Go
2413 lines
79 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/sergi/go-diff/diffmatchpatch"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
goldhtml "github.com/yuin/goldmark/renderer/html"
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
|
"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/publicasset"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
type EnterpriseSiteService struct {
|
|
pool *pgxpool.Pool
|
|
cfg config.Provider
|
|
httpClient *http.Client
|
|
cache sharedcache.Cache
|
|
compliance *tenantcompliance.Service
|
|
storage objectstorage.Client
|
|
}
|
|
|
|
func NewEnterpriseSiteService(pool *pgxpool.Pool, cfg config.Provider) *EnterpriseSiteService {
|
|
return &EnterpriseSiteService{
|
|
pool: pool,
|
|
cfg: cfg,
|
|
httpClient: &http.Client{
|
|
Timeout: 25 * time.Second,
|
|
},
|
|
compliance: tenantcompliance.NewService(pool, cfg, nil),
|
|
}
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) WithCache(c sharedcache.Cache) *EnterpriseSiteService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) WithObjectStorage(storage objectstorage.Client) *EnterpriseSiteService {
|
|
s.storage = storage
|
|
return s
|
|
}
|
|
|
|
type EnterpriseSiteConnectionResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
SiteURL string `json:"site_url"`
|
|
CMSType string `json:"cms_type"`
|
|
AuthType string `json:"auth_type"`
|
|
AppID string `json:"appid"`
|
|
DefaultAcode *string `json:"default_acode"`
|
|
DefaultMcode *string `json:"default_mcode"`
|
|
DefaultScode *string `json:"default_scode"`
|
|
Status string `json:"status"`
|
|
PluginVersion *string `json:"plugin_version"`
|
|
SiteName *string `json:"site_name"`
|
|
LastPingAt *time.Time `json:"last_ping_at"`
|
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
|
LastPublishAt *time.Time `json:"last_publish_at"`
|
|
LastError *string `json:"last_error"`
|
|
CategoryCount int `json:"category_count"`
|
|
LatestRecord *EnterpriseSiteLatestPublishRecord `json:"latest_record,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type EnterpriseSiteLatestPublishRecord struct {
|
|
ID int64 `json:"id"`
|
|
ArticleID int64 `json:"article_id"`
|
|
ArticleTitle *string `json:"article_title"`
|
|
Status string `json:"status"`
|
|
ExternalArticleID *string `json:"external_article_id"`
|
|
ExternalArticleURL *string `json:"external_article_url"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
PublishedAt *time.Time `json:"published_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type EnterpriseSiteCategoryResponse struct {
|
|
ID int64 `json:"id"`
|
|
RemoteID string `json:"remote_id"`
|
|
ParentRemoteID *string `json:"parent_remote_id"`
|
|
Name string `json:"name"`
|
|
Slug *string `json:"slug"`
|
|
Raw map[string]any `json:"raw,omitempty"`
|
|
SyncedAt time.Time `json:"synced_at"`
|
|
}
|
|
|
|
type EnterpriseSiteCategoryItem struct {
|
|
RemoteID string
|
|
ParentRemoteID *string
|
|
Name string
|
|
Slug *string
|
|
Raw map[string]any
|
|
}
|
|
|
|
type EnterpriseSiteCapability struct {
|
|
CMSType string `json:"cms_type"`
|
|
PluginVersion *string `json:"plugin_version"`
|
|
SiteURL string `json:"site_url"`
|
|
SiteName *string `json:"site_name"`
|
|
APIAuth bool `json:"api_auth"`
|
|
}
|
|
|
|
type CreateEnterpriseSiteConnectionRequest struct {
|
|
Name string `json:"name"`
|
|
SiteURL string `json:"site_url" binding:"required"`
|
|
CMSType string `json:"cms_type" binding:"required"`
|
|
AppID string `json:"appid" binding:"required"`
|
|
Secret string `json:"secret" binding:"required"`
|
|
DefaultAcode *string `json:"default_acode"`
|
|
DefaultMcode *string `json:"default_mcode"`
|
|
DefaultScode *string `json:"default_scode"`
|
|
}
|
|
|
|
type UpdateEnterpriseSiteConnectionRequest struct {
|
|
Name *string `json:"name"`
|
|
SiteURL *string `json:"site_url"`
|
|
AppID *string `json:"appid"`
|
|
Secret *string `json:"secret"`
|
|
DefaultAcode *string `json:"default_acode"`
|
|
DefaultMcode *string `json:"default_mcode"`
|
|
DefaultScode *string `json:"default_scode"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
type PublishEnterpriseSiteArticleRequest struct {
|
|
ArticleID int64 `json:"article_id" binding:"required"`
|
|
CategoryID string `json:"category_id"`
|
|
PublishType string `json:"publish_type"`
|
|
Author *string `json:"author"`
|
|
Source *string `json:"source"`
|
|
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
|
|
AckRecordID *int64 `json:"ack_record_id,omitempty"`
|
|
CoverAssetURL *string `json:"cover_asset_url,omitempty"`
|
|
CoverImageAssetID *int64 `json:"cover_image_asset_id,omitempty"`
|
|
}
|
|
|
|
type EnterpriseSitePublishResponse struct {
|
|
PublishBatchID int64 `json:"publish_batch_id"`
|
|
PublishRecordID int64 `json:"publish_record_id"`
|
|
ConnectionID int64 `json:"connection_id"`
|
|
ArticleID int64 `json:"article_id"`
|
|
Status string `json:"status"`
|
|
ExternalArticleID *string `json:"external_article_id"`
|
|
ExternalArticleURL *string `json:"external_article_url"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
PublishedAt *time.Time `json:"published_at"`
|
|
}
|
|
|
|
type enterpriseSiteConnectionRecord struct {
|
|
ID int64
|
|
TenantID int64
|
|
WorkspaceID int64
|
|
Name string
|
|
SiteURL string
|
|
CMSType string
|
|
AuthType string
|
|
AppID string
|
|
CredentialCiphertext string
|
|
DefaultAcode *string
|
|
DefaultMcode *string
|
|
DefaultScode *string
|
|
Status string
|
|
PluginVersion *string
|
|
SiteName *string
|
|
LastPingAt *time.Time
|
|
LastSyncAt *time.Time
|
|
LastPublishAt *time.Time
|
|
LastError *string
|
|
CategoryCount int
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type enterpriseSitePublishArticle struct {
|
|
ID int64
|
|
BrandID int64
|
|
CurrentVersionID *int64
|
|
Title string
|
|
HTMLContent string
|
|
MarkdownContent string
|
|
WordCount int
|
|
SourceLabel *string
|
|
CoverAssetURL *string
|
|
CoverImageAssetID *int64
|
|
}
|
|
|
|
var enterpriseSiteSummaryTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
|
var enterpriseSiteImageAttrPattern = regexp.MustCompile(`(?i)(<img\b[^>]*\s(?:src|data-src|_src)=["'])([^"']+)(["'])`)
|
|
var enterpriseSiteMarkdownHeadingPattern = regexp.MustCompile(`^#{1,2}\s+(.+?)\s*#*\s*$`)
|
|
var enterpriseSiteMalformedEditorImagePattern = regexp.MustCompile(`(?i)(<p\b[^<>]*?)\s+(<img\b)`)
|
|
|
|
func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConnectionResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
if actor.TenantID == 0 || workspaceID == 0 {
|
|
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
WITH category_counts AS (
|
|
SELECT connection_id, COUNT(*)::int AS category_count
|
|
FROM enterprise_site_categories
|
|
WHERE tenant_id = $1 AND workspace_id = $2
|
|
GROUP BY connection_id
|
|
),
|
|
latest_records AS (
|
|
SELECT DISTINCT ON (pr.target_connection_id)
|
|
pr.target_connection_id,
|
|
pr.id,
|
|
pr.article_id,
|
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', '')) AS article_title,
|
|
pr.status,
|
|
pr.external_article_id,
|
|
pr.external_article_url,
|
|
pr.error_message,
|
|
pr.published_at,
|
|
pr.created_at,
|
|
pr.updated_at
|
|
FROM publish_records pr
|
|
LEFT JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
|
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
|
WHERE pr.tenant_id = $1
|
|
AND pr.target_type = 'enterprise_site'
|
|
AND pr.target_connection_id IS NOT NULL
|
|
ORDER BY pr.target_connection_id, pr.created_at DESC, pr.id DESC
|
|
)
|
|
SELECT
|
|
c.id,
|
|
c.tenant_id,
|
|
c.workspace_id,
|
|
c.name,
|
|
c.site_url,
|
|
c.cms_type,
|
|
c.auth_type,
|
|
c.appid,
|
|
c.credential_ciphertext,
|
|
c.default_acode,
|
|
c.default_mcode,
|
|
c.default_scode,
|
|
c.status,
|
|
c.plugin_version,
|
|
c.site_name,
|
|
c.last_ping_at,
|
|
c.last_sync_at,
|
|
c.last_publish_at,
|
|
c.last_error,
|
|
COALESCE(cc.category_count, 0),
|
|
c.created_at,
|
|
c.updated_at,
|
|
lr.id,
|
|
lr.article_id,
|
|
lr.article_title,
|
|
lr.status,
|
|
lr.external_article_id,
|
|
lr.external_article_url,
|
|
lr.error_message,
|
|
lr.published_at,
|
|
lr.created_at,
|
|
lr.updated_at
|
|
FROM enterprise_site_connections c
|
|
LEFT JOIN category_counts cc ON cc.connection_id = c.id
|
|
LEFT JOIN latest_records lr ON lr.target_connection_id = c.id
|
|
WHERE c.tenant_id = $1
|
|
AND c.workspace_id = $2
|
|
AND c.deleted_at IS NULL
|
|
ORDER BY c.created_at DESC, c.id DESC
|
|
`, actor.TenantID, workspaceID)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50220, "enterprise_site_query_failed", "failed to list enterprise sites")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]EnterpriseSiteConnectionResponse, 0)
|
|
for rows.Next() {
|
|
record, latest, scanErr := scanEnterpriseSiteConnectionRow(rows)
|
|
if scanErr != nil {
|
|
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "failed to parse enterprise site")
|
|
}
|
|
item := enterpriseSiteConnectionResponse(record)
|
|
item.LatestRecord = latest
|
|
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "failed to iterate enterprise sites")
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterpriseSiteConnectionRequest) (*EnterpriseSiteConnectionResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
|
|
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
normalized, err := normalizeCreateEnterpriseSiteConnectionRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "failed to store enterprise site credential")
|
|
}
|
|
|
|
var id int64
|
|
if err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO enterprise_site_connections (
|
|
tenant_id, workspace_id, created_by_user_id, name, site_url, cms_type, auth_type,
|
|
appid, credential_ciphertext, default_acode, default_mcode, default_scode, status
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'native_api', $7, $8, $9, $10, $11, 'draft')
|
|
RETURNING id
|
|
`,
|
|
actor.TenantID,
|
|
workspaceID,
|
|
actor.UserID,
|
|
normalized.Name,
|
|
normalized.SiteURL,
|
|
normalized.CMSType,
|
|
normalized.AppID,
|
|
ciphertext,
|
|
normalized.DefaultAcode,
|
|
normalized.DefaultMcode,
|
|
normalized.DefaultScode,
|
|
).Scan(&id); err != nil {
|
|
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists")
|
|
}
|
|
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req UpdateEnterpriseSiteConnectionRequest) (*EnterpriseSiteConnectionResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
if actor.TenantID == 0 || workspaceID == 0 {
|
|
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
current, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if current.Status == "disabled" && req.Status == nil {
|
|
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "enterprise site connection is disabled")
|
|
}
|
|
|
|
next := *current
|
|
if req.Name != nil {
|
|
name := strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "site name is required")
|
|
}
|
|
next.Name = name
|
|
}
|
|
if req.SiteURL != nil {
|
|
siteURL, normalizeErr := normalizeEnterpriseSiteURL(*req.SiteURL)
|
|
if normalizeErr != nil {
|
|
return nil, normalizeErr
|
|
}
|
|
next.SiteURL = siteURL
|
|
}
|
|
if req.AppID != nil {
|
|
appid := strings.TrimSpace(*req.AppID)
|
|
if appid == "" {
|
|
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required")
|
|
}
|
|
next.AppID = appid
|
|
}
|
|
if req.DefaultAcode != nil {
|
|
next.DefaultAcode = normalizeOptionalEnterpriseCode(*req.DefaultAcode)
|
|
}
|
|
if req.DefaultMcode != nil {
|
|
next.DefaultMcode = normalizeOptionalEnterpriseCode(*req.DefaultMcode)
|
|
}
|
|
if req.DefaultScode != nil {
|
|
next.DefaultScode = normalizeOptionalEnterpriseCode(*req.DefaultScode)
|
|
}
|
|
if req.Status != nil {
|
|
status := strings.TrimSpace(*req.Status)
|
|
if !validEnterpriseSiteStatus(status) {
|
|
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "status is invalid")
|
|
}
|
|
next.Status = status
|
|
}
|
|
ciphertext := next.CredentialCiphertext
|
|
if req.Secret != nil && strings.TrimSpace(*req.Secret) != "" {
|
|
encrypted, encryptErr := encryptEnterpriseSiteCredential(*req.Secret, s.credentialKeyMaterial())
|
|
if encryptErr != nil {
|
|
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "failed to store enterprise site credential")
|
|
}
|
|
ciphertext = encrypted
|
|
}
|
|
|
|
if _, err := s.pool.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET name = $1,
|
|
site_url = $2,
|
|
appid = $3,
|
|
credential_ciphertext = $4,
|
|
default_acode = $5,
|
|
default_mcode = $6,
|
|
default_scode = $7,
|
|
status = $8,
|
|
last_error = CASE WHEN $8 IN ('draft', 'connected') THEN NULL ELSE last_error END,
|
|
updated_at = NOW()
|
|
WHERE id = $9
|
|
AND tenant_id = $10
|
|
AND workspace_id = $11
|
|
AND deleted_at IS NULL
|
|
`,
|
|
next.Name,
|
|
next.SiteURL,
|
|
next.AppID,
|
|
ciphertext,
|
|
next.DefaultAcode,
|
|
next.DefaultMcode,
|
|
next.DefaultScode,
|
|
next.Status,
|
|
id,
|
|
actor.TenantID,
|
|
workspaceID,
|
|
); err != nil {
|
|
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists")
|
|
}
|
|
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Get(ctx context.Context, id int64) (*EnterpriseSiteConnectionResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
record, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
item := enterpriseSiteConnectionResponse(record)
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Delete(ctx context.Context, id int64) error {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
command, err := s.pool.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET deleted_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND tenant_id = $2
|
|
AND workspace_id = $3
|
|
AND deleted_at IS NULL
|
|
`, id, actor.TenantID, workspaceID)
|
|
if err != nil {
|
|
return response.ErrInternal(50222, "enterprise_site_delete_failed", "failed to delete enterprise site")
|
|
}
|
|
if command.RowsAffected() == 0 {
|
|
return response.ErrNotFound(40441, "enterprise_site_not_found", "enterprise site connection not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Ping(ctx context.Context, id int64) (*EnterpriseSiteCapability, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credential, err := s.connectionCredential(connection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publisher := s.publisherFor(connection.CMSType)
|
|
capability, raw, err := publisher.Ping(ctx, credential)
|
|
if err != nil {
|
|
_ = s.markConnectionError(ctx, connection.ID, actor.TenantID, workspaceID, err)
|
|
return nil, response.ErrBadRequest(40044, "enterprise_site_ping_failed", sanitizeEnterpriseSiteError(err))
|
|
}
|
|
pluginVersion := capability.PluginVersion
|
|
siteName := capability.SiteName
|
|
if _, err := s.pool.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET status = 'connected',
|
|
plugin_version = $1,
|
|
site_name = $2,
|
|
last_ping_at = NOW(),
|
|
last_error = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $3 AND tenant_id = $4 AND workspace_id = $5 AND deleted_at IS NULL
|
|
`, pluginVersion, siteName, connection.ID, actor.TenantID, workspaceID); err != nil {
|
|
return nil, response.ErrInternal(50223, "enterprise_site_ping_update_failed", "failed to update enterprise site ping")
|
|
}
|
|
_ = raw
|
|
return capability, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([]EnterpriseSiteCategoryResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credential, err := s.connectionCredential(connection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items, _, err := s.publisherFor(connection.CMSType).Categories(ctx, credential)
|
|
if err != nil {
|
|
return nil, response.ErrBadRequest(40045, "enterprise_site_category_sync_failed", sanitizeEnterpriseSiteError(err))
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, response.ErrBadRequest(40046, "enterprise_site_categories_empty", "CMS did not return any publishable categories")
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_begin_failed", "failed to sync enterprise site categories")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM enterprise_site_categories
|
|
WHERE tenant_id = $1 AND workspace_id = $2 AND connection_id = $3
|
|
`, actor.TenantID, workspaceID, connection.ID); err != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_category_clear_failed", "failed to sync enterprise site categories")
|
|
}
|
|
|
|
for _, item := range items {
|
|
rawJSON, marshalErr := json.Marshal(item.Raw)
|
|
if marshalErr != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_category_payload_failed", "failed to sync enterprise site categories")
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO enterprise_site_categories (
|
|
tenant_id, workspace_id, connection_id, remote_id, parent_remote_id, name, slug, raw_payload_json
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
`,
|
|
actor.TenantID,
|
|
workspaceID,
|
|
connection.ID,
|
|
item.RemoteID,
|
|
item.ParentRemoteID,
|
|
item.Name,
|
|
item.Slug,
|
|
rawJSON,
|
|
); err != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_category_insert_failed", "failed to sync enterprise site categories")
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET status = 'connected',
|
|
last_sync_at = NOW(),
|
|
last_error = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
|
|
`, connection.ID, actor.TenantID, workspaceID); err != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_sync_update_failed", "failed to update enterprise site sync state")
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_commit_failed", "failed to sync enterprise site categories")
|
|
}
|
|
return s.Categories(ctx, id)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) Categories(ctx context.Context, id int64) ([]EnterpriseSiteCategoryResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
if _, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, remote_id, parent_remote_id, name, slug, raw_payload_json, synced_at
|
|
FROM enterprise_site_categories
|
|
WHERE tenant_id = $1
|
|
AND workspace_id = $2
|
|
AND connection_id = $3
|
|
ORDER BY COALESCE(parent_remote_id, ''), name ASC, id ASC
|
|
`, actor.TenantID, workspaceID, id)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50225, "enterprise_site_category_query_failed", "failed to list enterprise site categories")
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]EnterpriseSiteCategoryResponse, 0)
|
|
for rows.Next() {
|
|
var item EnterpriseSiteCategoryResponse
|
|
var raw []byte
|
|
if err := rows.Scan(&item.ID, &item.RemoteID, &item.ParentRemoteID, &item.Name, &item.Slug, &raw, &item.SyncedAt); err != nil {
|
|
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "failed to parse enterprise site category")
|
|
}
|
|
if len(raw) > 0 {
|
|
item.Raw = map[string]any{}
|
|
_ = json.Unmarshal(raw, &item.Raw)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "failed to iterate enterprise site categories")
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID int64, req PublishEnterpriseSiteArticleRequest) (*EnterpriseSitePublishResponse, error) {
|
|
actor := auth.MustActor(ctx)
|
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
|
brandID, err := requireCurrentBrandID(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
|
|
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
|
|
}
|
|
|
|
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, connectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if connection.Status == "disabled" {
|
|
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "enterprise site connection is disabled")
|
|
}
|
|
|
|
article, err := s.loadPublishArticle(ctx, actor.TenantID, brandID, req.ArticleID, req.ArticleVersionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
categoryID := strings.TrimSpace(req.CategoryID)
|
|
if categoryID == "" {
|
|
categoryID = strings.TrimSpace(enterpriseSiteStringPointerValue(connection.DefaultScode))
|
|
}
|
|
if categoryID == "" {
|
|
return nil, response.ErrBadRequest(40047, "enterprise_site_category_required", "category_id is required")
|
|
}
|
|
if err := s.ensureCategoryExists(ctx, actor.TenantID, workspaceID, connection.ID, categoryID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentHTML := enterpriseSiteNormalizeStoredContent(article.HTMLContent)
|
|
markdownContent := enterpriseSiteNormalizeStoredContent(article.MarkdownContent)
|
|
if contentHTML != "" && !enterpriseSiteContentLooksLikeHTML(contentHTML) {
|
|
if markdownContent == "" {
|
|
markdownContent = contentHTML
|
|
}
|
|
contentHTML = ""
|
|
}
|
|
markdownContent = enterpriseSiteStripLeadingTitleHeadingMarkdown(markdownContent, article.Title)
|
|
if contentHTML == "" {
|
|
contentHTML, err = markdownToEnterpriseSiteHTML(markdownContent)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50226, "enterprise_site_markdown_render_failed", "failed to render article content")
|
|
}
|
|
}
|
|
contentHTML = enterpriseSiteStripLeadingTitleHeadingHTML(contentHTML, article.Title)
|
|
if strings.TrimSpace(contentHTML) == "" {
|
|
return nil, response.ErrBadRequest(40048, "enterprise_site_article_empty", "article content is empty")
|
|
}
|
|
|
|
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, article.ID, article.CurrentVersionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
|
|
TenantID: actor.TenantID,
|
|
ArticleID: article.ID,
|
|
ArticleVersionID: effectiveVersionID,
|
|
ActorID: actor.UserID,
|
|
TargetPlatforms: []string{connection.CMSType},
|
|
AckRecordID: req.AckRecordID,
|
|
TriggerSource: "publish_gate",
|
|
})
|
|
if err != nil {
|
|
return nil, compliancePublishError(err, gate)
|
|
}
|
|
if gate != nil {
|
|
switch gate.Decision {
|
|
case sharedcompliance.GateDecisionBlock:
|
|
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
|
|
case sharedcompliance.GateDecisionNeedsAck:
|
|
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
|
|
}
|
|
}
|
|
|
|
publishType := normalizeEnterprisePublishType(req.PublishType)
|
|
coverURLInput, coverImageAssetID := enterpriseSitePublishCoverInput(article, req)
|
|
contentHTML, coverURL := s.prepareEnterpriseSitePublishAssets(
|
|
ctx,
|
|
actor.TenantID,
|
|
contentHTML,
|
|
coverURLInput,
|
|
coverImageAssetID,
|
|
)
|
|
requestPayload := cmsPublishRequest{
|
|
ArticleID: article.ID,
|
|
Title: article.Title,
|
|
ContentHTML: contentHTML,
|
|
CategoryID: categoryID,
|
|
Description: enterpriseSiteDescription(contentHTML, markdownContent),
|
|
Author: strings.TrimSpace(enterpriseSiteStringPointerValue(req.Author)),
|
|
Source: strings.TrimSpace(enterpriseSiteStringPointerValue(req.Source)),
|
|
CoverURL: coverURL,
|
|
PublishType: publishType,
|
|
}
|
|
if requestPayload.Author == "" {
|
|
requestPayload.Author = "Geo SaaS"
|
|
}
|
|
if requestPayload.Source == "" {
|
|
requestPayload.Source = "Geo SaaS"
|
|
}
|
|
|
|
credential, err := s.connectionCredential(connection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requestJSON, err := json.Marshal(map[string]any{
|
|
"connection_id": connection.ID,
|
|
"cms_type": connection.CMSType,
|
|
"category_id": categoryID,
|
|
"publish_type": publishType,
|
|
"article_id": article.ID,
|
|
"article_version_id": effectiveVersionID,
|
|
"requested_article_version_id": req.ArticleVersionID,
|
|
"cover_image_asset_id": coverImageAssetID,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50227, "enterprise_site_publish_payload_failed", "failed to create publish record")
|
|
}
|
|
|
|
publishBatchID, publishRecordID, err := s.createEnterprisePublishRecord(ctx, actor, article, connection, publishType, requestJSON, effectiveVersionID, req.AckRecordID, gate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result, raw, publishErr := s.publisherFor(connection.CMSType).Publish(ctx, credential, requestPayload)
|
|
if publishErr != nil {
|
|
errorMessage := sanitizeEnterpriseSiteError(publishErr)
|
|
if updateErr := s.finishEnterprisePublishRecord(ctx, actor.TenantID, workspaceID, publishBatchID, publishRecordID, article.ID, connection.ID, "failed", nil, nil, nil, nil, &errorMessage); updateErr != nil {
|
|
return nil, updateErr
|
|
}
|
|
return &EnterpriseSitePublishResponse{
|
|
PublishBatchID: publishBatchID,
|
|
PublishRecordID: publishRecordID,
|
|
ConnectionID: connection.ID,
|
|
ArticleID: article.ID,
|
|
Status: "failed",
|
|
ErrorMessage: &errorMessage,
|
|
}, nil
|
|
}
|
|
|
|
rawJSON, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50228, "enterprise_site_publish_response_failed", "failed to persist CMS response")
|
|
}
|
|
externalID := normalizeStringPointer(&result.RemoteID)
|
|
externalURL := normalizeStringPointer(&result.URL)
|
|
publishedAt := time.Now().UTC()
|
|
status := "success"
|
|
if publishType == "draft" {
|
|
status = "success"
|
|
}
|
|
if err := s.finishEnterprisePublishRecord(ctx, actor.TenantID, workspaceID, publishBatchID, publishRecordID, article.ID, connection.ID, status, externalID, externalURL, &rawJSON, &publishedAt, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &article.ID)
|
|
return &EnterpriseSitePublishResponse{
|
|
PublishBatchID: publishBatchID,
|
|
PublishRecordID: publishRecordID,
|
|
ConnectionID: connection.ID,
|
|
ArticleID: article.ID,
|
|
Status: status,
|
|
ExternalArticleID: externalID,
|
|
ExternalArticleURL: externalURL,
|
|
PublishedAt: &publishedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) createEnterprisePublishRecord(
|
|
ctx context.Context,
|
|
actor auth.Actor,
|
|
article enterpriseSitePublishArticle,
|
|
connection *enterpriseSiteConnectionRecord,
|
|
publishType string,
|
|
requestJSON []byte,
|
|
articleVersionID int64,
|
|
ackRecordID *int64,
|
|
gate *tenantcompliance.GateOutcome,
|
|
) (int64, int64, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_begin_failed", "failed to create publish record")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var publishBatchID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO publish_batches (tenant_id, article_id, initiator_user_id, status, publish_type, cover_asset_url)
|
|
VALUES ($1, $2, $3, 'publishing', $4, $5)
|
|
RETURNING id
|
|
`, actor.TenantID, article.ID, actor.UserID, publishType, normalizeStringPointer(article.CoverAssetURL)).Scan(&publishBatchID); err != nil {
|
|
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_batch_failed", "failed to create publish record")
|
|
}
|
|
|
|
var publishRecordID int64
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO publish_records (
|
|
tenant_id, publish_batch_id, article_id, platform_account_id, platform_id, status,
|
|
request_payload_json, target_type, target_connection_id
|
|
)
|
|
VALUES ($1, $2, $3, NULL, $4, 'publishing', $5, 'enterprise_site', $6)
|
|
RETURNING id
|
|
`, actor.TenantID, publishBatchID, article.ID, connection.CMSType, requestJSON, connection.ID).Scan(&publishRecordID); err != nil {
|
|
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_record_failed", "failed to create publish record")
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE articles
|
|
SET publish_status = 'publishing', updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
|
`, article.ID, actor.TenantID); err != nil {
|
|
return 0, 0, response.ErrInternal(50227, "enterprise_site_article_publish_status_failed", "failed to update article publish status")
|
|
}
|
|
|
|
if err := s.consumeEnterprisePublishAckTx(ctx, tx, ackRecordID, gate, actor.TenantID, article.ID, articleVersionID); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_commit_failed", "failed to create publish record")
|
|
}
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &article.ID)
|
|
return publishBatchID, publishRecordID, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) consumeEnterprisePublishAckTx(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
ackRecordID *int64,
|
|
gate *tenantcompliance.GateOutcome,
|
|
tenantID int64,
|
|
articleID int64,
|
|
articleVersionID int64,
|
|
) error {
|
|
if ackRecordID == nil {
|
|
return nil
|
|
}
|
|
if gate == nil || gate.Result == nil {
|
|
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
|
|
}
|
|
consumed, err := s.compliance.ConsumeAckTx(ctx, tx, *ackRecordID, tenantcompliance.ConsumeAckQuery{
|
|
TenantID: tenantID,
|
|
ArticleID: articleID,
|
|
ArticleVersionID: articleVersionID,
|
|
CheckRecordID: gate.Result.RecordID,
|
|
ContentHash: gate.Result.ContentHash,
|
|
PolicyFingerprint: gate.Result.PolicyFingerprint,
|
|
ConsumedByJobID: nil,
|
|
})
|
|
if err != nil {
|
|
return response.ErrInternal(50227, "enterprise_site_publish_ack_failed", "failed to consume compliance acknowledgement")
|
|
}
|
|
if !consumed {
|
|
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is invalid or expired")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
|
|
ctx context.Context,
|
|
tenantID int64,
|
|
workspaceID int64,
|
|
publishBatchID int64,
|
|
publishRecordID int64,
|
|
articleID int64,
|
|
connectionID int64,
|
|
status string,
|
|
externalID *string,
|
|
externalURL *string,
|
|
responseJSON *[]byte,
|
|
publishedAt *time.Time,
|
|
errorMessage *string,
|
|
) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_publish_finish_begin_failed", "failed to update publish record")
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var responsePayload []byte
|
|
if responseJSON != nil {
|
|
responsePayload = *responseJSON
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE publish_records
|
|
SET status = $1,
|
|
external_article_id = $2,
|
|
external_article_url = $3,
|
|
external_manage_url = $3,
|
|
response_payload_json = $4,
|
|
published_at = $5,
|
|
error_message = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $7 AND tenant_id = $8
|
|
`, status, externalID, externalURL, nullableJSON(responsePayload), publishedAt, errorMessage, publishRecordID, tenantID); err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "failed to update publish record")
|
|
}
|
|
|
|
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE publish_batches
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3
|
|
`, batchStatus, publishBatchID, tenantID); err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_publish_batch_update_failed", "failed to update publish batch")
|
|
}
|
|
|
|
articleStatus := publishBatchStatusToArticleStatus(batchStatus)
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE articles
|
|
SET publish_status = $1, updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
|
|
`, articleStatus, articleID, tenantID); err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_article_publish_update_failed", "failed to update article publish status")
|
|
}
|
|
|
|
if status == "success" {
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET status = 'connected',
|
|
last_publish_at = NOW(),
|
|
last_error = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
|
|
`, connectionID, tenantID, workspaceID); err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_publish_connection_update_failed", "failed to update enterprise site")
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return response.ErrInternal(50228, "enterprise_site_publish_finish_commit_failed", "failed to update publish record")
|
|
}
|
|
invalidateArticleCaches(ctx, s.cache, tenantID, &articleID)
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadPublishArticle(ctx context.Context, tenantID, brandID, articleID int64, requestedVersionID *int64) (enterpriseSitePublishArticle, error) {
|
|
var article enterpriseSitePublishArticle
|
|
var wizardStateJSON []byte
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
a.id,
|
|
a.brand_id,
|
|
a.current_version_id,
|
|
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '未命名文章'),
|
|
COALESCE(av.word_count, 0),
|
|
av.source_label,
|
|
a.wizard_state_json
|
|
FROM articles a
|
|
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
|
WHERE a.id = $1
|
|
AND a.tenant_id = $2
|
|
AND a.brand_id = $3
|
|
AND a.deleted_at IS NULL
|
|
AND a.generate_status = 'completed'
|
|
`, articleID, tenantID, brandID).Scan(
|
|
&article.ID,
|
|
&article.BrandID,
|
|
&article.CurrentVersionID,
|
|
&article.Title,
|
|
&article.WordCount,
|
|
&article.SourceLabel,
|
|
&wizardStateJSON,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return article, response.ErrNotFound(40411, "article_not_found", "article not found")
|
|
}
|
|
return article, response.ErrInternal(50229, "enterprise_site_article_lookup_failed", "failed to load article")
|
|
}
|
|
if err := s.populatePublishArticleContent(ctx, &article, wizardStateJSON, requestedVersionID); err != nil {
|
|
return article, err
|
|
}
|
|
article.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
|
article.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
|
article.Title = strings.TrimSpace(article.Title)
|
|
if article.Title == "" {
|
|
article.Title = "未命名文章"
|
|
}
|
|
return article, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) populatePublishArticleContent(ctx context.Context, article *enterpriseSitePublishArticle, wizardStateJSON []byte, requestedVersionID *int64) error {
|
|
if article == nil {
|
|
return nil
|
|
}
|
|
|
|
if requestedVersionID != nil && *requestedVersionID > 0 {
|
|
if err := s.loadPublishArticleVersionContent(ctx, article, *requestedVersionID, true); err != nil {
|
|
return err
|
|
}
|
|
article.CurrentVersionID = requestedVersionID
|
|
if enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
|
|
if err := s.loadPublishArticleVersionSnapshotContent(ctx, article, *requestedVersionID, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
|
|
return s.populatePublishArticleWizardFallback(article, wizardStateJSON)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if article.CurrentVersionID != nil && *article.CurrentVersionID > 0 {
|
|
if err := s.loadPublishArticleVersionContent(ctx, article, *article.CurrentVersionID, false); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
}
|
|
if !enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
|
|
return nil
|
|
}
|
|
|
|
if err := s.loadLatestPublishArticleVersionContent(ctx, article); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
if article.CurrentVersionID != nil && *article.CurrentVersionID > 0 && enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
|
|
if err := s.loadPublishArticleVersionSnapshotContent(ctx, article, *article.CurrentVersionID, false); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
}
|
|
return s.populatePublishArticleWizardFallback(article, wizardStateJSON)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadPublishArticleVersionContent(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64, strict bool) error {
|
|
content, err := repository.LoadArticleVersionContent(ctx, s.pool, article.ID, versionID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if !strict {
|
|
return pgx.ErrNoRows
|
|
}
|
|
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
|
|
}
|
|
return response.ErrInternal(50231, "enterprise_site_article_version_reconstruct_failed", "failed to reconstruct article version")
|
|
}
|
|
if content != nil {
|
|
article.HTMLContent = strings.TrimSpace(enterpriseSiteStringPointerValue(content.HTMLContent))
|
|
article.MarkdownContent = strings.TrimSpace(enterpriseSiteStringPointerValue(content.MarkdownContent))
|
|
}
|
|
if err := s.loadPublishArticleVersionMetadata(ctx, article, versionID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadPublishArticleVersionMetadata(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64) error {
|
|
var title string
|
|
var sourceLabel *string
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT COALESCE(title, ''), COALESCE(word_count, 0), source_label
|
|
FROM article_versions
|
|
WHERE id = $1 AND article_id = $2
|
|
`, versionID, article.ID).Scan(&title, &article.WordCount, &sourceLabel); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
|
|
}
|
|
return response.ErrInternal(50231, "enterprise_site_article_version_lookup_failed", "failed to load article version")
|
|
}
|
|
if strings.TrimSpace(title) != "" {
|
|
article.Title = strings.TrimSpace(title)
|
|
}
|
|
if sourceLabel != nil {
|
|
article.SourceLabel = sourceLabel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadPublishArticleVersionSnapshotContent(ctx context.Context, article *enterpriseSitePublishArticle, versionID int64, strict bool) error {
|
|
if article == nil {
|
|
return nil
|
|
}
|
|
var snapshot string
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT COALESCE(plaintext_snapshot, '')
|
|
FROM article_versions
|
|
WHERE id = $1 AND article_id = $2
|
|
`, versionID, article.ID).Scan(&snapshot); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if !strict {
|
|
return pgx.ErrNoRows
|
|
}
|
|
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
|
|
}
|
|
return response.ErrInternal(50231, "enterprise_site_article_version_snapshot_lookup_failed", "failed to load article version")
|
|
}
|
|
snapshot = enterpriseSiteNormalizeStoredContent(snapshot)
|
|
if strings.TrimSpace(snapshot) != "" {
|
|
article.MarkdownContent = snapshot
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadLatestPublishArticleVersionContent(ctx context.Context, article *enterpriseSitePublishArticle) error {
|
|
var versionID int64
|
|
var title string
|
|
var sourceLabel *string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, COALESCE(title, ''), COALESCE(word_count, 0), source_label
|
|
FROM article_versions
|
|
WHERE article_id = $1
|
|
ORDER BY version_no DESC, id DESC
|
|
LIMIT 1
|
|
`, article.ID).Scan(&versionID, &title, &article.WordCount, &sourceLabel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
article.CurrentVersionID = &versionID
|
|
if strings.TrimSpace(article.Title) == "" && strings.TrimSpace(title) != "" {
|
|
article.Title = strings.TrimSpace(title)
|
|
}
|
|
if article.SourceLabel == nil && sourceLabel != nil {
|
|
article.SourceLabel = sourceLabel
|
|
}
|
|
return s.loadPublishArticleVersionContent(ctx, article, versionID, false)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) populatePublishArticleWizardFallback(article *enterpriseSitePublishArticle, wizardStateJSON []byte) error {
|
|
if !enterpriseSitePublishArticleContentInvalid(article.HTMLContent, article.MarkdownContent) {
|
|
return nil
|
|
}
|
|
htmlContent, markdownContent := enterpriseSiteContentFromWizardState(wizardStateJSON)
|
|
if strings.TrimSpace(htmlContent) != "" {
|
|
article.HTMLContent = strings.TrimSpace(htmlContent)
|
|
}
|
|
if strings.TrimSpace(markdownContent) != "" {
|
|
article.MarkdownContent = strings.TrimSpace(markdownContent)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) ensureCategoryExists(ctx context.Context, tenantID, workspaceID, connectionID int64, categoryID string) error {
|
|
var exists bool
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM enterprise_site_categories
|
|
WHERE tenant_id = $1
|
|
AND workspace_id = $2
|
|
AND connection_id = $3
|
|
AND remote_id = $4
|
|
)
|
|
`, tenantID, workspaceID, connectionID, categoryID).Scan(&exists); err != nil {
|
|
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "failed to validate enterprise site category")
|
|
}
|
|
if !exists {
|
|
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "category is not synced for this site")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadConnection(ctx context.Context, tenantID, workspaceID, id int64) (*enterpriseSiteConnectionRecord, error) {
|
|
var item enterpriseSiteConnectionRecord
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
c.id,
|
|
c.tenant_id,
|
|
c.workspace_id,
|
|
c.name,
|
|
c.site_url,
|
|
c.cms_type,
|
|
c.auth_type,
|
|
c.appid,
|
|
c.credential_ciphertext,
|
|
c.default_acode,
|
|
c.default_mcode,
|
|
c.default_scode,
|
|
c.status,
|
|
c.plugin_version,
|
|
c.site_name,
|
|
c.last_ping_at,
|
|
c.last_sync_at,
|
|
c.last_publish_at,
|
|
c.last_error,
|
|
(
|
|
SELECT COUNT(*)::int
|
|
FROM enterprise_site_categories ec
|
|
WHERE ec.connection_id = c.id
|
|
AND ec.tenant_id = c.tenant_id
|
|
AND ec.workspace_id = c.workspace_id
|
|
) AS category_count,
|
|
c.created_at,
|
|
c.updated_at
|
|
FROM enterprise_site_connections c
|
|
WHERE c.id = $1
|
|
AND c.tenant_id = $2
|
|
AND c.workspace_id = $3
|
|
AND c.deleted_at IS NULL
|
|
`, id, tenantID, workspaceID).Scan(
|
|
&item.ID,
|
|
&item.TenantID,
|
|
&item.WorkspaceID,
|
|
&item.Name,
|
|
&item.SiteURL,
|
|
&item.CMSType,
|
|
&item.AuthType,
|
|
&item.AppID,
|
|
&item.CredentialCiphertext,
|
|
&item.DefaultAcode,
|
|
&item.DefaultMcode,
|
|
&item.DefaultScode,
|
|
&item.Status,
|
|
&item.PluginVersion,
|
|
&item.SiteName,
|
|
&item.LastPingAt,
|
|
&item.LastSyncAt,
|
|
&item.LastPublishAt,
|
|
&item.LastError,
|
|
&item.CategoryCount,
|
|
&item.CreatedAt,
|
|
&item.UpdatedAt,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "enterprise site connection not found")
|
|
}
|
|
return nil, response.ErrInternal(50220, "enterprise_site_lookup_failed", "failed to load enterprise site")
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) connectionCredential(connection *enterpriseSiteConnectionRecord) (enterpriseSiteCredential, error) {
|
|
secret, err := decryptEnterpriseSiteCredential(connection.CredentialCiphertext, s.credentialKeyMaterial())
|
|
if err != nil {
|
|
return enterpriseSiteCredential{}, response.ErrInternal(50231, "enterprise_site_secret_decrypt_failed", "failed to read enterprise site credential")
|
|
}
|
|
return enterpriseSiteCredential{
|
|
SiteURL: connection.SiteURL,
|
|
AppID: connection.AppID,
|
|
Secret: secret,
|
|
DefaultAcode: enterpriseSiteStringPointerValue(connection.DefaultAcode),
|
|
DefaultMcode: enterpriseSiteStringPointerValue(connection.DefaultMcode),
|
|
DefaultScode: enterpriseSiteStringPointerValue(connection.DefaultScode),
|
|
}, nil
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) publisherFor(cmsType string) cmsPublisher {
|
|
switch strings.TrimSpace(cmsType) {
|
|
case pbootCMSPlatformID:
|
|
return newPBootCMSPublisher(s.httpClient)
|
|
default:
|
|
return newPBootCMSPublisher(s.httpClient)
|
|
}
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) credentialKeyMaterial() string {
|
|
if s != nil && s.cfg != nil && s.cfg.Current() != nil {
|
|
if secret := strings.TrimSpace(s.cfg.Current().JWT.Secret); secret != "" {
|
|
return secret
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) markConnectionError(ctx context.Context, connectionID, tenantID, workspaceID int64, err error) error {
|
|
message := sanitizeEnterpriseSiteError(err)
|
|
_, updateErr := s.pool.Exec(ctx, `
|
|
UPDATE enterprise_site_connections
|
|
SET status = 'error',
|
|
last_error = $1,
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND tenant_id = $3 AND workspace_id = $4 AND deleted_at IS NULL
|
|
`, message, connectionID, tenantID, workspaceID)
|
|
return updateErr
|
|
}
|
|
|
|
func scanEnterpriseSiteConnectionRow(rows pgx.Rows) (*enterpriseSiteConnectionRecord, *EnterpriseSiteLatestPublishRecord, error) {
|
|
var item enterpriseSiteConnectionRecord
|
|
var latestID pgtype.Int8
|
|
var latestArticleID pgtype.Int8
|
|
var latestArticleTitle pgtype.Text
|
|
var latestStatus pgtype.Text
|
|
var latestExternalID pgtype.Text
|
|
var latestExternalURL pgtype.Text
|
|
var latestError pgtype.Text
|
|
var latestPublishedAt pgtype.Timestamptz
|
|
var latestCreatedAt pgtype.Timestamptz
|
|
var latestUpdatedAt pgtype.Timestamptz
|
|
|
|
err := rows.Scan(
|
|
&item.ID,
|
|
&item.TenantID,
|
|
&item.WorkspaceID,
|
|
&item.Name,
|
|
&item.SiteURL,
|
|
&item.CMSType,
|
|
&item.AuthType,
|
|
&item.AppID,
|
|
&item.CredentialCiphertext,
|
|
&item.DefaultAcode,
|
|
&item.DefaultMcode,
|
|
&item.DefaultScode,
|
|
&item.Status,
|
|
&item.PluginVersion,
|
|
&item.SiteName,
|
|
&item.LastPingAt,
|
|
&item.LastSyncAt,
|
|
&item.LastPublishAt,
|
|
&item.LastError,
|
|
&item.CategoryCount,
|
|
&item.CreatedAt,
|
|
&item.UpdatedAt,
|
|
&latestID,
|
|
&latestArticleID,
|
|
&latestArticleTitle,
|
|
&latestStatus,
|
|
&latestExternalID,
|
|
&latestExternalURL,
|
|
&latestError,
|
|
&latestPublishedAt,
|
|
&latestCreatedAt,
|
|
&latestUpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var latest *EnterpriseSiteLatestPublishRecord
|
|
if latestID.Valid && latestArticleID.Valid && latestStatus.Valid && latestCreatedAt.Valid && latestUpdatedAt.Valid {
|
|
latest = &EnterpriseSiteLatestPublishRecord{
|
|
ID: latestID.Int64,
|
|
ArticleID: latestArticleID.Int64,
|
|
ArticleTitle: enterpriseSiteTextPointer(latestArticleTitle),
|
|
Status: latestStatus.String,
|
|
ExternalArticleID: enterpriseSiteTextPointer(latestExternalID),
|
|
ExternalArticleURL: enterpriseSiteTextPointer(latestExternalURL),
|
|
ErrorMessage: enterpriseSiteTextPointer(latestError),
|
|
PublishedAt: enterpriseSiteTimePointer(latestPublishedAt),
|
|
CreatedAt: latestCreatedAt.Time,
|
|
UpdatedAt: latestUpdatedAt.Time,
|
|
}
|
|
}
|
|
return &item, latest, nil
|
|
}
|
|
|
|
func enterpriseSiteConnectionResponse(item *enterpriseSiteConnectionRecord) EnterpriseSiteConnectionResponse {
|
|
return EnterpriseSiteConnectionResponse{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
SiteURL: item.SiteURL,
|
|
CMSType: item.CMSType,
|
|
AuthType: item.AuthType,
|
|
AppID: item.AppID,
|
|
DefaultAcode: item.DefaultAcode,
|
|
DefaultMcode: item.DefaultMcode,
|
|
DefaultScode: item.DefaultScode,
|
|
Status: item.Status,
|
|
PluginVersion: item.PluginVersion,
|
|
SiteName: item.SiteName,
|
|
LastPingAt: item.LastPingAt,
|
|
LastSyncAt: item.LastSyncAt,
|
|
LastPublishAt: item.LastPublishAt,
|
|
LastError: item.LastError,
|
|
CategoryCount: item.CategoryCount,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteNormalizeLegacyPublishErrorState(item *EnterpriseSiteConnectionResponse) {
|
|
if item == nil || item.Status != "error" || item.LatestRecord == nil {
|
|
return
|
|
}
|
|
switch strings.TrimSpace(item.LatestRecord.Status) {
|
|
case "failed", "publish_failed":
|
|
default:
|
|
return
|
|
}
|
|
lastError := strings.TrimSpace(enterpriseSiteStringPointerValue(item.LastError))
|
|
recordError := strings.TrimSpace(enterpriseSiteStringPointerValue(item.LatestRecord.ErrorMessage))
|
|
if lastError == "" || recordError == "" || lastError != recordError {
|
|
return
|
|
}
|
|
item.Status = "connected"
|
|
item.LastError = nil
|
|
}
|
|
|
|
type normalizedEnterpriseSiteConnectionRequest struct {
|
|
Name string
|
|
SiteURL string
|
|
CMSType string
|
|
AppID string
|
|
Secret string
|
|
DefaultAcode *string
|
|
DefaultMcode *string
|
|
DefaultScode *string
|
|
}
|
|
|
|
func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConnectionRequest) (normalizedEnterpriseSiteConnectionRequest, error) {
|
|
siteURL, err := normalizeEnterpriseSiteURL(req.SiteURL)
|
|
if err != nil {
|
|
return normalizedEnterpriseSiteConnectionRequest{}, err
|
|
}
|
|
cmsType := normalizeEnterpriseCMSType(req.CMSType)
|
|
if cmsType != pbootCMSPlatformID {
|
|
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "only PBootCMS is supported in this version")
|
|
}
|
|
appid := strings.TrimSpace(req.AppID)
|
|
if appid == "" {
|
|
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required")
|
|
}
|
|
secret := strings.TrimSpace(req.Secret)
|
|
if secret == "" {
|
|
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_secret_required", "secret is required")
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
if parsed, err := url.Parse(siteURL); err == nil {
|
|
name = parsed.Hostname()
|
|
}
|
|
}
|
|
if name == "" {
|
|
name = "企业站点"
|
|
}
|
|
return normalizedEnterpriseSiteConnectionRequest{
|
|
Name: name,
|
|
SiteURL: siteURL,
|
|
CMSType: cmsType,
|
|
AppID: appid,
|
|
Secret: secret,
|
|
DefaultAcode: normalizeOptionalEnterpriseCodePtr(req.DefaultAcode),
|
|
DefaultMcode: normalizeOptionalEnterpriseCodePtr(req.DefaultMcode),
|
|
DefaultScode: normalizeOptionalEnterpriseCodePtr(req.DefaultScode),
|
|
}, nil
|
|
}
|
|
|
|
func normalizeEnterpriseSiteURL(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", response.ErrBadRequest(40041, "enterprise_site_url_required", "site_url is required")
|
|
}
|
|
if !strings.Contains(trimmed, "://") {
|
|
trimmed = "https://" + trimmed
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "site_url is invalid")
|
|
}
|
|
scheme := strings.ToLower(parsed.Scheme)
|
|
if scheme != "https" && scheme != "http" {
|
|
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "site_url must be http or https")
|
|
}
|
|
parsed.Scheme = scheme
|
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
|
parsed.RawQuery = ""
|
|
parsed.Fragment = ""
|
|
return strings.TrimRight(parsed.String(), "/"), nil
|
|
}
|
|
|
|
func normalizeEnterpriseCMSType(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func normalizeOptionalEnterpriseCodePtr(value *string) *string {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return normalizeOptionalEnterpriseCode(*value)
|
|
}
|
|
|
|
func normalizeOptionalEnterpriseCode(value string) *string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return &trimmed
|
|
}
|
|
|
|
func validEnterpriseSiteStatus(status string) bool {
|
|
switch strings.TrimSpace(status) {
|
|
case "draft", "connected", "error", "disabled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeEnterprisePublishType(value string) string {
|
|
if strings.TrimSpace(value) == "draft" {
|
|
return "draft"
|
|
}
|
|
return "publish"
|
|
}
|
|
|
|
func enterpriseSitePublishArticleContentInvalid(htmlContent, markdownContent string) bool {
|
|
return enterpriseSiteStoredContentInvalid(htmlContent) && enterpriseSiteStoredContentInvalid(markdownContent)
|
|
}
|
|
|
|
func enterpriseSiteStoredContentInvalid(value string) bool {
|
|
return strings.TrimSpace(enterpriseSiteNormalizeStoredContent(value)) == ""
|
|
}
|
|
|
|
func enterpriseSiteNormalizeStoredContent(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if !enterpriseSiteLooksLikeDiffPatch(value) {
|
|
return enterpriseSiteRepairMalformedEditorImageHTML(value)
|
|
}
|
|
if restored, ok := enterpriseSiteRestoreDiffPatchContent(value); ok {
|
|
return strings.TrimSpace(enterpriseSiteRepairMalformedEditorImageHTML(restored))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func enterpriseSiteRepairMalformedEditorImageHTML(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || !strings.Contains(strings.ToLower(value), "<img") {
|
|
return value
|
|
}
|
|
return enterpriseSiteMalformedEditorImagePattern.ReplaceAllString(value, `$1>$2`)
|
|
}
|
|
|
|
func enterpriseSiteLooksLikeDiffPatch(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return strings.HasPrefix(value, "@@ -") && strings.Contains(value, " @@")
|
|
}
|
|
|
|
func enterpriseSiteRestoreDiffPatchContent(value string) (string, bool) {
|
|
dmp := diffmatchpatch.New()
|
|
patches, err := dmp.PatchFromText(strings.TrimSpace(value))
|
|
if err != nil || len(patches) == 0 {
|
|
return "", false
|
|
}
|
|
restored, applied := dmp.PatchApply(patches, "")
|
|
if len(applied) == 0 {
|
|
return "", false
|
|
}
|
|
for _, ok := range applied {
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
}
|
|
return restored, true
|
|
}
|
|
|
|
func enterpriseSiteContentLooksLikeHTML(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return false
|
|
}
|
|
return strings.Contains(value, "<p") ||
|
|
strings.Contains(value, "<div") ||
|
|
strings.Contains(value, "<br") ||
|
|
strings.Contains(value, "<h1") ||
|
|
strings.Contains(value, "<h2") ||
|
|
strings.Contains(value, "<h3") ||
|
|
strings.Contains(value, "<ul") ||
|
|
strings.Contains(value, "<ol") ||
|
|
strings.Contains(value, "<li") ||
|
|
strings.Contains(value, "<img") ||
|
|
strings.Contains(value, "<table") ||
|
|
strings.Contains(value, "<blockquote")
|
|
}
|
|
|
|
func enterpriseSiteContentFromWizardState(raw []byte) (string, string) {
|
|
if len(raw) == 0 {
|
|
return "", ""
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return "", ""
|
|
}
|
|
htmlKeys := []string{"html_content", "htmlContent", "content_html", "article_html", "body_html"}
|
|
markdownKeys := []string{"markdown_content", "markdownContent", "content", "article_content", "body", "text"}
|
|
|
|
var htmlContent string
|
|
for _, key := range htmlKeys {
|
|
if value := strings.TrimSpace(enterpriseSiteWizardString(payload[key])); value != "" && !enterpriseSiteStoredContentInvalid(value) {
|
|
htmlContent = value
|
|
break
|
|
}
|
|
}
|
|
|
|
var markdownContent string
|
|
for _, key := range markdownKeys {
|
|
if value := strings.TrimSpace(enterpriseSiteWizardString(payload[key])); value != "" && !enterpriseSiteStoredContentInvalid(value) {
|
|
markdownContent = value
|
|
break
|
|
}
|
|
}
|
|
return htmlContent, markdownContent
|
|
}
|
|
|
|
func enterpriseSiteWizardString(value any) string {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return typed
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func enterpriseSitePublishCoverInput(article enterpriseSitePublishArticle, req PublishEnterpriseSiteArticleRequest) (string, *int64) {
|
|
coverURL := enterpriseSiteStringPointerValue(article.CoverAssetURL)
|
|
if req.CoverAssetURL != nil {
|
|
coverURL = strings.TrimSpace(*req.CoverAssetURL)
|
|
}
|
|
coverImageAssetID := article.CoverImageAssetID
|
|
if req.CoverImageAssetID != nil {
|
|
coverImageAssetID = req.CoverImageAssetID
|
|
}
|
|
return coverURL, coverImageAssetID
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSitePublishAssets(ctx context.Context, tenantID int64, contentHTML, coverURL string, coverImageAssetID *int64) (string, string) {
|
|
base := s.enterpriseSitePublicBaseURL()
|
|
preparedHTML := s.prepareEnterpriseSiteImageURLs(ctx, tenantID, contentHTML, base)
|
|
preparedCover := s.prepareEnterpriseSiteCoverURL(ctx, tenantID, coverURL, coverImageAssetID, base)
|
|
return preparedHTML, preparedCover
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteCoverURL(ctx context.Context, tenantID int64, coverURL string, coverImageAssetID *int64, baseURL string) string {
|
|
if coverImageAssetID != nil && *coverImageAssetID > 0 {
|
|
if dataURI := s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, *coverImageAssetID); dataURI != "" {
|
|
return dataURI
|
|
}
|
|
}
|
|
return s.prepareEnterpriseSiteAssetURL(ctx, tenantID, coverURL, baseURL)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSitePublicBaseURL() string {
|
|
if s == nil || s.cfg == nil || s.cfg.Current() == nil {
|
|
return ""
|
|
}
|
|
cfg := s.cfg.Current()
|
|
if value := normalizeEnterpriseSiteAssetBaseURL(cfg.Server.PublicBaseURL); value != "" {
|
|
return value
|
|
}
|
|
for _, origin := range cfg.Server.AllowedOrigins {
|
|
if value := normalizeEnterpriseSiteAssetBaseURL(origin); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
port := cfg.Server.Port
|
|
if port <= 0 {
|
|
port = 8080
|
|
}
|
|
return "http://127.0.0.1:" + strconv.Itoa(port)
|
|
}
|
|
|
|
func normalizeEnterpriseSiteAssetBaseURL(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || value == "*" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return ""
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return ""
|
|
}
|
|
parsed.RawQuery = ""
|
|
parsed.Fragment = ""
|
|
return strings.TrimRight(parsed.String(), "/")
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLs(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
|
|
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
|
|
return fragment
|
|
}
|
|
contextNode := &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: "body",
|
|
DataAtom: atom.Body,
|
|
}
|
|
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
|
|
if err != nil {
|
|
return s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL)
|
|
}
|
|
for _, node := range nodes {
|
|
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, node, baseURL)
|
|
}
|
|
nodes = enterpriseSitePruneEmptyImageWrapperNodes(nodes)
|
|
return enterpriseSiteRenderHTMLNodes(nodes, s.prepareEnterpriseSiteImageURLsFallback(ctx, tenantID, fragment, baseURL))
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageNodeURLs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
if node.Type == html.ElementNode && strings.EqualFold(node.Data, "img") {
|
|
if !s.prepareEnterpriseSiteImageAttrs(ctx, tenantID, node, baseURL) {
|
|
enterpriseSiteRemoveHTMLNode(node)
|
|
return
|
|
}
|
|
}
|
|
for child := node.FirstChild; child != nil; {
|
|
next := child.NextSibling
|
|
s.prepareEnterpriseSiteImageNodeURLs(ctx, tenantID, child, baseURL)
|
|
child = next
|
|
}
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageAttrs(ctx context.Context, tenantID int64, node *html.Node, baseURL string) bool {
|
|
if node == nil {
|
|
return true
|
|
}
|
|
dataURI := s.enterpriseSiteDataURIFromImageNode(ctx, tenantID, node)
|
|
hasImageURL := false
|
|
for idx := range node.Attr {
|
|
key := strings.ToLower(strings.TrimSpace(node.Attr[idx].Key))
|
|
if key != "src" && key != "data-src" && key != "_src" {
|
|
continue
|
|
}
|
|
hasImageURL = true
|
|
if dataURI != "" {
|
|
node.Attr[idx].Val = dataURI
|
|
continue
|
|
}
|
|
node.Attr[idx].Val = s.prepareEnterpriseSiteAssetURL(ctx, tenantID, node.Attr[idx].Val, baseURL)
|
|
if strings.TrimSpace(node.Attr[idx].Val) == "" {
|
|
return false
|
|
}
|
|
}
|
|
node.Attr = enterpriseSiteStripSaaSImageAttrs(node.Attr)
|
|
return hasImageURL
|
|
}
|
|
|
|
func enterpriseSiteStripSaaSImageAttrs(attrs []html.Attribute) []html.Attribute {
|
|
if len(attrs) == 0 {
|
|
return attrs
|
|
}
|
|
cleaned := make([]html.Attribute, 0, len(attrs))
|
|
for _, attr := range attrs {
|
|
key := strings.ToLower(strings.TrimSpace(attr.Key))
|
|
if key == "data-asset-id" || key == "asset-id" {
|
|
continue
|
|
}
|
|
cleaned = append(cleaned, attr)
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func enterpriseSiteRemoveHTMLNode(node *html.Node) {
|
|
if node == nil || node.Parent == nil {
|
|
return
|
|
}
|
|
parent := node.Parent
|
|
parent.RemoveChild(node)
|
|
if enterpriseSiteHTMLNodeIsEmptyImageWrapper(parent) {
|
|
enterpriseSiteRemoveHTMLNode(parent)
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeIsEmptyImageWrapper(node *html.Node) bool {
|
|
if node == nil || node.Type != html.ElementNode || !strings.EqualFold(node.Data, "p") {
|
|
return false
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.TextNode && strings.TrimSpace(child.Data) == "" {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
if len(node.Attr) == 0 {
|
|
return true
|
|
}
|
|
for _, attr := range node.Attr {
|
|
key := strings.ToLower(strings.TrimSpace(attr.Key))
|
|
value := strings.ToLower(strings.TrimSpace(attr.Val))
|
|
if key == "class" && strings.Contains(value, "article-editor-image") {
|
|
return true
|
|
}
|
|
if key == "align" && value == "center" {
|
|
continue
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func enterpriseSitePruneEmptyImageWrapperNodes(nodes []*html.Node) []*html.Node {
|
|
if len(nodes) == 0 {
|
|
return nodes
|
|
}
|
|
pruned := make([]*html.Node, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
if enterpriseSiteHTMLNodeIsEmptyImageWrapper(node) {
|
|
continue
|
|
}
|
|
pruned = append(pruned, node)
|
|
}
|
|
return pruned
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteAssetURL(ctx context.Context, tenantID int64, value string, baseURL string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || strings.HasPrefix(strings.ToLower(value), "data:") {
|
|
return value
|
|
}
|
|
if dataURI := s.enterpriseSiteDataURIFromAssetURL(ctx, tenantID, value); dataURI != "" {
|
|
return dataURI
|
|
}
|
|
if enterpriseSiteLooksLikeSaaSAssetURL(value) {
|
|
return ""
|
|
}
|
|
return enterpriseSiteAbsoluteAssetURL(value, baseURL)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) prepareEnterpriseSiteImageURLsFallback(ctx context.Context, tenantID int64, fragment string, baseURL string) string {
|
|
return enterpriseSiteImageAttrPattern.ReplaceAllStringFunc(fragment, func(match string) string {
|
|
parts := enterpriseSiteImageAttrPattern.FindStringSubmatch(match)
|
|
if len(parts) != 4 {
|
|
return match
|
|
}
|
|
return parts[1] + s.prepareEnterpriseSiteAssetURL(ctx, tenantID, parts[2], baseURL) + parts[3]
|
|
})
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromAssetURL(ctx context.Context, tenantID int64, value string) string {
|
|
objectKey, ok := s.enterpriseSiteObjectKeyFromAssetURL(ctx, tenantID, value)
|
|
if !ok || s == nil || s.storage == nil {
|
|
return ""
|
|
}
|
|
return s.enterpriseSiteDataURIFromObjectKey(ctx, objectKey)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageNode(ctx context.Context, tenantID int64, node *html.Node) string {
|
|
assetID := enterpriseSiteImageNodeAssetID(node)
|
|
if assetID <= 0 {
|
|
return ""
|
|
}
|
|
return s.enterpriseSiteDataURIFromImageAssetID(ctx, tenantID, assetID)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromImageAssetID(ctx context.Context, tenantID, assetID int64) string {
|
|
objectKey, ok := s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
|
|
if !ok || s == nil || s.storage == nil {
|
|
return ""
|
|
}
|
|
return s.enterpriseSiteDataURIFromObjectKey(ctx, objectKey)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteDataURIFromObjectKey(ctx context.Context, objectKey string) string {
|
|
content, err := s.storage.GetBytes(ctx, objectKey)
|
|
if err != nil || len(content) == 0 {
|
|
return ""
|
|
}
|
|
contentType := enterpriseSiteImageContentType(content, objectKey)
|
|
if contentType == "" {
|
|
return ""
|
|
}
|
|
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(content)
|
|
}
|
|
|
|
func enterpriseSiteImageContentType(content []byte, objectKey string) string {
|
|
contentType := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
|
|
if strings.HasPrefix(contentType, "image/") {
|
|
return contentType
|
|
}
|
|
lowerKey := strings.ToLower(strings.TrimSpace(objectKey))
|
|
switch {
|
|
case strings.HasSuffix(lowerKey, ".webp"):
|
|
return "image/webp"
|
|
case strings.HasSuffix(lowerKey, ".png"):
|
|
return "image/png"
|
|
case strings.HasSuffix(lowerKey, ".jpg"), strings.HasSuffix(lowerKey, ".jpeg"):
|
|
return "image/jpeg"
|
|
case strings.HasSuffix(lowerKey, ".gif"):
|
|
return "image/gif"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteImageNodeAssetID(node *html.Node) int64 {
|
|
if node == nil {
|
|
return 0
|
|
}
|
|
for _, attr := range node.Attr {
|
|
key := strings.ToLower(strings.TrimSpace(attr.Key))
|
|
if key != "data-asset-id" && key != "asset-id" {
|
|
continue
|
|
}
|
|
id, err := strconv.ParseInt(strings.TrimSpace(attr.Val), 10, 64)
|
|
if err == nil && id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteObjectKeyFromAssetURL(ctx context.Context, tenantID int64, value string) (string, bool) {
|
|
if objectKey, ok := enterpriseSiteObjectKeyFromPublicAssetURL(value, tenantID, s.enterpriseSiteAssetTokenSecret()); ok {
|
|
return objectKey, true
|
|
}
|
|
assetID, ok := enterpriseSiteAssetIDFromURL(value)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return s.loadEnterpriseSiteImageAssetObjectKey(ctx, tenantID, assetID)
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) loadEnterpriseSiteImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, bool) {
|
|
if s == nil || s.pool == nil || tenantID <= 0 || assetID <= 0 {
|
|
return "", false
|
|
}
|
|
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 {
|
|
return "", false
|
|
}
|
|
if !enterpriseSiteObjectKeyAllowedForTenant(objectKey, tenantID) {
|
|
return "", false
|
|
}
|
|
return objectKey, true
|
|
}
|
|
|
|
func (s *EnterpriseSiteService) enterpriseSiteAssetTokenSecret() string {
|
|
if s == nil || s.cfg == nil || s.cfg.Current() == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(s.cfg.Current().JWT.Secret)
|
|
}
|
|
|
|
func enterpriseSiteObjectKeyFromPublicAssetURL(raw string, tenantID int64, secret string) (string, bool) {
|
|
token, ok := enterpriseSitePublicAssetTokenFromURL(raw)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
objectKey, valid := publicasset.ParseObjectKeyToken(token, secret)
|
|
if !valid {
|
|
objectKey, valid = publicasset.ObjectKeyFromToken(token)
|
|
}
|
|
if !valid || !enterpriseSiteObjectKeyAllowedForTenant(objectKey, tenantID) {
|
|
return "", false
|
|
}
|
|
return objectKey, true
|
|
}
|
|
|
|
func enterpriseSiteLooksLikeSaaSAssetURL(raw string) bool {
|
|
_, ok := enterpriseSitePublicAssetTokenFromURL(raw)
|
|
return ok
|
|
}
|
|
|
|
func enterpriseSitePublicAssetTokenFromURL(raw string) (string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", false
|
|
}
|
|
if token, ok := enterpriseSiteAssetTokenFromPath(raw); ok {
|
|
return token, true
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if token, ok := enterpriseSiteAssetTokenFromPath(parsed.Path); ok {
|
|
return token, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func enterpriseSiteAssetTokenFromPath(pathValue string) (string, bool) {
|
|
pathValue = strings.TrimSpace(pathValue)
|
|
if pathValue == "" {
|
|
return "", false
|
|
}
|
|
pathValue = strings.TrimLeft(pathValue, "/")
|
|
for _, prefix := range []string{"api/public/assets/", "api/desktop/content/assets/", "public/assets/", "assets/"} {
|
|
if strings.HasPrefix(pathValue, prefix) {
|
|
token := strings.TrimPrefix(pathValue, prefix)
|
|
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), strings.TrimSpace(token) != ""
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func enterpriseSiteAssetIDFromURL(raw string) (int64, bool) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
value := strings.TrimSpace(parsed.Query().Get("data-asset-id"))
|
|
if value == "" {
|
|
value = strings.TrimSpace(parsed.Query().Get("asset_id"))
|
|
}
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
id, err := strconv.ParseInt(value, 10, 64)
|
|
return id, err == nil && id > 0
|
|
}
|
|
|
|
func enterpriseSiteObjectKeyAllowedForTenant(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 enterpriseSiteAbsoluteImageURLs(fragment string, baseURL string) string {
|
|
if strings.TrimSpace(fragment) == "" || !strings.Contains(strings.ToLower(fragment), "<img") {
|
|
return fragment
|
|
}
|
|
contextNode := &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: "body",
|
|
DataAtom: atom.Body,
|
|
}
|
|
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
|
|
if err != nil {
|
|
return enterpriseSiteAbsoluteImageURLsFallback(fragment, baseURL)
|
|
}
|
|
for _, node := range nodes {
|
|
enterpriseSiteRewriteImageNodeURLs(node, baseURL)
|
|
}
|
|
var builder strings.Builder
|
|
for _, node := range nodes {
|
|
if err := html.Render(&builder, node); err != nil {
|
|
return enterpriseSiteAbsoluteImageURLsFallback(fragment, baseURL)
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func enterpriseSiteRewriteImageNodeURLs(node *html.Node, baseURL string) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
if node.Type == html.ElementNode && strings.EqualFold(node.Data, "img") {
|
|
for idx := range node.Attr {
|
|
key := strings.ToLower(strings.TrimSpace(node.Attr[idx].Key))
|
|
if key != "src" && key != "data-src" && key != "_src" {
|
|
continue
|
|
}
|
|
node.Attr[idx].Val = enterpriseSiteAbsoluteAssetURL(node.Attr[idx].Val, baseURL)
|
|
}
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
enterpriseSiteRewriteImageNodeURLs(child, baseURL)
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteAbsoluteImageURLsFallback(fragment string, baseURL string) string {
|
|
return enterpriseSiteImageAttrPattern.ReplaceAllStringFunc(fragment, func(match string) string {
|
|
parts := enterpriseSiteImageAttrPattern.FindStringSubmatch(match)
|
|
if len(parts) != 4 {
|
|
return match
|
|
}
|
|
return parts[1] + enterpriseSiteAbsoluteAssetURL(parts[2], baseURL) + parts[3]
|
|
})
|
|
}
|
|
|
|
func enterpriseSiteAbsoluteAssetURL(value string, baseURL string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || strings.HasPrefix(strings.ToLower(value), "data:") {
|
|
return value
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err == nil && parsed.Scheme != "" {
|
|
return value
|
|
}
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" {
|
|
return value
|
|
}
|
|
if strings.HasPrefix(value, "//") {
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil || base.Scheme == "" {
|
|
return value
|
|
}
|
|
return base.Scheme + ":" + value
|
|
}
|
|
if strings.HasPrefix(value, "/") {
|
|
return baseURL + value
|
|
}
|
|
return baseURL + "/" + strings.TrimLeft(value, "/")
|
|
}
|
|
|
|
func enterpriseSiteStripLeadingTitleHeadingHTML(fragment string, title string) string {
|
|
normalizedTitle := enterpriseSiteNormalizeHeadingText(title)
|
|
if normalizedTitle == "" || strings.TrimSpace(fragment) == "" {
|
|
return fragment
|
|
}
|
|
contextNode := &html.Node{
|
|
Type: html.ElementNode,
|
|
Data: "body",
|
|
DataAtom: atom.Body,
|
|
}
|
|
nodes, err := html.ParseFragment(strings.NewReader(fragment), contextNode)
|
|
if err != nil {
|
|
return enterpriseSiteStripLeadingTitleHeadingHTMLFallback(fragment, normalizedTitle)
|
|
}
|
|
|
|
first := enterpriseSiteFirstMeaningfulHTMLNode(nodes)
|
|
if enterpriseSiteHTMLNodeIsTitleHeading(first, normalizedTitle) {
|
|
return enterpriseSiteRenderHTMLNodes(enterpriseSiteWithoutTopLevelHTMLNode(nodes, first), fragment)
|
|
}
|
|
if enterpriseSiteHTMLNodeIsContentWrapper(first) {
|
|
child := enterpriseSiteFirstMeaningfulHTMLChild(first)
|
|
if enterpriseSiteHTMLNodeIsTitleHeading(child, normalizedTitle) {
|
|
first.RemoveChild(child)
|
|
if enterpriseSiteHTMLNodeIsSkippable(first) {
|
|
nodes = enterpriseSiteWithoutTopLevelHTMLNode(nodes, first)
|
|
}
|
|
return enterpriseSiteRenderHTMLNodes(nodes, fragment)
|
|
}
|
|
}
|
|
return fragment
|
|
}
|
|
|
|
func enterpriseSiteStripLeadingTitleHeadingHTMLFallback(fragment string, normalizedTitle string) string {
|
|
match := regexp.MustCompile(`(?is)^\s*<h[12]\b[^>]*>(.*?)</h[12]>\s*`).FindStringSubmatchIndex(fragment)
|
|
if len(match) != 4 {
|
|
return fragment
|
|
}
|
|
heading := enterpriseSiteSummaryTagPattern.ReplaceAllString(fragment[match[2]:match[3]], " ")
|
|
if enterpriseSiteNormalizeHeadingText(heading) != normalizedTitle {
|
|
return fragment
|
|
}
|
|
return strings.TrimSpace(fragment[match[1]:])
|
|
}
|
|
|
|
func enterpriseSiteStripLeadingTitleHeadingMarkdown(markdownContent string, title string) string {
|
|
normalizedTitle := enterpriseSiteNormalizeHeadingText(title)
|
|
if normalizedTitle == "" || strings.TrimSpace(markdownContent) == "" {
|
|
return markdownContent
|
|
}
|
|
normalizedMarkdown := strings.TrimPrefix(markdownContent, "\uFEFF")
|
|
lineEnd := strings.IndexByte(normalizedMarkdown, '\n')
|
|
firstLine := normalizedMarkdown
|
|
rest := ""
|
|
if lineEnd >= 0 {
|
|
firstLine = strings.TrimSuffix(normalizedMarkdown[:lineEnd], "\r")
|
|
rest = normalizedMarkdown[lineEnd+1:]
|
|
}
|
|
match := enterpriseSiteMarkdownHeadingPattern.FindStringSubmatch(strings.TrimSpace(firstLine))
|
|
if len(match) == 2 && enterpriseSiteNormalizeHeadingText(match[1]) == normalizedTitle {
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
if enterpriseSiteNormalizeHeadingText(firstLine) == normalizedTitle {
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
return markdownContent
|
|
}
|
|
|
|
func enterpriseSiteNormalizeHeadingText(value string) string {
|
|
value = strings.TrimPrefix(strings.TrimSpace(value), "\uFEFF")
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func enterpriseSiteFirstMeaningfulHTMLNode(nodes []*html.Node) *html.Node {
|
|
for _, node := range nodes {
|
|
if !enterpriseSiteHTMLNodeIsSkippable(node) {
|
|
return node
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func enterpriseSiteFirstMeaningfulHTMLChild(node *html.Node) *html.Node {
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if !enterpriseSiteHTMLNodeIsSkippable(child) {
|
|
return child
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeIsTitleHeading(node *html.Node, normalizedTitle string) bool {
|
|
if node == nil || node.Type != html.ElementNode {
|
|
return false
|
|
}
|
|
tagName := strings.ToLower(strings.TrimSpace(node.Data))
|
|
if tagName != "h1" && tagName != "h2" {
|
|
return false
|
|
}
|
|
return enterpriseSiteNormalizeHeadingText(enterpriseSiteHTMLNodeText(node)) == normalizedTitle
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeText(node *html.Node) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
var builder strings.Builder
|
|
enterpriseSiteAppendHTMLNodeText(&builder, node)
|
|
return builder.String()
|
|
}
|
|
|
|
func enterpriseSiteAppendHTMLNodeText(builder *strings.Builder, node *html.Node) {
|
|
if node == nil {
|
|
return
|
|
}
|
|
if node.Type == html.TextNode {
|
|
builder.WriteString(node.Data)
|
|
return
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
enterpriseSiteAppendHTMLNodeText(builder, child)
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeIsContentWrapper(node *html.Node) bool {
|
|
if node == nil || node.Type != html.ElementNode {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(node.Data)) {
|
|
case "article", "div", "main", "section":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeIsSkippable(node *html.Node) bool {
|
|
if node == nil {
|
|
return true
|
|
}
|
|
switch node.Type {
|
|
case html.CommentNode:
|
|
return true
|
|
case html.TextNode:
|
|
return enterpriseSiteNormalizeHeadingText(node.Data) == ""
|
|
case html.ElementNode:
|
|
if strings.EqualFold(node.Data, "br") {
|
|
return true
|
|
}
|
|
return enterpriseSiteNormalizeHeadingText(enterpriseSiteHTMLNodeText(node)) == "" && !enterpriseSiteHTMLNodeHasNonTextContent(node)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func enterpriseSiteHTMLNodeHasNonTextContent(node *html.Node) bool {
|
|
if node == nil {
|
|
return false
|
|
}
|
|
if node.Type == html.ElementNode {
|
|
switch strings.ToLower(strings.TrimSpace(node.Data)) {
|
|
case "audio", "canvas", "embed", "iframe", "img", "object", "svg", "table", "video":
|
|
return true
|
|
}
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if enterpriseSiteHTMLNodeHasNonTextContent(child) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func enterpriseSiteWithoutTopLevelHTMLNode(nodes []*html.Node, remove *html.Node) []*html.Node {
|
|
if remove == nil {
|
|
return nodes
|
|
}
|
|
filtered := make([]*html.Node, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
if node != remove {
|
|
filtered = append(filtered, node)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func enterpriseSiteRenderHTMLNodes(nodes []*html.Node, fallback string) string {
|
|
var builder strings.Builder
|
|
for _, node := range nodes {
|
|
if err := html.Render(&builder, node); err != nil {
|
|
return fallback
|
|
}
|
|
}
|
|
return strings.TrimSpace(builder.String())
|
|
}
|
|
|
|
func markdownToEnterpriseSiteHTML(markdown string) (string, error) {
|
|
renderer := goldmark.New(
|
|
goldmark.WithExtensions(extension.GFM),
|
|
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
|
)
|
|
var out bytes.Buffer
|
|
if err := renderer.Convert([]byte(strings.TrimSpace(markdown)), &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func enterpriseSiteDescription(htmlContent, markdownContent string) string {
|
|
text := strings.TrimSpace(enterpriseSiteSummaryTagPattern.ReplaceAllString(htmlContent, ""))
|
|
if text == "" {
|
|
text = enterpriseSitePlainTextFromMarkdown(markdownContent)
|
|
}
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
if len([]rune(text)) <= 150 {
|
|
return text
|
|
}
|
|
runes := []rune(text)
|
|
return string(runes[:150])
|
|
}
|
|
|
|
func enterpriseSitePlainTextFromMarkdown(markdownContent string) string {
|
|
lines := strings.Split(strings.TrimSpace(markdownContent), "\n")
|
|
cleaned := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
line = strings.TrimLeft(line, "#")
|
|
line = strings.TrimSpace(line)
|
|
line = strings.TrimLeft(line, "-*")
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
cleaned = append(cleaned, line)
|
|
}
|
|
}
|
|
return strings.Join(cleaned, " ")
|
|
}
|
|
|
|
func sanitizeEnterpriseSiteError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := strings.TrimSpace(err.Error())
|
|
if message == "" {
|
|
return "CMS request failed"
|
|
}
|
|
for _, marker := range []string{"api_secret", "secret", "signature", "appid="} {
|
|
if strings.Contains(strings.ToLower(message), strings.ToLower(marker)) {
|
|
return "CMS request failed; please verify site credentials and plugin installation"
|
|
}
|
|
}
|
|
if len([]rune(message)) > 300 {
|
|
runes := []rune(message)
|
|
return string(runes[:300])
|
|
}
|
|
return message
|
|
}
|
|
|
|
func enterpriseSiteStringPointerValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func enterpriseSiteTextPointer(value pgtype.Text) *string {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
trimmed := strings.TrimSpace(value.String)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return &trimmed
|
|
}
|
|
|
|
func enterpriseSiteTimePointer(value pgtype.Timestamptz) *time.Time {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
return &value.Time
|
|
}
|