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
// /_support/api/v1/projects//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 /_support/api/v1/tasks//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)
}