package compose import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // postPayload is the R-1 request body. additionalProperties is false on the // schema, so this struct is the whole request: queue fields such as approved // and publication are not sent. visual_url and tags are omitted; this // strategy does not set them. type postPayload struct { PostID string `json:"post_id"` Body string `json:"body"` SourceEntryID string `json:"source_entry_id"` SourceEntryURL string `json:"source_entry_url"` ReviewedBy string `json:"reviewed_by"` SubjectDisplayName string `json:"subject_display_name"` SubjectConsent string `json:"subject_consent"` Variant string `json:"variant"` } // Publish POSTs an approved draft to {adapterBase}/v1/channel-posts. // It reads the draft from the queue and refuses when that record is not // approved, when reviewed_by is empty, or when subject_consent is not one of // the three caller-supplied enum values. HTTP 200 and 201 are recorded on // the draft. Redirects to another host are not followed. func (q *Queue) Publish(ctx context.Context, adapterBase, postID string) (Draft, error) { d, err := q.Get(postID) if err != nil { return Draft{}, err } if err := publishable(d); err != nil { return Draft{}, fmt.Errorf("publish %s: %w", postID, err) } endpoint, err := channelPostsURL(adapterBase) if err != nil { return Draft{}, err } payload := postPayload{ PostID: d.PostID, Body: d.Body, SourceEntryID: d.SourceEntryID, SourceEntryURL: d.SourceEntryURL, ReviewedBy: d.ReviewedBy, SubjectDisplayName: d.SubjectDisplayName, SubjectConsent: d.SubjectConsent, Variant: d.Variant, } raw, err := json.Marshal(payload) if err != nil { return Draft{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) if err != nil { return Draft{}, err } req.Header.Set("Content-Type", "application/json") resp, err := publishClient().Do(req) if err != nil { if resp != nil && resp.Body != nil { resp.Body.Close() } return Draft{}, err } defer resp.Body.Close() respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return Draft{}, err } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return Draft{}, fmt.Errorf("publish %s: HTTP %d", postID, resp.StatusCode) } d.Publication = &Publication{StatusCode: resp.StatusCode, Body: string(respBody)} if err := q.save(d); err != nil { return Draft{}, err } return d, nil } func publishable(d Draft) error { if !d.Approved { return ErrNotApproved } if strings.TrimSpace(d.ReviewedBy) == "" { return ErrReviewer } if _, ok := consentBases[d.SubjectConsent]; !ok { return fmt.Errorf("%w: got %q", ErrConsent, d.SubjectConsent) } return nil } func channelPostsURL(base string) (string, error) { u, err := url.Parse(base) if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return "", fmt.Errorf("adapter base URL must be absolute http(s)") } u.Path = strings.TrimRight(u.Path, "/") + "/v1/channel-posts" u.RawQuery = "" u.Fragment = "" return u.String(), nil } func publishClient() *http.Client { return &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) == 0 { return nil } if hostPort(req.URL) != hostPort(via[0].URL) { return fmt.Errorf("%w: %s", ErrCrossHost, req.URL.Host) } if len(via) >= 5 { return fmt.Errorf("too many redirects") } return nil }, } } func hostPort(u *url.URL) string { host := strings.ToLower(u.Hostname()) port := u.Port() if port == "" { if strings.EqualFold(u.Scheme, "https") { port = "443" } else { port = "80" } } return host + ":" + port }