feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer
- 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
This commit is contained in:
@@ -2,16 +2,36 @@ package classes_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
// The plugin registers itself; pgtest activates it.
|
||||
_ "git.golem15.com/golem15/sm-feedback-plugin"
|
||||
"git.golem15.com/golem15/sm-feedback-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest"
|
||||
"git.golem15.com/golem15/sm-feedback-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/tide"
|
||||
"gocloud.dev/blob"
|
||||
_ "gocloud.dev/blob/memblob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func mustHex(t *testing.T, s string) []byte {
|
||||
@@ -76,3 +96,278 @@ func TestImageGuardCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roundTrip adapts a func to http.RoundTripper.
|
||||
type roundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func jsonAnswer(status int, body string) roundTrip {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// syncEnv is an app over the shared test database with a memory uploads
|
||||
// bucket, for StoreSubmission and SyncG15Office.
|
||||
func syncEnv(t *testing.T) (*backpack.App, *gorm.DB, *blob.Bucket) {
|
||||
t.Helper()
|
||||
gdb := pgtest.DB(t)
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-classes\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, pgtest.SQL(t), gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket, err := blob.OpenBucket(t.Context(), "mem://")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
return app, gdb, bucket
|
||||
}
|
||||
|
||||
// recordedSubmission stores the submission PHP filed when the sidecar was
|
||||
// recorded (the same fields and screenshot) and returns it.
|
||||
func recordedSubmission(t *testing.T, app *backpack.App, gdb *gorm.DB, bucket *blob.Bucket) *models.Submission {
|
||||
t.Helper()
|
||||
str := func(s string) *string { return &s }
|
||||
sub := &models.Submission{
|
||||
Message: "Okładka albumu\tjest zła.\n\nDruga linia z dłuższym opisem problemu, który na pewno przekracza osiemdziesiąt znaków tytułu zadania.",
|
||||
Type: "feature",
|
||||
Email: str("reporter@parity.test"),
|
||||
PageURL: "https://app.parity.test/albums/7?tab=photos",
|
||||
UserAgent: str("Mozilla/5.0 (X11; Linux x86_64) Parity/1.0"),
|
||||
ConsoleLog: str("[log] " + strings.Repeat("żółć ąę ", 520)),
|
||||
}
|
||||
shot := mustHex(t, "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc0000001949444154789c626160a8906360c0442c20021b189c128001005509023d9970f43c0000000049454e44ae426082")
|
||||
if err := classes.StoreSubmission(t.Context(), app, gdb, bucket, sub, &classes.Screenshot{Filename: "screenshot.png", Data: shot}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func reload(t *testing.T, gdb *gorm.DB, id uint) models.Submission {
|
||||
t.Helper()
|
||||
var s models.Submission
|
||||
if err := gdb.Take(&s, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultSettings(t *testing.T, gdb *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Background: Cleanup runs it after the test's context is cancelled.
|
||||
if _, err := models.Instance(context.Background(), gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const parityToken = "parityG15OfficeTokenNotReal01"
|
||||
|
||||
func parityClient() *classes.G15OfficeClient {
|
||||
return classes.NewG15OfficeClientWith("https://office.parity.test/", parityToken, "parity-project", nil)
|
||||
}
|
||||
|
||||
// TestSyncG15Office replays the G15Office exchange PHP's
|
||||
// SyncFeedbackToG15Office made (testdata/g15office-task.upstream.yaml,
|
||||
// recorded through the upstream proxy): the task JSON, the Bearer token,
|
||||
// Accept: application/json, no User-Agent, the screenshot as the multipart
|
||||
// part "file" with its name, type and sha256. The submission ends as PHP's
|
||||
// did (sent, task tskParity01). The failure paths store status failed and
|
||||
// PHP's message and return the error so the job is retried; an unconfigured
|
||||
// client sends nothing.
|
||||
func TestSyncG15Office(t *testing.T) {
|
||||
app, gdb, bucket := syncEnv(t)
|
||||
defaultSettings(t, gdb)
|
||||
|
||||
t.Run("php-recorded", func(t *testing.T) {
|
||||
sub := recordedSubmission(t, app, gdb, bucket)
|
||||
sidecar, err := tide.LoadUpstream(filepath.Join("testdata", "g15office-task.upstream.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := tide.OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Set("secret:g15office-token", parityToken)
|
||||
fake := tide.NewUpstreamFake(sidecar, store)
|
||||
ctx := fetchguard.WithTransport(t.Context(), fake)
|
||||
if err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fake.Verify(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := reload(t, gdb, sub.ID)
|
||||
if got.Status != models.StatusSent || got.G15OfficeTaskID == nil || *got.G15OfficeTaskID != "tskParity01" || got.G15OfficeError != nil {
|
||||
t.Fatalf("submission %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
failing := func(t *testing.T, client *classes.G15OfficeClient, rt http.RoundTripper, wantErr string) {
|
||||
t.Helper()
|
||||
sub := recordedSubmission(t, app, gdb, bucket)
|
||||
ctx := t.Context()
|
||||
if rt != nil {
|
||||
ctx = fetchguard.WithTransport(ctx, rt)
|
||||
}
|
||||
err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: client}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID})
|
||||
if err == nil || err.Error() != wantErr {
|
||||
t.Fatalf("error %v, want %q", err, wantErr)
|
||||
}
|
||||
got := reload(t, gdb, sub.ID)
|
||||
if got.Status != models.StatusFailed || got.G15OfficeError == nil || *got.G15OfficeError != wantErr || got.G15OfficeTaskID != nil {
|
||||
t.Fatalf("submission %+v", got)
|
||||
}
|
||||
}
|
||||
never := roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
t.Errorf("unexpected request to %s", r.URL)
|
||||
return nil, errors.New("no request expected")
|
||||
})
|
||||
|
||||
t.Run("unconfigured-sends-nothing", func(t *testing.T) {
|
||||
for _, c := range []*classes.G15OfficeClient{
|
||||
classes.NewG15OfficeClient(nil),
|
||||
classes.NewG15OfficeClientWith("https://office.parity.test", "", "parity-project", nil),
|
||||
classes.NewG15OfficeClientWith("https://office.parity.test", parityToken, "", nil),
|
||||
classes.NewG15OfficeClientWith("", parityToken, "parity-project", nil),
|
||||
} {
|
||||
if c.IsConfigured() {
|
||||
t.Fatal("a partial configuration counts as configured")
|
||||
}
|
||||
failing(t, c, never, "g15office is not configured (G15_OFFICE_BASE_URL / G15_OFFICE_TOKEN / G15_OFFICE_PROJECT).")
|
||||
}
|
||||
})
|
||||
t.Run("error-envelope", func(t *testing.T) {
|
||||
failing(t, parityClient(), jsonAnswer(404, `{"error":true,"message":"Project not found"}`), "g15office create task failed: Project not found")
|
||||
failing(t, parityClient(), jsonAnswer(500, `{"error":1}`), "g15office create task failed: unknown error")
|
||||
})
|
||||
t.Run("missing-task-id", func(t *testing.T) {
|
||||
failing(t, parityClient(), jsonAnswer(201, `{"success":true,"data":{"number":7}}`), "g15office did not return a task id.")
|
||||
failing(t, parityClient(), jsonAnswer(201, `{"data":{"id":""}}`), "g15office did not return a task id.")
|
||||
})
|
||||
t.Run("invalid-body", func(t *testing.T) {
|
||||
failing(t, parityClient(), jsonAnswer(502, `<html>Bad gateway</html>`), "g15office create task failed: invalid response body.")
|
||||
failing(t, parityClient(), jsonAnswer(200, `"ok"`), "g15office create task failed: invalid response body.")
|
||||
})
|
||||
t.Run("connection-error", func(t *testing.T) {
|
||||
failing(t, parityClient(), roundTrip(func(*http.Request) (*http.Response, error) { return nil, errors.New("dial failed") }), "g15office create task failed: connection error.")
|
||||
})
|
||||
t.Run("attach-failure", func(t *testing.T) {
|
||||
calls := 0
|
||||
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if strings.HasSuffix(r.URL.Path, "/tasks") {
|
||||
return jsonAnswer(201, `{"data":{"id":"tskX"}}`)(r)
|
||||
}
|
||||
return jsonAnswer(413, `{"error":true,"message":"File too large"}`)(r)
|
||||
})
|
||||
failing(t, parityClient(), rt, "g15office attach file failed: File too large")
|
||||
if calls != 2 {
|
||||
t.Fatalf("%d requests", calls)
|
||||
}
|
||||
})
|
||||
t.Run("http-base-url-is-refused", func(t *testing.T) {
|
||||
failing(t, classes.NewG15OfficeClientWith("http://office.parity.test", parityToken, "parity-project", nil), never, "g15office create task failed: connection error.")
|
||||
})
|
||||
t.Run("missing-submission", func(t *testing.T) {
|
||||
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), never), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: 999999999}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("settings-status-and-priority", func(t *testing.T) {
|
||||
status, priority := "Nowe", "urgent"
|
||||
if err := gdb.Model(&models.Settings{}).Where("id = ?", models.SettingsID).Updates(map[string]any{
|
||||
"g15office_task_status": status, "g15office_task_priority": priority,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { defaultSettings(t, gdb) })
|
||||
var body map[string]any
|
||||
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if strings.HasSuffix(r.URL.Path, "/tasks") {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Errorf("task body %s", raw)
|
||||
}
|
||||
return jsonAnswer(201, `{"data":{"id":"tskY"}}`)(r)
|
||||
}
|
||||
return jsonAnswer(201, `{"data":{"id":"att"}}`)(r)
|
||||
})
|
||||
sub := recordedSubmission(t, app, gdb, bucket)
|
||||
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), rt), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != status || body["priority"] != priority || body["type"] != "Feature" {
|
||||
t.Fatalf("task body %v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTaskText pins buildTitle and buildDescription: whitespace collapsed
|
||||
// as PCRE's \s (vertical tab included, no-break space not), Str::limit by
|
||||
// display width (wide characters count two columns), and the optional
|
||||
// metadata lines skipped for "" and "0" as PHP's truthiness does.
|
||||
func TestTaskText(t *testing.T) {
|
||||
limits := []struct{ in, want string }{
|
||||
{"abc", "abc"},
|
||||
{strings.Repeat("a", 80), strings.Repeat("a", 80)},
|
||||
{strings.Repeat("a", 81), strings.Repeat("a", 80) + "..."},
|
||||
{strings.Repeat("中", 41), strings.Repeat("中", 40) + "..."},
|
||||
{"a" + strings.Repeat("中", 40), "a" + strings.Repeat("中", 39) + "..."},
|
||||
{strings.Repeat("a", 79) + " b", strings.Repeat("a", 79) + "..."},
|
||||
{strings.Repeat("ą", 81), strings.Repeat("ą", 80) + "..."},
|
||||
}
|
||||
for _, c := range limits {
|
||||
if got := classes.StrLimit(c.in, 80, "..."); got != c.want {
|
||||
t.Errorf("StrLimit(%q) = %q", c.in, got)
|
||||
}
|
||||
}
|
||||
if got := classes.TaskTitle(" a\x0b\x0c b c \n"); got != "Feedback: a b c" {
|
||||
t.Errorf("title %q", got)
|
||||
}
|
||||
if w := classes.StrWidth("ą中😀アA"); w != 1+2+2+1+2 {
|
||||
t.Errorf("width %d", w)
|
||||
}
|
||||
zero, empty := "0", ""
|
||||
sub := &models.Submission{Message: "Hi", PageURL: "https://example.test/", Email: &zero, UserAgent: &empty, ConsoleLog: &zero}
|
||||
if got := classes.TaskDescription(sub); got != "Hi\n\n---\n\n- **Page:** https://example.test/" {
|
||||
t.Errorf("description %q", got)
|
||||
}
|
||||
email, ua, log := "a@example.test", "UA", "line1\nline2"
|
||||
sub = &models.Submission{Message: "Hi", PageURL: "/p", Email: &email, UserAgent: &ua, ConsoleLog: &log}
|
||||
want := "Hi\n\n---\n\n- **Page:** /p\n- **Reporter email:** a@example.test\n- **User agent:** UA\n\n**Console log:**\n```\nline1\nline2\n```"
|
||||
if got := classes.TaskDescription(sub); got != want {
|
||||
t.Errorf("description %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 4001)
|
||||
sub.ConsoleLog = &long
|
||||
if got := classes.TaskDescription(sub); !strings.HasSuffix(got, strings.Repeat("x", 4000)+"… (truncated)\n```") {
|
||||
t.Errorf("console log not cut at 4000 columns")
|
||||
}
|
||||
for typ, want := range map[string]string{"bug": "Bug", "feature": "Feature", "other": "Task", "x": "Task"} {
|
||||
if got := (models.Submission{Type: typ}).G15OfficeTaskType(); got != want {
|
||||
t.Errorf("type %s -> %s", typ, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
247
classes/g15office_client.go
Normal file
247
classes/g15office_client.go
Normal file
@@ -0,0 +1,247 @@
|
||||
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)
|
||||
}
|
||||
177
classes/str_limit.go
Normal file
177
classes/str_limit.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package classes
|
||||
|
||||
import "strings"
|
||||
|
||||
// StrLimit is Laravel's Str::limit: value as it is when its display width
|
||||
// (mb_strwidth) is at most limit, else the widest prefix that fits
|
||||
// (mb_strimwidth), right-trimmed, followed by end.
|
||||
func StrLimit(value string, limit int, end string) string {
|
||||
if StrWidth(value) <= limit {
|
||||
return value
|
||||
}
|
||||
width := 0
|
||||
cut := len(value)
|
||||
for i, r := range value {
|
||||
w := runeWidth(r)
|
||||
if width+w > limit {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
width += w
|
||||
}
|
||||
return strings.TrimRight(value[:cut], " \t\n\r\x00\x0B") + end
|
||||
}
|
||||
|
||||
// StrWidth is mb_strwidth(value, 'UTF-8'): 2 columns for each East Asian
|
||||
// wide or fullwidth character, 1 for every other one.
|
||||
func StrWidth(value string) int {
|
||||
n := 0
|
||||
for _, r := range value {
|
||||
n += runeWidth(r)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func runeWidth(r rune) int {
|
||||
lo, hi := 0, len(wideRanges)
|
||||
for lo < hi {
|
||||
mid := (lo + hi) / 2
|
||||
switch {
|
||||
case r < wideRanges[mid][0]:
|
||||
hi = mid
|
||||
case r > wideRanges[mid][1]:
|
||||
lo = mid + 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// wideRanges are the code points PHP's mbstring counts as two columns
|
||||
// (generated from mb_strwidth over U+0000..U+3FFFF on PHP 8.5).
|
||||
var wideRanges = [][2]rune{
|
||||
{0x1100, 0x115F},
|
||||
{0x231A, 0x231B},
|
||||
{0x2329, 0x232A},
|
||||
{0x23E9, 0x23EC},
|
||||
{0x23F0, 0x23F0},
|
||||
{0x23F3, 0x23F3},
|
||||
{0x25FD, 0x25FE},
|
||||
{0x2614, 0x2615},
|
||||
{0x2630, 0x2637},
|
||||
{0x2648, 0x2653},
|
||||
{0x267F, 0x267F},
|
||||
{0x268A, 0x268F},
|
||||
{0x2693, 0x2693},
|
||||
{0x26A1, 0x26A1},
|
||||
{0x26AA, 0x26AB},
|
||||
{0x26BD, 0x26BE},
|
||||
{0x26C4, 0x26C5},
|
||||
{0x26CE, 0x26CE},
|
||||
{0x26D4, 0x26D4},
|
||||
{0x26EA, 0x26EA},
|
||||
{0x26F2, 0x26F3},
|
||||
{0x26F5, 0x26F5},
|
||||
{0x26FA, 0x26FA},
|
||||
{0x26FD, 0x26FD},
|
||||
{0x2705, 0x2705},
|
||||
{0x270A, 0x270B},
|
||||
{0x2728, 0x2728},
|
||||
{0x274C, 0x274C},
|
||||
{0x274E, 0x274E},
|
||||
{0x2753, 0x2755},
|
||||
{0x2757, 0x2757},
|
||||
{0x2795, 0x2797},
|
||||
{0x27B0, 0x27B0},
|
||||
{0x27BF, 0x27BF},
|
||||
{0x2B1B, 0x2B1C},
|
||||
{0x2B50, 0x2B50},
|
||||
{0x2B55, 0x2B55},
|
||||
{0x2E80, 0x2E99},
|
||||
{0x2E9B, 0x2EF3},
|
||||
{0x2F00, 0x2FD5},
|
||||
{0x2FF0, 0x303E},
|
||||
{0x3041, 0x3096},
|
||||
{0x3099, 0x30FF},
|
||||
{0x3105, 0x312F},
|
||||
{0x3131, 0x318E},
|
||||
{0x3190, 0x31E5},
|
||||
{0x31EF, 0x321E},
|
||||
{0x3220, 0x3247},
|
||||
{0x3250, 0xA48C},
|
||||
{0xA490, 0xA4C6},
|
||||
{0xA960, 0xA97C},
|
||||
{0xAC00, 0xD7A3},
|
||||
{0xF900, 0xFAFF},
|
||||
{0xFE10, 0xFE19},
|
||||
{0xFE30, 0xFE52},
|
||||
{0xFE54, 0xFE66},
|
||||
{0xFE68, 0xFE6B},
|
||||
{0xFF01, 0xFF60},
|
||||
{0xFFE0, 0xFFE6},
|
||||
{0x16FE0, 0x16FE4},
|
||||
{0x16FF0, 0x16FF6},
|
||||
{0x17000, 0x18CD5},
|
||||
{0x18CFF, 0x18D1E},
|
||||
{0x18D80, 0x18DF2},
|
||||
{0x1AFF0, 0x1AFF3},
|
||||
{0x1AFF5, 0x1AFFB},
|
||||
{0x1AFFD, 0x1AFFE},
|
||||
{0x1B000, 0x1B122},
|
||||
{0x1B132, 0x1B132},
|
||||
{0x1B150, 0x1B152},
|
||||
{0x1B155, 0x1B155},
|
||||
{0x1B164, 0x1B167},
|
||||
{0x1B170, 0x1B2FB},
|
||||
{0x1D300, 0x1D356},
|
||||
{0x1D360, 0x1D376},
|
||||
{0x1F004, 0x1F004},
|
||||
{0x1F0CF, 0x1F0CF},
|
||||
{0x1F18E, 0x1F18E},
|
||||
{0x1F191, 0x1F19A},
|
||||
{0x1F200, 0x1F202},
|
||||
{0x1F210, 0x1F23B},
|
||||
{0x1F240, 0x1F248},
|
||||
{0x1F250, 0x1F251},
|
||||
{0x1F260, 0x1F265},
|
||||
{0x1F300, 0x1F320},
|
||||
{0x1F32D, 0x1F335},
|
||||
{0x1F337, 0x1F37C},
|
||||
{0x1F37E, 0x1F393},
|
||||
{0x1F3A0, 0x1F3CA},
|
||||
{0x1F3CF, 0x1F3D3},
|
||||
{0x1F3E0, 0x1F3F0},
|
||||
{0x1F3F4, 0x1F3F4},
|
||||
{0x1F3F8, 0x1F43E},
|
||||
{0x1F440, 0x1F440},
|
||||
{0x1F442, 0x1F4FC},
|
||||
{0x1F4FF, 0x1F53D},
|
||||
{0x1F54B, 0x1F54E},
|
||||
{0x1F550, 0x1F567},
|
||||
{0x1F57A, 0x1F57A},
|
||||
{0x1F595, 0x1F596},
|
||||
{0x1F5A4, 0x1F5A4},
|
||||
{0x1F5FB, 0x1F64F},
|
||||
{0x1F680, 0x1F6C5},
|
||||
{0x1F6CC, 0x1F6CC},
|
||||
{0x1F6D0, 0x1F6D2},
|
||||
{0x1F6D5, 0x1F6D8},
|
||||
{0x1F6DC, 0x1F6DF},
|
||||
{0x1F6EB, 0x1F6EC},
|
||||
{0x1F6F4, 0x1F6FC},
|
||||
{0x1F7E0, 0x1F7EB},
|
||||
{0x1F7F0, 0x1F7F0},
|
||||
{0x1F90C, 0x1F93A},
|
||||
{0x1F93C, 0x1F945},
|
||||
{0x1F947, 0x1F9FF},
|
||||
{0x1FA70, 0x1FA7C},
|
||||
{0x1FA80, 0x1FA8A},
|
||||
{0x1FA8E, 0x1FAC6},
|
||||
{0x1FAC8, 0x1FAC8},
|
||||
{0x1FACD, 0x1FADC},
|
||||
{0x1FADF, 0x1FAEA},
|
||||
{0x1FAEF, 0x1FAF8},
|
||||
{0x20000, 0x2FFFD},
|
||||
{0x30000, 0x3FFFD},
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-feedback-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The G15Office sync job's contract: SubmitFeedback queues one per stored
|
||||
// submission, the worker files it (SyncFeedbackToG15Office).
|
||||
const (
|
||||
@@ -20,3 +33,122 @@ type SyncG15OfficeArgs struct {
|
||||
|
||||
// Kind is the River job kind.
|
||||
func (SyncG15OfficeArgs) Kind() string { return SyncG15OfficeKind }
|
||||
|
||||
// SyncDeps are what one run of the job needs, resolved per run.
|
||||
type SyncDeps struct {
|
||||
DB *gorm.DB
|
||||
Bucket *blob.Bucket
|
||||
Client *G15OfficeClient
|
||||
}
|
||||
|
||||
// SyncG15Office is SyncFeedbackToG15Office::handle: it files the submission
|
||||
// as a G15Office task (title, Markdown description, type, the settings'
|
||||
// task status and priority) and attaches its screenshot. Success stores
|
||||
// status sent and the task id; any failure stores status failed and the
|
||||
// error and returns the error, so the job is retried. A submission that is
|
||||
// gone is skipped. An unconfigured client fails before any request, so
|
||||
// nothing leaves the application.
|
||||
func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) error {
|
||||
if deps.DB == nil {
|
||||
return errors.New("feedback: database is not configured")
|
||||
}
|
||||
db := deps.DB.WithContext(ctx)
|
||||
var rows []models.Submission
|
||||
if err := db.Where("id = ?", args.SubmissionID).Limit(1).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
sub := rows[0]
|
||||
id, err := fileTask(ctx, deps, &sub)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if uerr := db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
|
||||
"status": models.StatusFailed, "g15office_error": msg, "updated_at": time.Now(),
|
||||
}).Error; uerr != nil {
|
||||
return errors.Join(err, uerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
|
||||
"status": models.StatusSent, "g15office_task_id": id, "g15office_error": nil, "updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
|
||||
settings, err := models.Instance(ctx, deps.DB)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := TaskFields{
|
||||
Title: TaskTitle(sub.Message),
|
||||
Description: TaskDescription(sub),
|
||||
Type: sub.G15OfficeTaskType(),
|
||||
Priority: "normal",
|
||||
}
|
||||
if s := settings.G15OfficeTaskStatus; s != nil && phpTruthy(*s) {
|
||||
fields.Status = *s
|
||||
}
|
||||
if p := settings.G15OfficeTaskPriority; p != nil && phpTruthy(*p) {
|
||||
fields.Priority = *p
|
||||
}
|
||||
task, err := deps.Client.CreateTask(ctx, fields)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := taskID(task)
|
||||
if id == "" {
|
||||
return "", errors.New("g15office did not return a task id.")
|
||||
}
|
||||
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if file != nil {
|
||||
if deps.Bucket == nil {
|
||||
return "", errors.New("feedback: upload bucket is not configured")
|
||||
}
|
||||
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// whitespaceRun is PCRE's \s+ without the u flag: ASCII space, tab, LF, VT, FF
|
||||
// and CR (Go's \s leaves out VT).
|
||||
var whitespaceRun = regexp.MustCompile(`[\t\n\v\f\r ]+`)
|
||||
|
||||
// TaskTitle is SyncFeedbackToG15Office::buildTitle: "Feedback: " and the
|
||||
// message with whitespace runs collapsed, trimmed and cut to 80 columns.
|
||||
func TaskTitle(message string) string {
|
||||
collapsed := strings.Trim(whitespaceRun.ReplaceAllString(message, " "), " \t\n\r\x00\x0B")
|
||||
return "Feedback: " + StrLimit(collapsed, 80, "...")
|
||||
}
|
||||
|
||||
// TaskDescription is SyncFeedbackToG15Office::buildDescription: the message,
|
||||
// a rule, one bullet per metadata line (page, then the reporter email and
|
||||
// the user agent when set) and the console log in a code fence, cut to
|
||||
// 4000 columns, blocks separated by blank lines.
|
||||
func TaskDescription(sub *models.Submission) string {
|
||||
blocks := []string{sub.Message, "---"}
|
||||
meta := []string{"- **Page:** " + sub.PageURL}
|
||||
if sub.Email != nil && phpTruthy(*sub.Email) {
|
||||
meta = append(meta, "- **Reporter email:** "+*sub.Email)
|
||||
}
|
||||
if sub.UserAgent != nil && phpTruthy(*sub.UserAgent) {
|
||||
meta = append(meta, "- **User agent:** "+*sub.UserAgent)
|
||||
}
|
||||
blocks = append(blocks, strings.Join(meta, "\n"))
|
||||
if sub.ConsoleLog != nil && phpTruthy(*sub.ConsoleLog) {
|
||||
blocks = append(blocks, "**Console log:**\n```\n"+StrLimit(*sub.ConsoleLog, 4000, "… (truncated)")+"\n```")
|
||||
}
|
||||
return strings.Join(blocks, "\n\n")
|
||||
}
|
||||
|
||||
32
classes/testdata/g15office-task.upstream.yaml
vendored
Normal file
32
classes/testdata/g15office-task.upstream.yaml
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user