package app import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const pbootCMSPlatformID = "pbootcms" type cmsPublisher interface { Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) } type pbootCMSPublisher struct { client *http.Client } type enterpriseSiteCredential struct { SiteURL string AppID string Secret string DefaultAcode string DefaultMcode string DefaultScode string } type cmsPublishRequest struct { ArticleID int64 Title string ContentHTML string CategoryID string Keywords string Description string Author string Source string CoverURL string PublishType string } type cmsPublishResult struct { RemoteID string URL string Status string } type pbootCMSResponse struct { Code int `json:"code"` Data json.RawMessage `json:"data"` Msg string `json:"msg"` } type pbootCMSRequestError struct { message string routeUnavailable bool } type unsupportedCMSPublisher struct { cmsType string } func (err *pbootCMSRequestError) Error() string { return err.message } func (p unsupportedCMSPublisher) Ping(context.Context, enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) { return nil, nil, p.err() } func (p unsupportedCMSPublisher) Categories(context.Context, enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) { return nil, nil, p.err() } func (p unsupportedCMSPublisher) Publish(context.Context, enterpriseSiteCredential, cmsPublishRequest) (*cmsPublishResult, map[string]any, error) { return nil, nil, p.err() } func (p unsupportedCMSPublisher) err() error { cmsType := strings.TrimSpace(p.cmsType) if cmsType == "" { cmsType = "CMS" } return fmt.Errorf("unsupported cms type: %s", cmsType) } func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher { if client == nil { client = &http.Client{Timeout: 20 * time.Second} } return &pbootCMSPublisher{client: client} } func (p *pbootCMSPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) { var payload map[string]any if err := p.request(ctx, http.MethodGet, credential, "ping", nil, nil, &payload); err != nil { return nil, nil, err } capability := &EnterpriseSiteCapability{ CMSType: strings.TrimSpace(stringFromMap(payload, "cms_type")), PluginVersion: stringPointerFromMap(payload, "plugin_version"), SiteURL: strings.TrimSpace(stringFromMap(payload, "site_url")), SiteName: stringPointerFromMap(payload, "site_name"), APIAuth: boolFromMap(payload, "api_auth"), } if capability.CMSType == "" { capability.CMSType = pbootCMSPlatformID } if capability.SiteURL == "" { capability.SiteURL = credential.SiteURL } return capability, payload, nil } func (p *pbootCMSPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) { query := url.Values{} if strings.TrimSpace(credential.DefaultAcode) != "" { query.Set("acode", strings.TrimSpace(credential.DefaultAcode)) } if strings.TrimSpace(credential.DefaultMcode) != "" { query.Set("mcode", strings.TrimSpace(credential.DefaultMcode)) } var rawItems []map[string]any if err := p.request(ctx, http.MethodGet, credential, "categories", query, nil, &rawItems); err != nil { return nil, nil, err } items := make([]EnterpriseSiteCategoryItem, 0, len(rawItems)) for _, raw := range rawItems { id := firstStringFromMap(raw, "id", "scode", "remote_id") name := firstStringFromMap(raw, "title", "name") if strings.TrimSpace(id) == "" || strings.TrimSpace(name) == "" { continue } items = append(items, EnterpriseSiteCategoryItem{ RemoteID: strings.TrimSpace(id), ParentRemoteID: pbootCMSOptionalString(firstStringFromMap(raw, "parent_id", "pcode")), Name: strings.TrimSpace(name), Slug: pbootCMSOptionalString(firstStringFromMap(raw, "slug", "filename")), Raw: raw, }) } return items, rawItems, nil } func (p *pbootCMSPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) { categoryID := strings.TrimSpace(req.CategoryID) if categoryID == "" { categoryID = strings.TrimSpace(credential.DefaultScode) } body := map[string]any{ "scode": categoryID, "title": strings.TrimSpace(req.Title), "content": strings.TrimSpace(req.ContentHTML), "keywords": strings.TrimSpace(req.Keywords), "description": strings.TrimSpace(req.Description), "author": strings.TrimSpace(req.Author), "source": strings.TrimSpace(req.Source), "status": pbootCMSStatusFromPublishType(req.PublishType), } if strings.TrimSpace(req.CoverURL) != "" { body["ico"] = strings.TrimSpace(req.CoverURL) } if strings.TrimSpace(credential.DefaultAcode) != "" { body["acode"] = strings.TrimSpace(credential.DefaultAcode) } var payload map[string]any if err := p.request(ctx, http.MethodPost, credential, "publish", nil, body, &payload); err != nil { return nil, nil, err } remoteID := firstStringFromMap(payload, "id", "remote_id") result := &cmsPublishResult{ RemoteID: strings.TrimSpace(remoteID), URL: strings.TrimSpace(firstStringFromMap(payload, "url", "external_article_url")), Status: strings.TrimSpace(firstStringFromMap(payload, "status")), } if result.Status == "" { result.Status = "published" } return result, payload, nil } func (p *pbootCMSPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error { endpoints, err := pbootCMSEndpoints(credential.SiteURL, action) if err != nil { return err } var lastErr error for _, endpoint := range endpoints { if err := p.requestEndpoint(ctx, method, endpoint, credential, action, query, body, out); err != nil { lastErr = err if !pbootCMSRouteUnavailable(err) { break } continue } return nil } if lastErr != nil { return lastErr } return fmt.Errorf("pbootcms %s endpoint is unavailable", action) } func (p *pbootCMSPublisher) requestEndpoint(ctx context.Context, method string, endpoint *url.URL, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error { requestEndpoint := *endpoint requestQuery := requestEndpoint.Query() for key, values := range query { requestQuery.Del(key) for _, value := range values { requestQuery.Add(key, value) } } p.signQuery(credential, requestQuery) requestEndpoint.RawQuery = requestQuery.Encode() var reader io.Reader if body != nil { raw, marshalErr := json.Marshal(body) if marshalErr != nil { return fmt.Errorf("marshal pbootcms request: %w", marshalErr) } reader = bytes.NewReader(raw) } httpReq, err := http.NewRequestWithContext(ctx, method, requestEndpoint.String(), reader) if err != nil { return fmt.Errorf("build pbootcms request: %w", err) } httpReq.Header.Set("User-Agent", "GeoRankly-PBootCMS/1.0") if body != nil { httpReq.Header.Set("Content-Type", "application/json") } resp, err := p.client.Do(httpReq) if err != nil { return fmt.Errorf("request pbootcms %s: %w", action, err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) if err != nil { return fmt.Errorf("read pbootcms response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return &pbootCMSRequestError{ message: fmt.Sprintf("pbootcms %s returned http %d", action, resp.StatusCode), routeUnavailable: resp.StatusCode == http.StatusNotFound, } } var envelope pbootCMSResponse if err := json.Unmarshal(raw, &envelope); err != nil { rawText := strings.TrimSpace(string(raw)) return &pbootCMSRequestError{ message: fmt.Sprintf("parse pbootcms response: %v", err), routeUnavailable: pbootCMSLooksLikeMissingRoute(rawText), } } if envelope.Code != 1 { msg := strings.TrimSpace(envelope.Msg) if msg == "" { msg = strings.TrimSpace(string(envelope.Data)) } if msg == "" { msg = fmt.Sprintf("pbootcms %s failed with code %d", action, envelope.Code) } return &pbootCMSRequestError{ message: msg, routeUnavailable: strings.Contains(msg, "页面类文件不存在") || strings.Contains(strings.ToLower(msg), "not found"), } } if out == nil { return nil } if len(envelope.Data) == 0 || string(envelope.Data) == "null" { return nil } if err := json.Unmarshal(envelope.Data, out); err != nil { return fmt.Errorf("parse pbootcms data: %w", err) } return nil } func (p *pbootCMSPublisher) signQuery(credential enterpriseSiteCredential, query url.Values) { timestamp := strconv.FormatInt(time.Now().Unix(), 10) appid := strings.TrimSpace(credential.AppID) secret := strings.TrimSpace(credential.Secret) query.Set("appid", appid) query.Set("timestamp", timestamp) query.Set("signature", pbootCMSSignature(appid, secret, timestamp)) } func pbootCMSEndpoints(siteURL string, action string) ([]*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(siteURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("invalid pbootcms site url") } basePath := strings.TrimRight(parsed.Path, "/") cleanAction := strings.TrimLeft(action, "/") pretty := *parsed pretty.Path = basePath + "/api/GeoPublisher/" + cleanAction pretty.RawQuery = "" pretty.Fragment = "" queryEntry := *parsed queryEntry.Path = basePath + "/api.php" query := url.Values{} query.Set("p", "/GeoPublisher/"+cleanAction) queryEntry.RawQuery = query.Encode() queryEntry.Fragment = "" return []*url.URL{&pretty, &queryEntry}, nil } func pbootCMSSignature(appid, secret, timestamp string) string { first := md5.Sum([]byte(appid + secret + timestamp)) firstHex := hex.EncodeToString(first[:]) second := md5.Sum([]byte(firstHex)) return hex.EncodeToString(second[:]) } func pbootCMSStatusFromPublishType(publishType string) int { if strings.TrimSpace(publishType) == "draft" { return 0 } return 1 } func pbootCMSRouteUnavailable(err error) bool { requestErr, ok := err.(*pbootCMSRequestError) return ok && requestErr.routeUnavailable } func pbootCMSLooksLikeMissingRoute(text string) bool { text = strings.ToLower(strings.TrimSpace(text)) return strings.Contains(text, "页面类文件不存在") || strings.Contains(text, "页面不存在") || strings.Contains(text, "404") || strings.Contains(text, "not found") } func firstStringFromMap(payload map[string]any, keys ...string) string { for _, key := range keys { if value := stringFromMap(payload, key); strings.TrimSpace(value) != "" { return value } } return "" } func stringFromMap(payload map[string]any, key string) string { if payload == nil { return "" } value, ok := payload[key] if !ok || value == nil { return "" } switch typed := value.(type) { case string: return typed case float64: if typed == float64(int64(typed)) { return strconv.FormatInt(int64(typed), 10) } return strconv.FormatFloat(typed, 'f', -1, 64) case int: return strconv.Itoa(typed) case int64: return strconv.FormatInt(typed, 10) case bool: if typed { return "true" } return "false" default: return fmt.Sprintf("%v", typed) } } func stringPointerFromMap(payload map[string]any, key string) *string { if value := strings.TrimSpace(stringFromMap(payload, key)); value != "" { return &value } return nil } func boolFromMap(payload map[string]any, key string) bool { if payload == nil { return false } switch value := payload[key].(type) { case bool: return value case string: return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1" case float64: return value != 0 default: return false } } func pbootCMSOptionalString(value string) *string { value = strings.TrimSpace(value) if value == "" || value == "0" { return nil } return &value }