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) }