Files
geo/server/internal/tenant/app/enterprise_site_service.go
T
root 88c37e50b2
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Failing after 6m43s
feat(enterprise-site): add PbootCMS enterprise site publisher
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
2026-06-06 13:06:14 +08:00

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
}