- SyncG15Office files each submission as a G15Office task with its screenshot through a fetchguard client limited to the configured host; a River job with 3 attempts, PHP's messages on failure, nothing sent when unconfigured - the replay of PHP's recorded G15Office exchange (classes/testdata) asserts the task JSON, headers and the screenshot part - settings screen (cabana field types only), read-only submissions list, manage_settings permission and navigation - embed.js served byte for byte at /plugins/golem15/feedback/assets/js/embed.js - feedback:import-settings copies the WinterCMS settings row once
248 lines
7.5 KiB
Go
248 lines
7.5 KiB
Go
package classes
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
|
)
|
|
|
|
// Config keys of the G15Office connection (config/feedback.php
|
|
// g15_office.*, G15_OFFICE_* in the WinterCMS plugin).
|
|
const (
|
|
ConfigBaseURL = "golem15.feedback.g15_office.base_url"
|
|
ConfigToken = "golem15.feedback.g15_office.token"
|
|
ConfigProject = "golem15.feedback.g15_office.project"
|
|
)
|
|
|
|
// G15OfficeTimeout bounds one G15Office request (the PHP client sets none).
|
|
const G15OfficeTimeout = 30 * time.Second
|
|
|
|
// ErrNotConfigured is G15OfficeClient::assertConfigured's failure; its text
|
|
// is PHP's, which the submission's g15office_error column keeps.
|
|
var ErrNotConfigured = errors.New("g15office is not configured (G15_OFFICE_BASE_URL / G15_OFFICE_TOKEN / G15_OFFICE_PROJECT).")
|
|
|
|
// G15OfficeClient is the port of G15OfficeClient: the support portal's task
|
|
// API, used to file a task per submission and attach its screenshot. Every
|
|
// request goes through a fetchguard.Client in AllowHostsMode limited to the
|
|
// base URL's host (https only, private addresses refused at dial time), so
|
|
// submission content can reach no other host. It sends what PHP's raw curl
|
|
// sent: a Bearer token, Accept: application/json and no User-Agent.
|
|
type G15OfficeClient struct {
|
|
baseURL string
|
|
token string
|
|
project string
|
|
client *fetchguard.Client
|
|
initErr error
|
|
}
|
|
|
|
// NewG15OfficeClient builds the client from the plugin config.
|
|
func NewG15OfficeClient(cfg *compass.Config) *G15OfficeClient {
|
|
var baseURL, token, project string
|
|
if cfg != nil {
|
|
baseURL, token, project = cfg.String(ConfigBaseURL), cfg.String(ConfigToken), cfg.String(ConfigProject)
|
|
}
|
|
return NewG15OfficeClientWith(baseURL, token, project, cfg)
|
|
}
|
|
|
|
// NewG15OfficeClientWith builds the client from explicit values; the base
|
|
// URL loses its trailing slashes, as PHP's rtrim does.
|
|
func NewG15OfficeClientWith(baseURL, token, project string, cfg *compass.Config) *G15OfficeClient {
|
|
c := &G15OfficeClient{
|
|
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
|
token: strings.TrimSpace(token),
|
|
project: strings.TrimSpace(project),
|
|
}
|
|
if !c.IsConfigured() {
|
|
return c
|
|
}
|
|
u, err := url.Parse(c.baseURL)
|
|
if err != nil || u.Hostname() == "" {
|
|
c.initErr = fmt.Errorf("g15office base URL %q has no host", c.baseURL)
|
|
return c
|
|
}
|
|
c.client, c.initErr = fetchguard.NewClient(fetchguard.Policy{
|
|
Mode: fetchguard.AllowHostsMode,
|
|
AllowHosts: []string{u.Hostname()},
|
|
Timeout: G15OfficeTimeout,
|
|
}, cfg)
|
|
return c
|
|
}
|
|
|
|
// IsConfigured reports whether the base URL, the token and the project
|
|
// are all set; without them nothing is ever sent.
|
|
func (c *G15OfficeClient) IsConfigured() bool {
|
|
return c != nil && c.baseURL != "" && c.token != "" && c.project != ""
|
|
}
|
|
|
|
// TaskFields is the createTask body, in PHP's key order. Status is left
|
|
// out when empty (array_filter).
|
|
type TaskFields struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority string `json:"priority"`
|
|
}
|
|
|
|
// CreateTask is G15OfficeClient::createTask: POST the task JSON to
|
|
// <base>/_support/api/v1/projects/<project>/tasks and return its "data"
|
|
// object (with the task's hash id).
|
|
func (c *G15OfficeClient) CreateTask(ctx context.Context, fields TaskFields) (map[string]any, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
var body bytes.Buffer
|
|
enc := json.NewEncoder(&body)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(fields); err != nil {
|
|
return nil, err
|
|
}
|
|
target := c.baseURL + "/_support/api/v1/projects/" + c.project + "/tasks"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(bytes.TrimRight(body.Bytes(), "\n")))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("g15office create task failed: connection error.")
|
|
}
|
|
req.Header = c.headers()
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res, err := c.client.Send(req)
|
|
return decodeG15Office(res, err, "create task")
|
|
}
|
|
|
|
// AttachFile is G15OfficeClient::attachFile: POST the file as the
|
|
// multipart field "file" to <base>/_support/api/v1/tasks/<hashID>/attachments
|
|
// and return the attachment's "data" object.
|
|
func (c *G15OfficeClient) AttachFile(ctx context.Context, hashID, name, mimeType string, body io.Reader) (map[string]any, error) {
|
|
if err := c.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
target := c.baseURL + "/_support/api/v1/tasks/" + hashID + "/attachments"
|
|
res, err := c.client.PostMultipart(ctx, target, c.headers(), nil, []fetchguard.FormFile{{
|
|
Field: "file", Filename: name, ContentType: mimeType, Body: body,
|
|
}})
|
|
return decodeG15Office(res, err, "attach file")
|
|
}
|
|
|
|
func (c *G15OfficeClient) ready() error {
|
|
if !c.IsConfigured() {
|
|
return ErrNotConfigured
|
|
}
|
|
return c.initErr
|
|
}
|
|
|
|
// headers are PHP's: the Bearer token and Accept: application/json, with an
|
|
// empty User-Agent so net/http adds none (raw curl sends none).
|
|
func (c *G15OfficeClient) headers() http.Header {
|
|
return http.Header{
|
|
"Authorization": {fetchguard.Bearer(c.token)},
|
|
"Accept": {"application/json"},
|
|
"User-Agent": {""},
|
|
}
|
|
}
|
|
|
|
// decodeG15Office is G15OfficeClient::decode: PHP reads no status code, only
|
|
// the body. A failed connection, a body that is not a JSON object or list,
|
|
// or a truthy "error" key fail with PHP's messages; otherwise the "data"
|
|
// value is returned (the whole body when it has none).
|
|
func decodeG15Office(res *fetchguard.Result, err error, action string) (map[string]any, error) {
|
|
if err != nil || res == nil {
|
|
return nil, fmt.Errorf("g15office %s failed: connection error.", action)
|
|
}
|
|
var decoded any
|
|
dec := json.NewDecoder(bytes.NewReader(res.Body))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&decoded); err != nil {
|
|
return nil, fmt.Errorf("g15office %s failed: invalid response body.", action)
|
|
}
|
|
switch v := decoded.(type) {
|
|
case map[string]any:
|
|
if phpTruthy(v["error"]) {
|
|
msg := "unknown error"
|
|
if m, ok := v["message"]; ok && m != nil {
|
|
msg = phpString(m)
|
|
}
|
|
return nil, fmt.Errorf("g15office %s failed: %s", action, msg)
|
|
}
|
|
if data, ok := v["data"].(map[string]any); ok {
|
|
return data, nil
|
|
}
|
|
if d, ok := v["data"]; ok && d != nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
return v, nil
|
|
case []any:
|
|
return map[string]any{}, nil
|
|
}
|
|
return nil, fmt.Errorf("g15office %s failed: invalid response body.", action)
|
|
}
|
|
|
|
// phpTruthy is PHP's !empty() of a decoded JSON value.
|
|
func phpTruthy(v any) bool {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return false
|
|
case bool:
|
|
return t
|
|
case string:
|
|
return t != "" && t != "0"
|
|
case json.Number:
|
|
f, err := t.Float64()
|
|
return err != nil || f != 0
|
|
case []any:
|
|
return len(t) > 0
|
|
case map[string]any:
|
|
return len(t) > 0
|
|
}
|
|
return true
|
|
}
|
|
|
|
// phpString is PHP's string interpolation of a decoded JSON scalar.
|
|
func phpString(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case json.Number:
|
|
return t.String()
|
|
case bool:
|
|
if t {
|
|
return "1"
|
|
}
|
|
return ""
|
|
case nil:
|
|
return ""
|
|
case []any, map[string]any:
|
|
return "Array"
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|
|
|
|
// taskID is the task's id as PHP keeps it ($task['id'] ?? null), "" when
|
|
// it is missing or falsy, which the job treats as no id.
|
|
func taskID(task map[string]any) string {
|
|
v, ok := task["id"]
|
|
if !ok || !phpTruthy(v) {
|
|
return ""
|
|
}
|
|
switch t := v.(type) {
|
|
case json.Number:
|
|
return t.String()
|
|
case bool:
|
|
return "1"
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
}
|
|
return phpString(v)
|
|
}
|