From 77117f8fbc99279e2e918fd71ca8d7d72d073667 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 15:28:10 -0400 Subject: [PATCH 01/32] chore: add testify + sqlmock for new tests Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 38 +---- go.sum | 427 ++------------------------------------------------------- 2 files changed, 16 insertions(+), 449 deletions(-) diff --git a/go.mod b/go.mod index c1282c9..cb2afa9 100644 --- a/go.mod +++ b/go.mod @@ -36,28 +36,17 @@ require ( connectrpc.com/connect v1.18.1 // indirect connectrpc.com/otelconnect v0.7.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect - github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.9 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/bufbuild/protocompile v0.14.1 // indirect github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect - github.com/bufbuild/protovalidate-go v0.8.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/cgroups/v3 v3.0.5 // indirect - github.com/containerd/containerd v1.7.29 // indirect - github.com/containerd/containerd/api v1.8.0 // indirect - github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/containerd/ttrpc v1.2.7 // indirect - github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect @@ -66,26 +55,18 @@ require ( github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/felixge/fgprof v0.9.5 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gofrs/flock v0.12.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.25.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.3 // indirect - github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jdx/go-netrc v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect @@ -105,20 +86,16 @@ require ( github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/sys/reexec v0.1.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/signal v0.7.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/onsi/ginkgo/v2 v2.23.4 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runtime-spec v1.2.0 // indirect github.com/opencontainers/selinux v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkg/profile v1.7.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/rs/cors v1.11.1 // indirect @@ -132,27 +109,20 @@ require ( github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/square/certstrap v1.3.0 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect - github.com/urfave/cli v1.22.16 // indirect github.com/vbatts/tar-split v0.12.1 // indirect go.lsp.dev/jsonrpc2 v0.10.0 // indirect go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect go.lsp.dev/protocol v0.12.0 // indirect go.lsp.dev/uri v0.3.0 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.6.0 // indirect - go.step.sm/crypto v0.16.2 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/mock v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect @@ -164,12 +134,8 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.2 // indirect pluginrpc.com/pluginrpc v0.5.0 // indirect ) diff --git a/go.sum b/go.sum index 1361d47..52f7be3 100644 --- a/go.sum +++ b/go.sum @@ -1,148 +1,80 @@ -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.4-20250121211742-6d880cc6cc8d.1 h1:p5SFT60M93aMQhOz81VH3kPg8t1pp/Litae/1eSxie4= -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.4-20250121211742-6d880cc6cc8d.1/go.mod h1:umI0o7WWHv8lCbLjYUMzfjHKjyaIt2D89sIj1D9fqy0= buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1 h1:f6miF8tK6H+Ktad24WpnNfpHO75GRGk0rhJ1mxPXqgA= buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1/go.mod h1:rvbyamNtvJ4o3ExeCmaG5/6iHnu0vy0E+UQ+Ph0om8s= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1 h1:4erM3WLgEG/HIBrpBDmRbs1puhd7p0z7kNXDuhHthwM= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1/go.mod h1:novQBstnxcGpfKf8qGRATqn1anQKwMJIbH5Q581jibU= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250116203702-1c024d64352b.1 h1:1SDs5tEGoWWv2vmKLx2B0Bp+yfhlxiU4DaZUII8+Pvs= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250116203702-1c024d64352b.1/go.mod h1:o2AgVM1j3MczvxnMqfZTpiqGwK1VD4JbEagseY0QcjE= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250424215339-a457693b5db4.1 h1:/xe4ZIu8EIUaWoGeqTVQYzJ/bNMgkWUi706Puwo8A5Q= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250424215339-a457693b5db4.1/go.mod h1:9bOYLdeGZQKUUP21CviSP1G3U5qpgZ0k5Kn7i9Q9GfY= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.4-20250116203702-1c024d64352b.1 h1:uKJgSNHvwQUZ6+0dSnx9MtkZ+h/ORbkKym0rlzIjUSI= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.4-20250116203702-1c024d64352b.1/go.mod h1:Ua59W2s7uwPS5sGNgW08QewjBaPnUxOdpkWsuDvJ36Q= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250424215339-a457693b5db4.1 h1:/CrSdYpZi1tRhnkUNTY00LE8ro++bHwSBjLcW+dywM8= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250424215339-a457693b5db4.1/go.mod h1:bUPpZtzAkcnTA7OLfKCvkvkxEAC6dG/ZIlbnbUJicL4= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.4-20241007202033-cf42259fcbfc.1 h1:XmYgi9W/9oST2ZrfT3ucGWkzD9+Vd0ls9yhyZ8ae0KQ= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.4-20241007202033-cf42259fcbfc.1/go.mod h1:cxFpqWIC80Wm8YNo1038ocBmrF84uQ0IfL0uVdAu9ZY= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1 h1:trcsXBDm8exui7mvndZnvworCyBq1xuMnod2N0j79K8= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1/go.mod h1:OUbhXurY+VHFGn9FBxcRy8UB7HXk9NvJ2qCgifOMypQ= buf.build/go/app v0.1.0 h1:nlqD/h0rhIN73ZoiDElprrPiO2N6JV+RmNK34K29Ihg= buf.build/go/app v0.1.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= -buf.build/go/bufplugin v0.7.0 h1:Tq8FXBVfpMxhl3QR6P/gMQHROg1Ss7WhpyD4QVV61ds= -buf.build/go/bufplugin v0.7.0/go.mod h1:LuQzv36Ezu2zQIQUtwg4WJJFe58tXn1anL1IosAh6ik= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE= buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM= buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew= buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= -buf.build/go/protoyaml v0.3.1 h1:ucyzE7DRnjX+mQ6AH4JzN0Kg50ByHHu+yrSKbgQn2D4= -buf.build/go/protoyaml v0.3.1/go.mod h1:0TzNpFQDXhwbkXb/ajLvxIijqbve+vMQvWY/b3/Dzxg= buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= buf.build/go/standard v0.1.0 h1:g98T9IyvAl0vS3Pq8iVk6Cvj2ZiFvoUJRtfyGa0120U= buf.build/go/standard v0.1.0/go.mod h1:PiqpHz/7ZFq+kqvYhc/SK3lxFIB9N/aiH2CFC2JHIQg= -cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= -cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= -connectrpc.com/otelconnect v0.7.1 h1:scO5pOb0i4yUE66CnNrHeK1x51yq0bE0ehPg6WvzXJY= -connectrpc.com/otelconnect v0.7.1/go.mod h1:dh3bFgHBTb2bkqGCeVVOtHJreSns7uu9wwL2Tbz17ms= connectrpc.com/otelconnect v0.7.2 h1:WlnwFzaW64dN06JXU+hREPUGeEzpz3Acz2ACOmN8cMI= connectrpc.com/otelconnect v0.7.2/go.mod h1:JS7XUKfuJs2adhCnXhNHPHLz6oAaZniCJdSF00OZSew= -filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA= -github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6lLg= -github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bufbuild/buf v1.50.0 h1:gm/GtEUAkYaO27FgUesY4NpcwOpOdJgygjRKcdt41zE= -github.com/bufbuild/buf v1.50.0/go.mod h1:tlpWuRe4EjA4w7O+Z/R2k2Df2eJtKsds2hofIIPEoSY= github.com/bufbuild/buf v1.54.0 h1:0kjSRNNkrDZ1wSKXjcCn05IMeRq9aCUcwrORwH24x1c= github.com/bufbuild/buf v1.54.0/go.mod h1:UbGzdlFMzfg1GUmGL+AZUB/4bzGn+XPeqL3ru14iwd4= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/bufbuild/protoplugin v0.0.0-20250106231243-3a819552c9d9 h1:kAWER21DzhzU7ys8LL1WkSfbGkwXv+tM30hyEsYrW2k= -github.com/bufbuild/protoplugin v0.0.0-20250106231243-3a819552c9d9/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= -github.com/bufbuild/protovalidate-go v0.8.2 h1:sgzXHkHYP6HnAsL2Rd3I1JxkYUyEQUv9awU1PduMxbM= -github.com/bufbuild/protovalidate-go v0.8.2/go.mod h1:K6w8iPNAXBoIivVueSELbUeUl+MmeTQfCDSug85pn3M= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= -github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo= -github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= -github.com/containerd/containerd v1.7.25 h1:khEQOAXOEJalRO228yzVsuASLH42vT7DIo9Ss+9SMFQ= -github.com/containerd/containerd v1.7.25/go.mod h1:tWfHzVI0azhw4CT2vaIjsb2CoV4LJ9PrMPaULAr21Ok= -github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE= -github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= -github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVMyTRdsD2bS0= -github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= -github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= -github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= -github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= -github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= +github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -151,56 +83,27 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v27.5.1+incompatible h1:JB9cieUT9YNiMITtIsguaN55PLOHhBSz3LKVc6cqWaY= -github.com/docker/cli v27.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k= -github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8= -github.com/docker/docker v27.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= -github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= -github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= -github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= -github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= -github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= -github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -208,73 +111,23 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= -github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= -github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20250202011525-fc3143867406 h1:wlQI2cYY0BsWmmPPAnxfQ8SDW0S3Jasn+4B8kXFxprg= -github.com/google/pprof v0.0.0-20250202011525-fc3143867406/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4= -github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0 h1:VD1gqscl4nYs1YxVuSdemTrSgTKrwOWDK0FVFMqm+Cg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.0/go.mod h1:4EgsQoS4TOhJizV+JTFg40qx1Ofh3XmXEQNBpgvNT40= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c h1:kQWxfPIHVLbgLzphqk3QUflDy9QdksZR4ygR807bpy0= -github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= -github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= -github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= @@ -285,11 +138,7 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= @@ -298,20 +147,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -330,10 +173,6 @@ github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= -github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= -github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= -github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= @@ -342,61 +181,31 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= -github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= -github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= -github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8= -github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U= github.com/opencontainers/selinux v1.13.0 h1:Zza88GWezyT7RLql12URvoxsbLfjFx988+LGaWfbL84= github.com/opencontainers/selinux v1.13.0/go.mod h1:XxWTed+A/s5NNq4GmYScVy+9jzXhGBVEOAyucdRUY8s= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.49.0 h1:w5iJHXwHxs1QxyBv1EHKuC50GX5to8mJAxvtnttJp94= -github.com/quic-go/quic-go v0.49.0/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= -github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc= -github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ= github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -409,58 +218,37 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.4.1 h1:KLGaLSW0jrmhB58Nn4+98spfvPvmo4Ci1P/WIQ9wn7w= github.com/segmentio/encoding v0.4.1/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/square/certstrap v1.3.0 h1:N9P0ZRA+DjT8pq5fGDj0z3FjafRKnBDypP0QHpMlaAk= -github.com/square/certstrap v1.3.0/go.mod h1:wGZo9eE1B7WX2GKBn0htJ+B3OuRl2UsdCFySNooy9hU= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= -github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= -github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= -github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= @@ -469,60 +257,28 @@ go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= -go.step.sm/crypto v0.16.2 h1:Pr9aazTwWBBZNogUsOqhOrPSdwAa9pPs+lMB602lnDA= -go.step.sm/crypto v0.16.2/go.mod h1:1WkTOTY+fOX/RY4TnZREp6trQAsBHRQ7nu6QJBiNQF8= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -531,191 +287,43 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210915083310-ed5796bab164/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= -golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287 h1:A2ni10G3UlplFrWdCDJTl7D7mJ7GSRm37S+PDimaKRw= -google.golang.org/genproto/googleapis/api v0.0.0-20250127172529-29210b9bc287/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -723,10 +331,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -735,10 +339,7 @@ gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= From ea2456c932734ce76486523c97ad4c65c82c5682 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:12:47 -0400 Subject: [PATCH 02/32] refactor(auth): migrate to N-scopes-per-key model - ApiKeyResult.ScopeRead -> Scopes map[string]struct{} + HasScope() - ValidateApiKey rewritten to JOIN xf_cav7_api_key_scope_def and _scope - gRPC interceptor attaches *ApiKeyResult to handler ctx, drops ScopeRead gate - HTTP middleware drops ScopeRead gate (validation still fail-fast on bad tokens) - All milpacs handlers explicitly RequireScope(ctx, "read") - Adds RequireScope + KeyFromContext + ContextWithKey helpers under servers/grpc/ Coordinated with Cav7/ApiKeyManager 1.1.0; see addon migration notes. --- datastores/auth_test.go | 74 ++++++++++++++++++++++++++++++++++ datastores/datastore.go | 15 +++++-- datastores/mysql.go | 34 +++++++++++----- go.mod | 6 ++- servers/gateway/gateway.go | 2 +- servers/grpc/auth.go | 34 ++++++++++++++++ servers/grpc/auth_test.go | 58 ++++++++++++++++++++++++++ servers/grpc/authentication.go | 4 +- servers/grpc/grpc.go | 33 +++++++++++++++ 9 files changed, 241 insertions(+), 19 deletions(-) create mode 100644 datastores/auth_test.go create mode 100644 servers/grpc/auth.go create mode 100644 servers/grpc/auth_test.go diff --git a/datastores/auth_test.go b/datastores/auth_test.go new file mode 100644 index 0000000..0343d3b --- /dev/null +++ b/datastores/auth_test.go @@ -0,0 +1,74 @@ +package datastores + +import ( + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newMockDS(t *testing.T) (Mysql, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + gormDB, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + return Mysql{Db: gormDB}, mock, func() { sqlDB.Close() } +} + +func TestValidateApiKey_ValidWithMultipleScopes(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + + rows := sqlmock.NewRows([]string{"key_id", "user_id", "scope_name"}). + AddRow(42, 1648, "read"). + AddRow(42, 1648, "read:tickets") + + mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")). + WithArgs("cav7_testtoken"). + WillReturnRows(rows) + + result, err := ds.ValidateApiKey("cav7_testtoken") + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, uint(42), result.KeyId) + assert.Equal(t, uint(1648), result.UserId) + assert.True(t, result.HasScope("read")) + assert.True(t, result.HasScope("read:tickets")) + assert.False(t, result.HasScope("write:something")) +} + +func TestValidateApiKey_ZeroRowsReturnsNil(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + + rows := sqlmock.NewRows([]string{"key_id", "user_id", "scope_name"}) + mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")). + WithArgs("bad_token"). + WillReturnRows(rows) + + result, err := ds.ValidateApiKey("bad_token") + require.NoError(t, err) + assert.Nil(t, result, "no rows must return nil result (auth boundary then responds 401)") +} + +func TestValidateApiKey_QueryError(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")). + WithArgs("any_token"). + WillReturnError(assert.AnError) + + result, err := ds.ValidateApiKey("any_token") + assert.Error(t, err) + assert.Nil(t, result) +} diff --git a/datastores/datastore.go b/datastores/datastore.go index fdf93e8..1ce3bf1 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -32,9 +32,18 @@ var ( ) type ApiKeyResult struct { - KeyId uint - UserId uint - ScopeRead bool + KeyId uint + UserId uint + Scopes map[string]struct{} +} + +// HasScope reports whether the API key has been granted the named scope. +func (r *ApiKeyResult) HasScope(name string) bool { + if r == nil { + return false + } + _, ok := r.Scopes[name] + return ok } type Datastore interface { diff --git a/datastores/mysql.go b/datastores/mysql.go index bea0fa0..ca84e78 100644 --- a/datastores/mysql.go +++ b/datastores/mysql.go @@ -705,24 +705,36 @@ func (ds Mysql) GetTableUpdates() ([]xenforo.TableInfo, error) { } func (ds Mysql) ValidateApiKey(rawKey string) (*ApiKeyResult, error) { - var row struct { - KeyId uint `gorm:"column:key_id"` - UserId uint `gorm:"column:user_id"` - ScopeRead bool `gorm:"column:scope_read"` + var rows []struct { + KeyId uint `gorm:"column:key_id"` + UserId uint `gorm:"column:user_id"` + ScopeName string `gorm:"column:scope_name"` } tx := ds.Db.Raw(` - SELECT key_id, user_id, scope_read - FROM xf_cav7_api_key - WHERE key_hash = UNHEX(SHA2(?, 256)) - AND is_active = 1`, rawKey).Scan(&row) + SELECT k.key_id, k.user_id, sd.scope_name + FROM xf_cav7_api_key k + JOIN xf_cav7_api_key_scope ks ON ks.key_id = k.key_id + JOIN xf_cav7_api_key_scope_def sd ON sd.scope_id = ks.scope_id + WHERE k.key_hash = UNHEX(SHA2(?, 256)) + AND k.is_active = 1 + AND sd.is_active = 1`, rawKey).Scan(&rows) if tx.Error != nil { return nil, tx.Error } - if tx.RowsAffected == 0 { + if len(rows) == 0 { return nil, nil } - go ds.Db.Exec(`UPDATE xf_cav7_api_key SET last_used_date = UNIX_TIMESTAMP() WHERE key_id = ?`, row.KeyId) - return &ApiKeyResult{KeyId: row.KeyId, UserId: row.UserId, ScopeRead: row.ScopeRead}, nil + scopes := make(map[string]struct{}, len(rows)) + for _, r := range rows { + scopes[r.ScopeName] = struct{}{} + } + keyId := rows[0].KeyId + go ds.Db.Exec(`UPDATE xf_cav7_api_key SET last_used_date = UNIX_TIMESTAMP() WHERE key_id = ?`, keyId) + return &ApiKeyResult{ + KeyId: keyId, + UserId: rows[0].UserId, + Scopes: scopes, + }, nil } func (ds Mysql) FindProfileByGamertag(gamertag string) (*proto.Profile, error) { diff --git a/go.mod b/go.mod index cb2afa9..dab5328 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/7cav/api go 1.24.0 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/bufbuild/buf v1.54.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/rakyll/statik v0.1.7 github.com/redis/go-redis/v9 v9.7.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 google.golang.org/grpc v1.79.3 @@ -37,7 +39,6 @@ require ( connectrpc.com/otelconnect v0.7.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/bufbuild/protocompile v0.14.1 // indirect @@ -48,6 +49,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.2.0+incompatible // indirect @@ -96,6 +98,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/rs/cors v1.11.1 // indirect @@ -110,7 +113,6 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/vbatts/tar-split v0.12.1 // indirect diff --git a/servers/gateway/gateway.go b/servers/gateway/gateway.go index 379ded0..efcd8be 100644 --- a/servers/gateway/gateway.go +++ b/servers/gateway/gateway.go @@ -76,7 +76,7 @@ func authMiddleware(ds datastores.Datastore, next http.Handler) http.Handler { } key, err := ds.ValidateApiKey(token) - if err != nil || key == nil || !key.ScopeRead { + if err != nil || key == nil { Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/servers/grpc/auth.go b/servers/grpc/auth.go new file mode 100644 index 0000000..35bbe3d --- /dev/null +++ b/servers/grpc/auth.go @@ -0,0 +1,34 @@ +package grpc + +import ( + "context" + + "github.com/7cav/api/datastores" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// keyContextKey is the private type used to attach *ApiKeyResult to a request +// ctx. Private so external packages can't accidentally clobber it. +type keyContextKey struct{} + +// ContextWithKey returns a new context carrying the given API key result. +func ContextWithKey(ctx context.Context, key *datastores.ApiKeyResult) context.Context { + return context.WithValue(ctx, keyContextKey{}, key) +} + +// KeyFromContext returns the *ApiKeyResult attached to ctx, or nil if none. +func KeyFromContext(ctx context.Context) *datastores.ApiKeyResult { + v, _ := ctx.Value(keyContextKey{}).(*datastores.ApiKeyResult) + return v +} + +// RequireScope returns codes.PermissionDenied if the key on ctx lacks the named +// scope. Call at the top of every RPC that requires authorization. +func RequireScope(ctx context.Context, scope string) error { + key := KeyFromContext(ctx) + if !key.HasScope(scope) { + return status.Errorf(codes.PermissionDenied, "scope required: %s", scope) + } + return nil +} diff --git a/servers/grpc/auth_test.go b/servers/grpc/auth_test.go new file mode 100644 index 0000000..5655270 --- /dev/null +++ b/servers/grpc/auth_test.go @@ -0,0 +1,58 @@ +package grpc + +import ( + "context" + "testing" + + "github.com/7cav/api/datastores" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestKeyFromContext_NilWhenAbsent(t *testing.T) { + got := KeyFromContext(context.Background()) + assert.Nil(t, got) +} + +func TestKeyFromContext_RoundTrip(t *testing.T) { + key := &datastores.ApiKeyResult{ + KeyId: 1, + UserId: 2, + Scopes: map[string]struct{}{"read": {}}, + } + ctx := ContextWithKey(context.Background(), key) + got := KeyFromContext(ctx) + require.NotNil(t, got) + assert.Equal(t, key, got) +} + +func TestRequireScope_AllowsWhenPresent(t *testing.T) { + key := &datastores.ApiKeyResult{ + Scopes: map[string]struct{}{"read:tickets": {}}, + } + ctx := ContextWithKey(context.Background(), key) + err := RequireScope(ctx, "read:tickets") + assert.NoError(t, err) +} + +func TestRequireScope_RejectsWhenMissing(t *testing.T) { + key := &datastores.ApiKeyResult{ + Scopes: map[string]struct{}{"read": {}}, + } + ctx := ContextWithKey(context.Background(), key) + err := RequireScope(ctx, "read:tickets") + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.PermissionDenied, st.Code()) +} + +func TestRequireScope_RejectsWhenKeyAbsent(t *testing.T) { + err := RequireScope(context.Background(), "read") + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.PermissionDenied, st.Code()) +} diff --git a/servers/grpc/authentication.go b/servers/grpc/authentication.go index 5515681..77e91c2 100644 --- a/servers/grpc/authentication.go +++ b/servers/grpc/authentication.go @@ -50,10 +50,10 @@ func NewAuthInterceptor(ds datastores.Datastore) grpc.UnaryServerInterceptor { } key, err := ds.ValidateApiKey(token) - if err != nil || key == nil || !key.ScopeRead { + if err != nil || key == nil { return nil, status.Errorf(codes.Unauthenticated, "invalid api key") } - return handler(ctx, req) + return handler(ContextWithKey(ctx, key), req) } } diff --git a/servers/grpc/grpc.go b/servers/grpc/grpc.go index e258950..0711ff4 100644 --- a/servers/grpc/grpc.go +++ b/servers/grpc/grpc.go @@ -41,6 +41,9 @@ var ( ) func (server *MilpacsService) GetProfile(ctx context.Context, request *proto.ProfileRequest) (*proto.Profile, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } var ( profiles []*proto.Profile err error @@ -65,6 +68,9 @@ func (server *MilpacsService) GetProfile(ctx context.Context, request *proto.Pro } func (server *MilpacsService) GetRoster(ctx context.Context, request *proto.RosterRequest) (*proto.Roster, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { return nil, errors.New("cannot request null roster type") } @@ -79,6 +85,9 @@ func (server *MilpacsService) GetRoster(ctx context.Context, request *proto.Rost } func (server *MilpacsService) GetUserViaKeycloakId(ctx context.Context, request *proto.KeycloakIdRequest) (*proto.Profile, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.GetKeycloakId() == "" { Warn.Println("Empty Keycloak ID provided, cannot return profile") @@ -94,6 +103,9 @@ func (server *MilpacsService) GetUserViaKeycloakId(ctx context.Context, request } func (server *MilpacsService) GetUserViaDiscordId(ctx context.Context, request *proto.DiscordIdRequest) (*proto.Profile, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.GetDiscordId() == "" { Warn.Println("Empty Discord ID provided, cannot return profile") @@ -108,6 +120,9 @@ func (server *MilpacsService) GetUserViaDiscordId(ctx context.Context, request * return profile, nil } func (server *MilpacsService) GetLiteRoster(ctx context.Context, request *proto.RosterRequest) (*proto.LiteRoster, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { return nil, errors.New("cannot request null roster type") } @@ -121,6 +136,9 @@ func (server *MilpacsService) GetLiteRoster(ctx context.Context, request *proto. return roster, nil } func (server *MilpacsService) SearchByPosition(ctx context.Context, request *proto.PositionSearchRequest) (*proto.LiteRoster, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.GetPositionQuery() == "" { return nil, status.Errorf(codes.InvalidArgument, "position query cannot be empty") } @@ -134,6 +152,9 @@ func (server *MilpacsService) SearchByPosition(ctx context.Context, request *pro } func (server *MilpacsService) GetS1UniformsRoster(ctx context.Context, request *proto.RosterRequest) (*proto.S1UniformsRoster, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.Roster == proto.RosterType_ROSTER_TYPE_UNSPECIFIED { return nil, errors.New("cannot request null roster type") } @@ -147,6 +168,9 @@ func (server *MilpacsService) GetS1UniformsRoster(ctx context.Context, request * } func (server *MilpacsService) GetAllRanks(ctx context.Context, _ *emptypb.Empty) (*proto.RanksResponse, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } ranks, err := server.Datastore.FindAllRanks() if err != nil { return nil, status.Errorf(codes.Internal, "error fetching ranks: %v", err) @@ -158,6 +182,9 @@ func (server *MilpacsService) GetAllRanks(ctx context.Context, _ *emptypb.Empty) } func (server *MilpacsService) GetPositionGroups(ctx context.Context, _ *emptypb.Empty) (*proto.PositionGroupsResponse, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } groups, err := server.Datastore.FindAllPositionGroups() if err != nil { return nil, status.Errorf(codes.Internal, "error fetching position groups: %v", err) @@ -169,6 +196,9 @@ func (server *MilpacsService) GetPositionGroups(ctx context.Context, _ *emptypb. } func (server *MilpacsService) GetAwol(ctx context.Context, _ *emptypb.Empty) (*proto.AwolResponse, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } awols, err := server.Datastore.FindAwol() if err != nil { return nil, status.Errorf(codes.Internal, "error fetching AWOL list: %v", err) @@ -180,6 +210,9 @@ func (server *MilpacsService) GetAwol(ctx context.Context, _ *emptypb.Empty) (*p } func (server *MilpacsService) GetGamertagProfile(ctx context.Context, request *proto.GamertagRequest) (*proto.Profile, error) { + if err := RequireScope(ctx, "read"); err != nil { + return nil, err + } if request.GetGamertag() == "" { Warn.Println("Empty Gamertag provided, cannot return profile") return &proto.Profile{}, status.Errorf(codes.InvalidArgument, "gamertag cannot be empty") From aa63412a10a969d7696f726bb869d3e904e0c512 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:29:56 -0400 Subject: [PATCH 03/32] feat(referencecache): in-memory cache for tickets reference data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds status/priority/prefix phrase names + the category tree. Refresh is loader-driven (the loader implementation lives in the datastore package next). Cache miss returns empty/nil — never an error — because the cache can be cold relative to addon-side additions. --- referencecache/cache.go | 189 +++++++++++++++++++++++++++++++++++ referencecache/cache_test.go | 90 +++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 referencecache/cache.go create mode 100644 referencecache/cache_test.go diff --git a/referencecache/cache.go b/referencecache/cache.go new file mode 100644 index 0000000..c999223 --- /dev/null +++ b/referencecache/cache.go @@ -0,0 +1,189 @@ +// Package referencecache holds small, slow-changing lookup data +// (ticket status/priority/prefix names, category tree) in memory so the +// tickets endpoints don't pay the JOIN cost on every request. +// +// Refreshes are periodic, not write-driven. Callers tolerate cache miss as +// empty string / nil — never as error — because new addon-side records can +// land between refresh ticks. +package referencecache + +import ( + "context" + "sync" +) + +// CategoryRecord mirrors the row shape we care about from +// xf_nf_tickets_category. lft/rgt support subtree expansion. +type CategoryRecord struct { + ID uint32 + Title string + Description string + ParentID uint32 + Depth uint32 + DisplayOrder uint32 + TicketCount uint32 + Lft uint32 + Rgt uint32 +} + +// Loader fetches data the cache holds. The cache implementation calls it +// at startup and on every refresh tick. Implementations live in the +// datastore package (against MariaDB). +type Loader interface { + LoadStatusNames(ctx context.Context) (map[uint32]string, error) + LoadPriorityNames(ctx context.Context) (map[uint32]string, error) + LoadPrefixNames(ctx context.Context) (map[uint32]string, error) + LoadCategories(ctx context.Context) ([]*CategoryRecord, error) +} + +// ReferenceCache is what consumers (datastore methods, handlers) call. +type ReferenceCache interface { + StatusName(id uint32) string + PriorityName(id uint32) string + PrefixName(id uint32) string + Category(id uint32) *CategoryRecord + CategoryAncestors(id uint32) []uint32 + CategoryTree() []*CategoryRecord + ExpandSubtree(ids []uint32) []uint32 + Refresh(ctx context.Context) error +} + +// Cache is the canonical implementation. +type Cache struct { + loader Loader + + mu sync.RWMutex + statuses map[uint32]string + priorities map[uint32]string + prefixes map[uint32]string + categories map[uint32]*CategoryRecord + tree []*CategoryRecord +} + +// New returns an unpopulated cache. Callers must run Refresh once before +// serving traffic. +func New(loader Loader) *Cache { + return &Cache{ + loader: loader, + statuses: map[uint32]string{}, + priorities: map[uint32]string{}, + prefixes: map[uint32]string{}, + categories: map[uint32]*CategoryRecord{}, + } +} + +func (c *Cache) Refresh(ctx context.Context) error { + statuses, err := c.loader.LoadStatusNames(ctx) + if err != nil { + return err + } + priorities, err := c.loader.LoadPriorityNames(ctx) + if err != nil { + return err + } + prefixes, err := c.loader.LoadPrefixNames(ctx) + if err != nil { + return err + } + tree, err := c.loader.LoadCategories(ctx) + if err != nil { + return err + } + + cats := make(map[uint32]*CategoryRecord, len(tree)) + for _, cat := range tree { + cats[cat.ID] = cat + } + + c.mu.Lock() + c.statuses = statuses + c.priorities = priorities + c.prefixes = prefixes + c.categories = cats + c.tree = tree + c.mu.Unlock() + return nil +} + +func (c *Cache) StatusName(id uint32) string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.statuses[id] +} + +func (c *Cache) PriorityName(id uint32) string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.priorities[id] +} + +func (c *Cache) PrefixName(id uint32) string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.prefixes[id] +} + +func (c *Cache) Category(id uint32) *CategoryRecord { + c.mu.RLock() + defer c.mu.RUnlock() + return c.categories[id] +} + +func (c *Cache) CategoryAncestors(id uint32) []uint32 { + c.mu.RLock() + defer c.mu.RUnlock() + cat, ok := c.categories[id] + if !ok { + return nil + } + var chain []uint32 + parent := cat.ParentID + for parent != 0 { + chain = append([]uint32{parent}, chain...) // root-first + p, ok := c.categories[parent] + if !ok { + break + } + parent = p.ParentID + } + return chain +} + +func (c *Cache) CategoryTree() []*CategoryRecord { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]*CategoryRecord, len(c.tree)) + copy(out, c.tree) + return out +} + +// ExpandSubtree returns ids plus every descendant category id reachable +// through the cached tree. Unknown ids are passed through unchanged so the +// caller's SQL IN(...) clause behaves the same as if the cache were warm. +func (c *Cache) ExpandSubtree(ids []uint32) []uint32 { + c.mu.RLock() + defer c.mu.RUnlock() + seen := map[uint32]struct{}{} + var out []uint32 + add := func(id uint32) { + if _, dup := seen[id]; dup { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, id := range ids { + root, ok := c.categories[id] + if !ok { + add(id) + continue + } + add(id) + for _, cat := range c.tree { + if cat.Lft > root.Lft && cat.Rgt < root.Rgt { + add(cat.ID) + } + } + } + return out +} diff --git a/referencecache/cache_test.go b/referencecache/cache_test.go new file mode 100644 index 0000000..ea69ead --- /dev/null +++ b/referencecache/cache_test.go @@ -0,0 +1,90 @@ +package referencecache + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +type fakeLoader struct { + mu sync.Mutex + calls int + statuses map[uint32]string + priorities map[uint32]string + prefixes map[uint32]string + categories []*CategoryRecord + err error +} + +func (f *fakeLoader) LoadStatusNames(_ context.Context) (map[uint32]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + return f.statuses, f.err +} +func (f *fakeLoader) LoadPriorityNames(_ context.Context) (map[uint32]string, error) { + return f.priorities, f.err +} +func (f *fakeLoader) LoadPrefixNames(_ context.Context) (map[uint32]string, error) { + return f.prefixes, f.err +} +func (f *fakeLoader) LoadCategories(_ context.Context) ([]*CategoryRecord, error) { + return f.categories, f.err +} + +func TestRefresh_PopulatesAllMaps(t *testing.T) { + loader := &fakeLoader{ + statuses: map[uint32]string{1: "Open", 3: "Resolved"}, + priorities: map[uint32]string{1: "Low", 4: "Critical"}, + prefixes: map[uint32]string{1: "[Bug]"}, + categories: []*CategoryRecord{ + {ID: 5, Title: "S1 Personnel Admin", ParentID: 0, Depth: 0, Lft: 1, Rgt: 12}, + {ID: 17, Title: "S1 Citations", ParentID: 5, Depth: 1, Lft: 2, Rgt: 3}, + }, + } + c := New(loader) + err := c.Refresh(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "Open", c.StatusName(1)) + assert.Equal(t, "Resolved", c.StatusName(3)) + assert.Equal(t, "", c.StatusName(99), "unknown id returns empty") + assert.Equal(t, "Critical", c.PriorityName(4)) + assert.Equal(t, "[Bug]", c.PrefixName(1)) + assert.Equal(t, "S1 Citations", c.Category(17).Title) + assert.Equal(t, []uint32{5}, c.CategoryAncestors(17)) + assert.Nil(t, c.CategoryAncestors(99), "unknown id returns nil") +} + +func TestExpandCategorySubtree(t *testing.T) { + loader := &fakeLoader{ + categories: []*CategoryRecord{ + {ID: 5, ParentID: 0, Lft: 1, Rgt: 12, Depth: 0}, + {ID: 17, ParentID: 5, Lft: 2, Rgt: 3, Depth: 1}, + {ID: 16, ParentID: 5, Lft: 4, Rgt: 5, Depth: 1}, + {ID: 19, ParentID: 0, Lft: 13, Rgt: 16, Depth: 0}, + }, + } + c := New(loader) + _ = c.Refresh(context.Background()) + + got := c.ExpandSubtree([]uint32{5}) + assert.ElementsMatch(t, []uint32{5, 17, 16}, got) + got = c.ExpandSubtree([]uint32{5, 19}) + assert.ElementsMatch(t, []uint32{5, 17, 16, 19}, got) + got = c.ExpandSubtree([]uint32{99}) + assert.Equal(t, []uint32{99}, got, "unknown id passes through unchanged") +} + +func TestRefresh_FailurePreservesPreviousData(t *testing.T) { + loader := &fakeLoader{statuses: map[uint32]string{1: "Open"}} + c := New(loader) + _ = c.Refresh(context.Background()) + assert.Equal(t, "Open", c.StatusName(1)) + + loader.err = assert.AnError + err := c.Refresh(context.Background()) + assert.Error(t, err) + assert.Equal(t, "Open", c.StatusName(1), "failed refresh must keep old data, not blank it") +} From b90f546452ec284344cfc3ddc2ade0bafe0a4b81 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:30:24 -0400 Subject: [PATCH 04/32] feat(datastores): implement referencecache.Loader on Mysql Reads ticket status/priority/prefix phrase names from xf_phrase and the full category tree from xf_nf_tickets_category. Caller (referencecache.Cache) calls these at startup and every refresh tick. --- datastores/tickets.go | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 datastores/tickets.go diff --git a/datastores/tickets.go b/datastores/tickets.go new file mode 100644 index 0000000..2476e15 --- /dev/null +++ b/datastores/tickets.go @@ -0,0 +1,81 @@ +package datastores + +import ( + "context" + "fmt" + + "github.com/7cav/api/referencecache" +) + +// Compile-time assertion: Mysql must implement referencecache.Loader. +var _ referencecache.Loader = (*Mysql)(nil) + +func (ds *Mysql) LoadStatusNames(ctx context.Context) (map[uint32]string, error) { + return ds.loadPhraseMap(ctx, "nf_tickets_ticket_status.") +} +func (ds *Mysql) LoadPriorityNames(ctx context.Context) (map[uint32]string, error) { + return ds.loadPhraseMap(ctx, "nf_tickets_ticket_priority.") +} +func (ds *Mysql) LoadPrefixNames(ctx context.Context) (map[uint32]string, error) { + return ds.loadPhraseMap(ctx, "nf_tickets_ticket_prefix.") +} + +// loadPhraseMap reads rows from xf_phrase whose title starts with the given +// prefix (e.g. "nf_tickets_ticket_status."), parses the trailing integer id, +// and returns id -> phrase_text. Rows where the trailing part isn't a +// uint32 are skipped (not an error — the phrase table is shared, so +// unrelated rows can be in scope of the LIKE pattern at the edges). +func (ds *Mysql) loadPhraseMap(ctx context.Context, prefix string) (map[uint32]string, error) { + var rows []struct { + Title string `gorm:"column:title"` + PhraseText string `gorm:"column:phrase_text"` + } + tx := ds.Db.WithContext(ctx). + Raw(`SELECT title, phrase_text FROM xf_phrase WHERE title LIKE ?`, prefix+"%"). + Scan(&rows) + if tx.Error != nil { + return nil, tx.Error + } + out := map[uint32]string{} + for _, r := range rows { + idStr := r.Title[len(prefix):] + var id uint32 + if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { + continue + } + out[id] = r.PhraseText + } + return out, nil +} + +func (ds *Mysql) LoadCategories(ctx context.Context) ([]*referencecache.CategoryRecord, error) { + var rows []struct { + ID uint32 `gorm:"column:ticket_category_id"` + Title string `gorm:"column:title"` + Description string `gorm:"column:description"` + ParentID uint32 `gorm:"column:parent_category_id"` + Depth uint32 `gorm:"column:depth"` + DisplayOrder uint32 `gorm:"column:display_order"` + TicketCount uint32 `gorm:"column:ticket_count"` + Lft uint32 `gorm:"column:lft"` + Rgt uint32 `gorm:"column:rgt"` + } + tx := ds.Db.WithContext(ctx). + Raw(`SELECT ticket_category_id, title, description, parent_category_id, + depth, display_order, ticket_count, lft, rgt + FROM xf_nf_tickets_category + ORDER BY lft`). + Scan(&rows) + if tx.Error != nil { + return nil, tx.Error + } + out := make([]*referencecache.CategoryRecord, 0, len(rows)) + for _, r := range rows { + out = append(out, &referencecache.CategoryRecord{ + ID: r.ID, Title: r.Title, Description: r.Description, + ParentID: r.ParentID, Depth: r.Depth, DisplayOrder: r.DisplayOrder, + TicketCount: r.TicketCount, Lft: r.Lft, Rgt: r.Rgt, + }) + } + return out, nil +} From d4313acd32a69a2d311205cc18e466b08ad13144 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:31:06 -0400 Subject: [PATCH 05/32] feat(server): load + refresh reference cache at startup Fails fast if the initial load errors (DB unreachable, schema mismatch). After startup the refresh goroutine ticks every 15min (configurable via REFERENCE_CACHE_REFRESH_INTERVAL env var); refresh failures log but never blank the cache. --- servers/server.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/servers/server.go b/servers/server.go index 0a2ea18..eb64f45 100644 --- a/servers/server.go +++ b/servers/server.go @@ -19,16 +19,19 @@ package servers import ( + "context" "fmt" "io" "log" "net" "net/http" "os" + "time" "github.com/7cav/api/cache" "github.com/7cav/api/datastores" milpacs "github.com/7cav/api/proto" + "github.com/7cav/api/referencecache" httpServices "github.com/7cav/api/servers/gateway" grpcServices "github.com/7cav/api/servers/grpc" "github.com/spf13/viper" @@ -41,10 +44,11 @@ import ( const version = "2.1.1" type MicroServer struct { - addr string - httpServer *http.Server - grpcServer *grpc.Server - cache *cache.RedisCache + addr string + httpServer *http.Server + grpcServer *grpc.Server + cache *cache.RedisCache + referenceCache *referencecache.Cache } // New initializes a new Backend struct. @@ -137,6 +141,11 @@ func (server *MicroServer) Start() { ds := setupDatasource() server.cache = setupRedis() + server.referenceCache = referencecache.New(ds) + if err := server.referenceCache.Refresh(context.Background()); err != nil { + Error.Fatalf("initial reference cache load failed: %v", err) + } + go runReferenceCacheRefresh(server.referenceCache) go cache.CacheManager(server.cache, ds) // relevant Grpc options @@ -176,3 +185,21 @@ func servHTTP(server *MicroServer, lis net.Listener, ds datastores.Datastore) { Error.Fatalf("unable to start HTTP servers: ", err) } } + +func runReferenceCacheRefresh(rc *referencecache.Cache) { + interval := 15 * time.Minute + if v := viper.GetString("REFERENCE_CACHE_REFRESH_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + interval = d + } else { + Warn.Printf("invalid REFERENCE_CACHE_REFRESH_INTERVAL %q, using default %s", v, interval) + } + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + if err := rc.Refresh(context.Background()); err != nil { + Warn.Printf("reference cache refresh failed (keeping previous data): %v", err) + } + } +} From b6df9517f7bbe2bff63551d9edd45d07a17439a3 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:36:53 -0400 Subject: [PATCH 06/32] fix(referencecache): code review hardening - Replace fmt.Sscanf with strconv.ParseUint for stricter id parse - Add cycle protection to CategoryAncestors traversal - Thread context.Context through refresh goroutine for future cancellation --- datastores/tickets.go | 8 ++++---- referencecache/cache.go | 5 +++++ servers/server.go | 15 ++++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/datastores/tickets.go b/datastores/tickets.go index 2476e15..8f1e102 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -2,7 +2,7 @@ package datastores import ( "context" - "fmt" + "strconv" "github.com/7cav/api/referencecache" ) @@ -39,11 +39,11 @@ func (ds *Mysql) loadPhraseMap(ctx context.Context, prefix string) (map[uint32]s out := map[uint32]string{} for _, r := range rows { idStr := r.Title[len(prefix):] - var id uint32 - if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { + parsed, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { continue } - out[id] = r.PhraseText + out[uint32(parsed)] = r.PhraseText } return out, nil } diff --git a/referencecache/cache.go b/referencecache/cache.go index c999223..08b67ce 100644 --- a/referencecache/cache.go +++ b/referencecache/cache.go @@ -136,9 +136,14 @@ func (c *Cache) CategoryAncestors(id uint32) []uint32 { if !ok { return nil } + seen := map[uint32]struct{}{id: {}} var chain []uint32 parent := cat.ParentID for parent != 0 { + if _, dup := seen[parent]; dup { + break // cycle in data — stop here, return what we have + } + seen[parent] = struct{}{} chain = append([]uint32{parent}, chain...) // root-first p, ok := c.categories[parent] if !ok { diff --git a/servers/server.go b/servers/server.go index eb64f45..9b0f945 100644 --- a/servers/server.go +++ b/servers/server.go @@ -145,7 +145,7 @@ func (server *MicroServer) Start() { if err := server.referenceCache.Refresh(context.Background()); err != nil { Error.Fatalf("initial reference cache load failed: %v", err) } - go runReferenceCacheRefresh(server.referenceCache) + go runReferenceCacheRefresh(context.Background(), server.referenceCache) go cache.CacheManager(server.cache, ds) // relevant Grpc options @@ -186,7 +186,7 @@ func servHTTP(server *MicroServer, lis net.Listener, ds datastores.Datastore) { } } -func runReferenceCacheRefresh(rc *referencecache.Cache) { +func runReferenceCacheRefresh(ctx context.Context, rc *referencecache.Cache) { interval := 15 * time.Minute if v := viper.GetString("REFERENCE_CACHE_REFRESH_INTERVAL"); v != "" { if d, err := time.ParseDuration(v); err == nil { @@ -197,9 +197,14 @@ func runReferenceCacheRefresh(rc *referencecache.Cache) { } ticker := time.NewTicker(interval) defer ticker.Stop() - for range ticker.C { - if err := rc.Refresh(context.Background()); err != nil { - Warn.Printf("reference cache refresh failed (keeping previous data): %v", err) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := rc.Refresh(ctx); err != nil { + Warn.Printf("reference cache refresh failed (keeping previous data): %v", err) + } } } } From 1d12b3b768b26acc15eef22872a4b96fc906a0b4 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:49:13 -0400 Subject: [PATCH 07/32] feat(proto): add tickets.proto for TicketsService --- buf.yaml | 5 ++ proto/tickets.proto | 171 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 proto/tickets.proto diff --git a/buf.yaml b/buf.yaml index f207f67..c877cc6 100644 --- a/buf.yaml +++ b/buf.yaml @@ -18,14 +18,19 @@ lint: ignore_only: PACKAGE_DIRECTORY_MATCH: - proto/milpacs.proto + - proto/tickets.proto PACKAGE_VERSION_SUFFIX: - proto/milpacs.proto + - proto/tickets.proto RPC_REQUEST_RESPONSE_UNIQUE: - proto/milpacs.proto + - proto/tickets.proto RPC_REQUEST_STANDARD_NAME: - proto/milpacs.proto + - proto/tickets.proto RPC_RESPONSE_STANDARD_NAME: - proto/milpacs.proto + - proto/tickets.proto disallow_comment_ignores: true breaking: except: diff --git a/proto/tickets.proto b/proto/tickets.proto new file mode 100644 index 0000000..75424ae --- /dev/null +++ b/proto/tickets.proto @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2026 7Cav.us + * This file is part of 7Cav-API . + * + * Licensed under the GNU General Public License v3 (see milpacs.proto for + * the full notice). + */ + +syntax = "proto3"; + +package proto; + +option go_package = "github.com/7cav/api/proto"; + +import "google/api/annotations.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/empty.proto"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "7Cav Tickets API"; + version: "2.2.0"; + description: "Read-only access to 7Cav forum tickets (nixfifty addon)."; + } + schemes: HTTPS; + consumes: "application/json"; + produces: "application/json"; +}; + +service TicketsService { + rpc ListTickets(ListTicketsRequest) returns (ListTicketsResponse) { + option (google.api.http) = { get: "/api/v1/tickets" }; + } + rpc GetTicket(GetTicketRequest) returns (GetTicketResponse) { + option (google.api.http) = { get: "/api/v1/tickets/{ticket_id}" }; + } + rpc GetTicketByRef(GetTicketByRefRequest) returns (GetTicketResponse) { + option (google.api.http) = { get: "/api/v1/tickets/ref/{ticket_ref}" }; + } + rpc ListTicketMessages(ListTicketMessagesRequest) returns (ListTicketMessagesResponse) { + option (google.api.http) = { get: "/api/v1/tickets/{ticket_id}/messages" }; + } + rpc ListCategories(google.protobuf.Empty) returns (ListCategoriesResponse) { + option (google.api.http) = { get: "/api/v1/tickets/categories" }; + } +} + +message Ticket { + // Identity + uint32 ticket_id = 1; + string ticket_ref = 2; + string title = 3; + + // Category + uint32 category_id = 10; + string category_title = 11; + repeated uint32 category_ancestor_ids = 12; + + // State / workflow + string ticket_state = 20; + uint32 status_id = 21; + string status_name = 22; + uint32 priority_id = 23; + string priority_name = 24; + uint32 prefix_id = 25; + string prefix_name = 26; + string discussion_state = 27; + bool ticket_locked = 28; + + // People + uint32 starter_user_id = 30; + string starter_username = 31; + uint32 assigned_user_id = 32; + string assigned_username = 33; + repeated TicketParticipant participants = 34; + + // Activity + uint32 start_date = 40; + uint32 last_message_date = 41; + uint32 last_message_user_id = 42; + string last_message_username = 43; + uint32 last_modified_date = 44; + uint32 reply_count = 45; + + // Data + map custom_fields = 50; + + // Convenience + string forum_url = 60; +} + +message Message { + uint32 message_id = 1; + uint32 ticket_id = 2; + uint32 user_id = 3; + string username = 4; + uint32 message_date = 5; + string message = 6; + string message_state = 7; + uint32 position = 8; + uint32 attach_count = 9; + uint32 last_edit_date = 10; + uint32 edit_count = 11; +} + +message Category { + uint32 category_id = 1; + string title = 2; + string description = 3; + uint32 parent_category_id = 4; + uint32 depth = 5; + uint32 display_order = 6; + uint32 ticket_count = 7; +} + +message TicketParticipant { + uint32 user_id = 1; + uint32 last_read_date = 2; +} + +message ListTicketsRequest { + repeated uint32 category_id = 1; + bool exclude_subcategories = 2; + repeated string ticket_state = 3; + repeated uint32 status_id = 4; + repeated uint32 prefix_id = 5; + repeated uint32 assigned_user_id = 6; + repeated uint32 starter_user_id = 7; + uint32 modified_since = 8; + bool include_hidden = 9; + + uint32 per_page = 50; + string after_cursor = 51; +} + +message ListTicketsResponse { + repeated Ticket tickets = 1; + string next_cursor = 2; + bool has_more = 3; +} + +message GetTicketRequest { + uint32 ticket_id = 1; +} + +message GetTicketByRefRequest { + string ticket_ref = 1; +} + +message GetTicketResponse { + Ticket ticket = 1; + repeated Message first_messages = 2; + uint32 total_message_count = 3; +} + +message ListTicketMessagesRequest { + uint32 ticket_id = 1; + uint32 per_page = 2; + uint32 after_position = 3; + bool include_hidden = 4; +} + +message ListTicketMessagesResponse { + repeated Message messages = 1; + uint32 next_cursor = 2; + bool has_more = 3; +} + +message ListCategoriesResponse { + repeated Category categories = 1; +} From d0bcb68f1c628e53fdbe4ef6f4b6aa51804fada7 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:50:38 -0400 Subject: [PATCH 08/32] chore: regenerate proto + swagger + statik for tickets --- proto/tickets.pb.go | 1400 ++++++++++++++++++++++ proto/tickets.pb.gw.go | 459 +++++++ proto/tickets_grpc.pb.go | 279 +++++ third_party/OpenAPI/tickets.swagger.json | 586 +++++++++ 4 files changed, 2724 insertions(+) create mode 100644 proto/tickets.pb.go create mode 100644 proto/tickets.pb.gw.go create mode 100644 proto/tickets_grpc.pb.go create mode 100644 third_party/OpenAPI/tickets.swagger.json diff --git a/proto/tickets.pb.go b/proto/tickets.pb.go new file mode 100644 index 0000000..2f2e0f7 --- /dev/null +++ b/proto/tickets.pb.go @@ -0,0 +1,1400 @@ +// +// Copyright (C) 2026 7Cav.us +// This file is part of 7Cav-API . +// +// Licensed under the GNU General Public License v3 (see milpacs.proto for +// the full notice). + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc (unknown) +// source: tickets.proto + +package proto + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Ticket struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identity + TicketId uint32 `protobuf:"varint,1,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + TicketRef string `protobuf:"bytes,2,opt,name=ticket_ref,json=ticketRef,proto3" json:"ticket_ref,omitempty"` + Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"` + // Category + CategoryId uint32 `protobuf:"varint,10,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"` + CategoryTitle string `protobuf:"bytes,11,opt,name=category_title,json=categoryTitle,proto3" json:"category_title,omitempty"` + CategoryAncestorIds []uint32 `protobuf:"varint,12,rep,packed,name=category_ancestor_ids,json=categoryAncestorIds,proto3" json:"category_ancestor_ids,omitempty"` + // State / workflow + TicketState string `protobuf:"bytes,20,opt,name=ticket_state,json=ticketState,proto3" json:"ticket_state,omitempty"` + StatusId uint32 `protobuf:"varint,21,opt,name=status_id,json=statusId,proto3" json:"status_id,omitempty"` + StatusName string `protobuf:"bytes,22,opt,name=status_name,json=statusName,proto3" json:"status_name,omitempty"` + PriorityId uint32 `protobuf:"varint,23,opt,name=priority_id,json=priorityId,proto3" json:"priority_id,omitempty"` + PriorityName string `protobuf:"bytes,24,opt,name=priority_name,json=priorityName,proto3" json:"priority_name,omitempty"` + PrefixId uint32 `protobuf:"varint,25,opt,name=prefix_id,json=prefixId,proto3" json:"prefix_id,omitempty"` + PrefixName string `protobuf:"bytes,26,opt,name=prefix_name,json=prefixName,proto3" json:"prefix_name,omitempty"` + DiscussionState string `protobuf:"bytes,27,opt,name=discussion_state,json=discussionState,proto3" json:"discussion_state,omitempty"` + TicketLocked bool `protobuf:"varint,28,opt,name=ticket_locked,json=ticketLocked,proto3" json:"ticket_locked,omitempty"` + // People + StarterUserId uint32 `protobuf:"varint,30,opt,name=starter_user_id,json=starterUserId,proto3" json:"starter_user_id,omitempty"` + StarterUsername string `protobuf:"bytes,31,opt,name=starter_username,json=starterUsername,proto3" json:"starter_username,omitempty"` + AssignedUserId uint32 `protobuf:"varint,32,opt,name=assigned_user_id,json=assignedUserId,proto3" json:"assigned_user_id,omitempty"` + AssignedUsername string `protobuf:"bytes,33,opt,name=assigned_username,json=assignedUsername,proto3" json:"assigned_username,omitempty"` + Participants []*TicketParticipant `protobuf:"bytes,34,rep,name=participants,proto3" json:"participants,omitempty"` + // Activity + StartDate uint32 `protobuf:"varint,40,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"` + LastMessageDate uint32 `protobuf:"varint,41,opt,name=last_message_date,json=lastMessageDate,proto3" json:"last_message_date,omitempty"` + LastMessageUserId uint32 `protobuf:"varint,42,opt,name=last_message_user_id,json=lastMessageUserId,proto3" json:"last_message_user_id,omitempty"` + LastMessageUsername string `protobuf:"bytes,43,opt,name=last_message_username,json=lastMessageUsername,proto3" json:"last_message_username,omitempty"` + LastModifiedDate uint32 `protobuf:"varint,44,opt,name=last_modified_date,json=lastModifiedDate,proto3" json:"last_modified_date,omitempty"` + ReplyCount uint32 `protobuf:"varint,45,opt,name=reply_count,json=replyCount,proto3" json:"reply_count,omitempty"` + // Data + CustomFields map[string]string `protobuf:"bytes,50,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Convenience + ForumUrl string `protobuf:"bytes,60,opt,name=forum_url,json=forumUrl,proto3" json:"forum_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ticket) Reset() { + *x = Ticket{} + mi := &file_tickets_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ticket) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ticket) ProtoMessage() {} + +func (x *Ticket) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ticket.ProtoReflect.Descriptor instead. +func (*Ticket) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{0} +} + +func (x *Ticket) GetTicketId() uint32 { + if x != nil { + return x.TicketId + } + return 0 +} + +func (x *Ticket) GetTicketRef() string { + if x != nil { + return x.TicketRef + } + return "" +} + +func (x *Ticket) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Ticket) GetCategoryId() uint32 { + if x != nil { + return x.CategoryId + } + return 0 +} + +func (x *Ticket) GetCategoryTitle() string { + if x != nil { + return x.CategoryTitle + } + return "" +} + +func (x *Ticket) GetCategoryAncestorIds() []uint32 { + if x != nil { + return x.CategoryAncestorIds + } + return nil +} + +func (x *Ticket) GetTicketState() string { + if x != nil { + return x.TicketState + } + return "" +} + +func (x *Ticket) GetStatusId() uint32 { + if x != nil { + return x.StatusId + } + return 0 +} + +func (x *Ticket) GetStatusName() string { + if x != nil { + return x.StatusName + } + return "" +} + +func (x *Ticket) GetPriorityId() uint32 { + if x != nil { + return x.PriorityId + } + return 0 +} + +func (x *Ticket) GetPriorityName() string { + if x != nil { + return x.PriorityName + } + return "" +} + +func (x *Ticket) GetPrefixId() uint32 { + if x != nil { + return x.PrefixId + } + return 0 +} + +func (x *Ticket) GetPrefixName() string { + if x != nil { + return x.PrefixName + } + return "" +} + +func (x *Ticket) GetDiscussionState() string { + if x != nil { + return x.DiscussionState + } + return "" +} + +func (x *Ticket) GetTicketLocked() bool { + if x != nil { + return x.TicketLocked + } + return false +} + +func (x *Ticket) GetStarterUserId() uint32 { + if x != nil { + return x.StarterUserId + } + return 0 +} + +func (x *Ticket) GetStarterUsername() string { + if x != nil { + return x.StarterUsername + } + return "" +} + +func (x *Ticket) GetAssignedUserId() uint32 { + if x != nil { + return x.AssignedUserId + } + return 0 +} + +func (x *Ticket) GetAssignedUsername() string { + if x != nil { + return x.AssignedUsername + } + return "" +} + +func (x *Ticket) GetParticipants() []*TicketParticipant { + if x != nil { + return x.Participants + } + return nil +} + +func (x *Ticket) GetStartDate() uint32 { + if x != nil { + return x.StartDate + } + return 0 +} + +func (x *Ticket) GetLastMessageDate() uint32 { + if x != nil { + return x.LastMessageDate + } + return 0 +} + +func (x *Ticket) GetLastMessageUserId() uint32 { + if x != nil { + return x.LastMessageUserId + } + return 0 +} + +func (x *Ticket) GetLastMessageUsername() string { + if x != nil { + return x.LastMessageUsername + } + return "" +} + +func (x *Ticket) GetLastModifiedDate() uint32 { + if x != nil { + return x.LastModifiedDate + } + return 0 +} + +func (x *Ticket) GetReplyCount() uint32 { + if x != nil { + return x.ReplyCount + } + return 0 +} + +func (x *Ticket) GetCustomFields() map[string]string { + if x != nil { + return x.CustomFields + } + return nil +} + +func (x *Ticket) GetForumUrl() string { + if x != nil { + return x.ForumUrl + } + return "" +} + +type Message struct { + state protoimpl.MessageState `protogen:"open.v1"` + MessageId uint32 `protobuf:"varint,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + TicketId uint32 `protobuf:"varint,2,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + UserId uint32 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"` + MessageDate uint32 `protobuf:"varint,5,opt,name=message_date,json=messageDate,proto3" json:"message_date,omitempty"` + Message string `protobuf:"bytes,6,opt,name=message,proto3" json:"message,omitempty"` + MessageState string `protobuf:"bytes,7,opt,name=message_state,json=messageState,proto3" json:"message_state,omitempty"` + Position uint32 `protobuf:"varint,8,opt,name=position,proto3" json:"position,omitempty"` + AttachCount uint32 `protobuf:"varint,9,opt,name=attach_count,json=attachCount,proto3" json:"attach_count,omitempty"` + LastEditDate uint32 `protobuf:"varint,10,opt,name=last_edit_date,json=lastEditDate,proto3" json:"last_edit_date,omitempty"` + EditCount uint32 `protobuf:"varint,11,opt,name=edit_count,json=editCount,proto3" json:"edit_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Message) Reset() { + *x = Message{} + mi := &file_tickets_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Message) ProtoMessage() {} + +func (x *Message) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Message.ProtoReflect.Descriptor instead. +func (*Message) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{1} +} + +func (x *Message) GetMessageId() uint32 { + if x != nil { + return x.MessageId + } + return 0 +} + +func (x *Message) GetTicketId() uint32 { + if x != nil { + return x.TicketId + } + return 0 +} + +func (x *Message) GetUserId() uint32 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *Message) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *Message) GetMessageDate() uint32 { + if x != nil { + return x.MessageDate + } + return 0 +} + +func (x *Message) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *Message) GetMessageState() string { + if x != nil { + return x.MessageState + } + return "" +} + +func (x *Message) GetPosition() uint32 { + if x != nil { + return x.Position + } + return 0 +} + +func (x *Message) GetAttachCount() uint32 { + if x != nil { + return x.AttachCount + } + return 0 +} + +func (x *Message) GetLastEditDate() uint32 { + if x != nil { + return x.LastEditDate + } + return 0 +} + +func (x *Message) GetEditCount() uint32 { + if x != nil { + return x.EditCount + } + return 0 +} + +type Category struct { + state protoimpl.MessageState `protogen:"open.v1"` + CategoryId uint32 `protobuf:"varint,1,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + ParentCategoryId uint32 `protobuf:"varint,4,opt,name=parent_category_id,json=parentCategoryId,proto3" json:"parent_category_id,omitempty"` + Depth uint32 `protobuf:"varint,5,opt,name=depth,proto3" json:"depth,omitempty"` + DisplayOrder uint32 `protobuf:"varint,6,opt,name=display_order,json=displayOrder,proto3" json:"display_order,omitempty"` + TicketCount uint32 `protobuf:"varint,7,opt,name=ticket_count,json=ticketCount,proto3" json:"ticket_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Category) Reset() { + *x = Category{} + mi := &file_tickets_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Category) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Category) ProtoMessage() {} + +func (x *Category) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Category.ProtoReflect.Descriptor instead. +func (*Category) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{2} +} + +func (x *Category) GetCategoryId() uint32 { + if x != nil { + return x.CategoryId + } + return 0 +} + +func (x *Category) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Category) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *Category) GetParentCategoryId() uint32 { + if x != nil { + return x.ParentCategoryId + } + return 0 +} + +func (x *Category) GetDepth() uint32 { + if x != nil { + return x.Depth + } + return 0 +} + +func (x *Category) GetDisplayOrder() uint32 { + if x != nil { + return x.DisplayOrder + } + return 0 +} + +func (x *Category) GetTicketCount() uint32 { + if x != nil { + return x.TicketCount + } + return 0 +} + +type TicketParticipant struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId uint32 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + LastReadDate uint32 `protobuf:"varint,2,opt,name=last_read_date,json=lastReadDate,proto3" json:"last_read_date,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TicketParticipant) Reset() { + *x = TicketParticipant{} + mi := &file_tickets_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TicketParticipant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TicketParticipant) ProtoMessage() {} + +func (x *TicketParticipant) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TicketParticipant.ProtoReflect.Descriptor instead. +func (*TicketParticipant) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{3} +} + +func (x *TicketParticipant) GetUserId() uint32 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *TicketParticipant) GetLastReadDate() uint32 { + if x != nil { + return x.LastReadDate + } + return 0 +} + +type ListTicketsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CategoryId []uint32 `protobuf:"varint,1,rep,packed,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"` + ExcludeSubcategories bool `protobuf:"varint,2,opt,name=exclude_subcategories,json=excludeSubcategories,proto3" json:"exclude_subcategories,omitempty"` + TicketState []string `protobuf:"bytes,3,rep,name=ticket_state,json=ticketState,proto3" json:"ticket_state,omitempty"` + StatusId []uint32 `protobuf:"varint,4,rep,packed,name=status_id,json=statusId,proto3" json:"status_id,omitempty"` + PrefixId []uint32 `protobuf:"varint,5,rep,packed,name=prefix_id,json=prefixId,proto3" json:"prefix_id,omitempty"` + AssignedUserId []uint32 `protobuf:"varint,6,rep,packed,name=assigned_user_id,json=assignedUserId,proto3" json:"assigned_user_id,omitempty"` + StarterUserId []uint32 `protobuf:"varint,7,rep,packed,name=starter_user_id,json=starterUserId,proto3" json:"starter_user_id,omitempty"` + ModifiedSince uint32 `protobuf:"varint,8,opt,name=modified_since,json=modifiedSince,proto3" json:"modified_since,omitempty"` + IncludeHidden bool `protobuf:"varint,9,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` + PerPage uint32 `protobuf:"varint,50,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` + AfterCursor string `protobuf:"bytes,51,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTicketsRequest) Reset() { + *x = ListTicketsRequest{} + mi := &file_tickets_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTicketsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTicketsRequest) ProtoMessage() {} + +func (x *ListTicketsRequest) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTicketsRequest.ProtoReflect.Descriptor instead. +func (*ListTicketsRequest) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{4} +} + +func (x *ListTicketsRequest) GetCategoryId() []uint32 { + if x != nil { + return x.CategoryId + } + return nil +} + +func (x *ListTicketsRequest) GetExcludeSubcategories() bool { + if x != nil { + return x.ExcludeSubcategories + } + return false +} + +func (x *ListTicketsRequest) GetTicketState() []string { + if x != nil { + return x.TicketState + } + return nil +} + +func (x *ListTicketsRequest) GetStatusId() []uint32 { + if x != nil { + return x.StatusId + } + return nil +} + +func (x *ListTicketsRequest) GetPrefixId() []uint32 { + if x != nil { + return x.PrefixId + } + return nil +} + +func (x *ListTicketsRequest) GetAssignedUserId() []uint32 { + if x != nil { + return x.AssignedUserId + } + return nil +} + +func (x *ListTicketsRequest) GetStarterUserId() []uint32 { + if x != nil { + return x.StarterUserId + } + return nil +} + +func (x *ListTicketsRequest) GetModifiedSince() uint32 { + if x != nil { + return x.ModifiedSince + } + return 0 +} + +func (x *ListTicketsRequest) GetIncludeHidden() bool { + if x != nil { + return x.IncludeHidden + } + return false +} + +func (x *ListTicketsRequest) GetPerPage() uint32 { + if x != nil { + return x.PerPage + } + return 0 +} + +func (x *ListTicketsRequest) GetAfterCursor() string { + if x != nil { + return x.AfterCursor + } + return "" +} + +type ListTicketsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tickets []*Ticket `protobuf:"bytes,1,rep,name=tickets,proto3" json:"tickets,omitempty"` + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,3,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTicketsResponse) Reset() { + *x = ListTicketsResponse{} + mi := &file_tickets_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTicketsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTicketsResponse) ProtoMessage() {} + +func (x *ListTicketsResponse) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTicketsResponse.ProtoReflect.Descriptor instead. +func (*ListTicketsResponse) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{5} +} + +func (x *ListTicketsResponse) GetTickets() []*Ticket { + if x != nil { + return x.Tickets + } + return nil +} + +func (x *ListTicketsResponse) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +func (x *ListTicketsResponse) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + +type GetTicketRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TicketId uint32 `protobuf:"varint,1,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTicketRequest) Reset() { + *x = GetTicketRequest{} + mi := &file_tickets_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTicketRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTicketRequest) ProtoMessage() {} + +func (x *GetTicketRequest) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTicketRequest.ProtoReflect.Descriptor instead. +func (*GetTicketRequest) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{6} +} + +func (x *GetTicketRequest) GetTicketId() uint32 { + if x != nil { + return x.TicketId + } + return 0 +} + +type GetTicketByRefRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TicketRef string `protobuf:"bytes,1,opt,name=ticket_ref,json=ticketRef,proto3" json:"ticket_ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTicketByRefRequest) Reset() { + *x = GetTicketByRefRequest{} + mi := &file_tickets_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTicketByRefRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTicketByRefRequest) ProtoMessage() {} + +func (x *GetTicketByRefRequest) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTicketByRefRequest.ProtoReflect.Descriptor instead. +func (*GetTicketByRefRequest) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{7} +} + +func (x *GetTicketByRefRequest) GetTicketRef() string { + if x != nil { + return x.TicketRef + } + return "" +} + +type GetTicketResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ticket *Ticket `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket,omitempty"` + FirstMessages []*Message `protobuf:"bytes,2,rep,name=first_messages,json=firstMessages,proto3" json:"first_messages,omitempty"` + TotalMessageCount uint32 `protobuf:"varint,3,opt,name=total_message_count,json=totalMessageCount,proto3" json:"total_message_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTicketResponse) Reset() { + *x = GetTicketResponse{} + mi := &file_tickets_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTicketResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTicketResponse) ProtoMessage() {} + +func (x *GetTicketResponse) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTicketResponse.ProtoReflect.Descriptor instead. +func (*GetTicketResponse) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{8} +} + +func (x *GetTicketResponse) GetTicket() *Ticket { + if x != nil { + return x.Ticket + } + return nil +} + +func (x *GetTicketResponse) GetFirstMessages() []*Message { + if x != nil { + return x.FirstMessages + } + return nil +} + +func (x *GetTicketResponse) GetTotalMessageCount() uint32 { + if x != nil { + return x.TotalMessageCount + } + return 0 +} + +type ListTicketMessagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TicketId uint32 `protobuf:"varint,1,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + PerPage uint32 `protobuf:"varint,2,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` + AfterPosition uint32 `protobuf:"varint,3,opt,name=after_position,json=afterPosition,proto3" json:"after_position,omitempty"` + IncludeHidden bool `protobuf:"varint,4,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTicketMessagesRequest) Reset() { + *x = ListTicketMessagesRequest{} + mi := &file_tickets_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTicketMessagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTicketMessagesRequest) ProtoMessage() {} + +func (x *ListTicketMessagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTicketMessagesRequest.ProtoReflect.Descriptor instead. +func (*ListTicketMessagesRequest) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{9} +} + +func (x *ListTicketMessagesRequest) GetTicketId() uint32 { + if x != nil { + return x.TicketId + } + return 0 +} + +func (x *ListTicketMessagesRequest) GetPerPage() uint32 { + if x != nil { + return x.PerPage + } + return 0 +} + +func (x *ListTicketMessagesRequest) GetAfterPosition() uint32 { + if x != nil { + return x.AfterPosition + } + return 0 +} + +func (x *ListTicketMessagesRequest) GetIncludeHidden() bool { + if x != nil { + return x.IncludeHidden + } + return false +} + +type ListTicketMessagesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Messages []*Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` + NextCursor uint32 `protobuf:"varint,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,3,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTicketMessagesResponse) Reset() { + *x = ListTicketMessagesResponse{} + mi := &file_tickets_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTicketMessagesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTicketMessagesResponse) ProtoMessage() {} + +func (x *ListTicketMessagesResponse) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTicketMessagesResponse.ProtoReflect.Descriptor instead. +func (*ListTicketMessagesResponse) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{10} +} + +func (x *ListTicketMessagesResponse) GetMessages() []*Message { + if x != nil { + return x.Messages + } + return nil +} + +func (x *ListTicketMessagesResponse) GetNextCursor() uint32 { + if x != nil { + return x.NextCursor + } + return 0 +} + +func (x *ListTicketMessagesResponse) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + +type ListCategoriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Categories []*Category `protobuf:"bytes,1,rep,name=categories,proto3" json:"categories,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCategoriesResponse) Reset() { + *x = ListCategoriesResponse{} + mi := &file_tickets_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCategoriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCategoriesResponse) ProtoMessage() {} + +func (x *ListCategoriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_tickets_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCategoriesResponse.ProtoReflect.Descriptor instead. +func (*ListCategoriesResponse) Descriptor() ([]byte, []int) { + return file_tickets_proto_rawDescGZIP(), []int{11} +} + +func (x *ListCategoriesResponse) GetCategories() []*Category { + if x != nil { + return x.Categories + } + return nil +} + +var File_tickets_proto protoreflect.FileDescriptor + +var file_tickets_proto_rawDesc = string([]byte{ + 0x0a, 0x0d, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65, 0x6e, + 0x2d, 0x6f, 0x70, 0x65, 0x6e, 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x22, 0x96, 0x09, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, + 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x66, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x1f, + 0x0a, 0x0b, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x49, 0x64, 0x12, + 0x25, 0x0a, 0x0e, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x74, 0x69, 0x74, 0x6c, + 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, + 0x79, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, + 0x72, 0x79, 0x5f, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x0c, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x13, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x41, + 0x6e, 0x63, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x69, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x16, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x70, + 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x0a, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, + 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x18, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x4e, 0x61, 0x6d, + 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x69, 0x64, 0x18, 0x19, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x49, 0x64, 0x12, 0x1f, + 0x0a, 0x0b, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x1a, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x29, 0x0a, 0x10, 0x64, 0x69, 0x73, 0x63, 0x75, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x63, 0x75, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1c, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x0c, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, + 0x26, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, + 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x1f, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, + 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, + 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x20, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x61, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x11, + 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x21, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, + 0x64, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x3c, 0x0a, 0x0c, 0x70, 0x61, 0x72, + 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x73, 0x18, 0x22, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x50, 0x61, + 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x52, 0x0c, 0x70, 0x61, 0x72, 0x74, 0x69, + 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x28, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x44, 0x61, 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x29, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x0f, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x61, + 0x74, 0x65, 0x12, 0x2f, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x2a, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x11, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x2b, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x13, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, + 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x2c, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x44, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x70, 0x6c, 0x79, 0x5f, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x2d, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x72, 0x65, 0x70, 0x6c, + 0x79, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x32, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x43, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, + 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, + 0x66, 0x6f, 0x72, 0x75, 0x6d, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x3c, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x66, 0x6f, 0x72, 0x75, 0x6d, 0x55, 0x72, 0x6c, 0x1a, 0x3f, 0x0a, 0x11, 0x43, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xe0, 0x02, 0x0a, 0x07, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, + 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, + 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0b, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x44, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6f, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x6f, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x5f, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0b, 0x61, 0x74, 0x74, + 0x61, 0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x73, 0x74, + 0x5f, 0x65, 0x64, 0x69, 0x74, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x45, 0x64, 0x69, 0x74, 0x44, 0x61, 0x74, 0x65, 0x12, 0x1d, + 0x0a, 0x0a, 0x65, 0x64, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x09, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xef, 0x01, + 0x0a, 0x08, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x61, + 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x0a, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, + 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, + 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x61, + 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x10, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x49, + 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x70, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x05, 0x64, 0x65, 0x70, 0x74, 0x68, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, + 0x61, 0x79, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, + 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, + 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x0b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, + 0x52, 0x0a, 0x11, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x50, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, + 0x70, 0x61, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, + 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x52, 0x65, 0x61, 0x64, 0x44, + 0x61, 0x74, 0x65, 0x22, 0xa5, 0x03, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x61, + 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, + 0x0a, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x15, 0x65, + 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, + 0x72, 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x65, 0x78, 0x63, 0x6c, + 0x75, 0x64, 0x65, 0x53, 0x75, 0x62, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, + 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x53, 0x74, + 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x69, 0x64, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x49, 0x64, + 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x49, 0x64, 0x12, 0x28, 0x0a, + 0x10, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, + 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0d, + 0x52, 0x0d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x25, 0x0a, 0x0e, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x73, 0x69, 0x6e, 0x63, + 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x53, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, + 0x65, 0x5f, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, + 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x48, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x12, 0x19, 0x0a, + 0x08, 0x70, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x07, 0x70, 0x65, 0x72, 0x50, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, + 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x33, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x7a, 0x0a, 0x13, 0x4c, + 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x52, 0x07, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, + 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x19, 0x0a, 0x08, + 0x68, 0x61, 0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x68, 0x61, 0x73, 0x4d, 0x6f, 0x72, 0x65, 0x22, 0x2f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x54, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, + 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x22, 0x36, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x54, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x66, + 0x22, 0xa1, 0x01, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x06, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x06, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x35, 0x0a, + 0x0e, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, + 0x19, 0x0a, 0x08, 0x70, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0d, 0x52, 0x07, 0x70, 0x65, 0x72, 0x50, 0x61, 0x67, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x66, + 0x74, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x0d, 0x61, 0x66, 0x74, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x68, 0x69, 0x64, + 0x64, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x63, 0x6c, 0x75, + 0x64, 0x65, 0x48, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x22, 0x84, 0x01, 0x0a, 0x1a, 0x4c, 0x69, 0x73, + 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, + 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, + 0x72, 0x73, 0x6f, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x68, 0x61, 0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61, 0x73, 0x4d, 0x6f, 0x72, 0x65, 0x22, + 0x49, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x0a, 0x63, 0x61, 0x74, + 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x52, 0x0a, + 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x32, 0xbf, 0x04, 0x0a, 0x0e, 0x54, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5d, 0x0a, + 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x17, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, 0x2f, 0x61, 0x70, + 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x63, 0x0a, 0x09, + 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x1d, 0x12, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, + 0x7d, 0x12, 0x72, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, + 0x52, 0x65, 0x66, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x82, 0xd3, 0xe4, + 0x93, 0x02, 0x22, 0x12, 0x20, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x72, 0x65, 0x66, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x5f, 0x72, 0x65, 0x66, 0x7d, 0x12, 0x87, 0x01, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x20, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x2c, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x12, 0x24, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x5f, 0x69, 0x64, 0x7d, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, + 0x6b, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, + 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x22, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, + 0x12, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x73, 0x2f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x42, 0x9a, 0x01, 0x92, + 0x41, 0x7c, 0x12, 0x53, 0x0a, 0x10, 0x37, 0x43, 0x61, 0x76, 0x20, 0x54, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x73, 0x20, 0x41, 0x50, 0x49, 0x12, 0x38, 0x52, 0x65, 0x61, 0x64, 0x2d, 0x6f, 0x6e, 0x6c, + 0x79, 0x20, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x20, 0x74, 0x6f, 0x20, 0x37, 0x43, 0x61, 0x76, + 0x20, 0x66, 0x6f, 0x72, 0x75, 0x6d, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x20, 0x28, + 0x6e, 0x69, 0x78, 0x66, 0x69, 0x66, 0x74, 0x79, 0x20, 0x61, 0x64, 0x64, 0x6f, 0x6e, 0x29, 0x2e, + 0x32, 0x05, 0x32, 0x2e, 0x32, 0x2e, 0x30, 0x2a, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x5a, 0x19, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x37, 0x63, 0x61, 0x76, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, +}) + +var ( + file_tickets_proto_rawDescOnce sync.Once + file_tickets_proto_rawDescData []byte +) + +func file_tickets_proto_rawDescGZIP() []byte { + file_tickets_proto_rawDescOnce.Do(func() { + file_tickets_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_tickets_proto_rawDesc), len(file_tickets_proto_rawDesc))) + }) + return file_tickets_proto_rawDescData +} + +var file_tickets_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_tickets_proto_goTypes = []any{ + (*Ticket)(nil), // 0: proto.Ticket + (*Message)(nil), // 1: proto.Message + (*Category)(nil), // 2: proto.Category + (*TicketParticipant)(nil), // 3: proto.TicketParticipant + (*ListTicketsRequest)(nil), // 4: proto.ListTicketsRequest + (*ListTicketsResponse)(nil), // 5: proto.ListTicketsResponse + (*GetTicketRequest)(nil), // 6: proto.GetTicketRequest + (*GetTicketByRefRequest)(nil), // 7: proto.GetTicketByRefRequest + (*GetTicketResponse)(nil), // 8: proto.GetTicketResponse + (*ListTicketMessagesRequest)(nil), // 9: proto.ListTicketMessagesRequest + (*ListTicketMessagesResponse)(nil), // 10: proto.ListTicketMessagesResponse + (*ListCategoriesResponse)(nil), // 11: proto.ListCategoriesResponse + nil, // 12: proto.Ticket.CustomFieldsEntry + (*emptypb.Empty)(nil), // 13: google.protobuf.Empty +} +var file_tickets_proto_depIdxs = []int32{ + 3, // 0: proto.Ticket.participants:type_name -> proto.TicketParticipant + 12, // 1: proto.Ticket.custom_fields:type_name -> proto.Ticket.CustomFieldsEntry + 0, // 2: proto.ListTicketsResponse.tickets:type_name -> proto.Ticket + 0, // 3: proto.GetTicketResponse.ticket:type_name -> proto.Ticket + 1, // 4: proto.GetTicketResponse.first_messages:type_name -> proto.Message + 1, // 5: proto.ListTicketMessagesResponse.messages:type_name -> proto.Message + 2, // 6: proto.ListCategoriesResponse.categories:type_name -> proto.Category + 4, // 7: proto.TicketsService.ListTickets:input_type -> proto.ListTicketsRequest + 6, // 8: proto.TicketsService.GetTicket:input_type -> proto.GetTicketRequest + 7, // 9: proto.TicketsService.GetTicketByRef:input_type -> proto.GetTicketByRefRequest + 9, // 10: proto.TicketsService.ListTicketMessages:input_type -> proto.ListTicketMessagesRequest + 13, // 11: proto.TicketsService.ListCategories:input_type -> google.protobuf.Empty + 5, // 12: proto.TicketsService.ListTickets:output_type -> proto.ListTicketsResponse + 8, // 13: proto.TicketsService.GetTicket:output_type -> proto.GetTicketResponse + 8, // 14: proto.TicketsService.GetTicketByRef:output_type -> proto.GetTicketResponse + 10, // 15: proto.TicketsService.ListTicketMessages:output_type -> proto.ListTicketMessagesResponse + 11, // 16: proto.TicketsService.ListCategories:output_type -> proto.ListCategoriesResponse + 12, // [12:17] is the sub-list for method output_type + 7, // [7:12] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_tickets_proto_init() } +func file_tickets_proto_init() { + if File_tickets_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_tickets_proto_rawDesc), len(file_tickets_proto_rawDesc)), + NumEnums: 0, + NumMessages: 13, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_tickets_proto_goTypes, + DependencyIndexes: file_tickets_proto_depIdxs, + MessageInfos: file_tickets_proto_msgTypes, + }.Build() + File_tickets_proto = out.File + file_tickets_proto_goTypes = nil + file_tickets_proto_depIdxs = nil +} diff --git a/proto/tickets.pb.gw.go b/proto/tickets.pb.gw.go new file mode 100644 index 0000000..1037c9e --- /dev/null +++ b/proto/tickets.pb.gw.go @@ -0,0 +1,459 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: tickets.proto + +/* +Package proto is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package proto + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/emptypb" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +var filter_TicketsService_ListTickets_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_TicketsService_ListTickets_0(ctx context.Context, marshaler runtime.Marshaler, client TicketsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTicketsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_TicketsService_ListTickets_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListTickets(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_TicketsService_ListTickets_0(ctx context.Context, marshaler runtime.Marshaler, server TicketsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTicketsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_TicketsService_ListTickets_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListTickets(ctx, &protoReq) + return msg, metadata, err +} + +func request_TicketsService_GetTicket_0(ctx context.Context, marshaler runtime.Marshaler, client TicketsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetTicketRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_id") + } + protoReq.TicketId, err = runtime.Uint32(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_id", err) + } + msg, err := client.GetTicket(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_TicketsService_GetTicket_0(ctx context.Context, marshaler runtime.Marshaler, server TicketsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetTicketRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_id") + } + protoReq.TicketId, err = runtime.Uint32(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_id", err) + } + msg, err := server.GetTicket(ctx, &protoReq) + return msg, metadata, err +} + +func request_TicketsService_GetTicketByRef_0(ctx context.Context, marshaler runtime.Marshaler, client TicketsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetTicketByRefRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_ref"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_ref") + } + protoReq.TicketRef, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_ref", err) + } + msg, err := client.GetTicketByRef(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_TicketsService_GetTicketByRef_0(ctx context.Context, marshaler runtime.Marshaler, server TicketsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetTicketByRefRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_ref"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_ref") + } + protoReq.TicketRef, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_ref", err) + } + msg, err := server.GetTicketByRef(ctx, &protoReq) + return msg, metadata, err +} + +var filter_TicketsService_ListTicketMessages_0 = &utilities.DoubleArray{Encoding: map[string]int{"ticket_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} + +func request_TicketsService_ListTicketMessages_0(ctx context.Context, marshaler runtime.Marshaler, client TicketsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTicketMessagesRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_id") + } + protoReq.TicketId, err = runtime.Uint32(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_id", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_TicketsService_ListTicketMessages_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListTicketMessages(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_TicketsService_ListTicketMessages_0(ctx context.Context, marshaler runtime.Marshaler, server TicketsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTicketMessagesRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["ticket_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "ticket_id") + } + protoReq.TicketId, err = runtime.Uint32(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "ticket_id", err) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_TicketsService_ListTicketMessages_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListTicketMessages(ctx, &protoReq) + return msg, metadata, err +} + +func request_TicketsService_ListCategories_0(ctx context.Context, marshaler runtime.Marshaler, client TicketsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq emptypb.Empty + metadata runtime.ServerMetadata + ) + msg, err := client.ListCategories(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_TicketsService_ListCategories_0(ctx context.Context, marshaler runtime.Marshaler, server TicketsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq emptypb.Empty + metadata runtime.ServerMetadata + ) + msg, err := server.ListCategories(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterTicketsServiceHandlerServer registers the http handlers for service TicketsService to "mux". +// UnaryRPC :call TicketsServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterTicketsServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterTicketsServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server TicketsServiceServer) error { + mux.Handle(http.MethodGet, pattern_TicketsService_ListTickets_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/proto.TicketsService/ListTickets", runtime.WithHTTPPathPattern("/api/v1/tickets")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_TicketsService_ListTickets_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListTickets_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_GetTicket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/proto.TicketsService/GetTicket", runtime.WithHTTPPathPattern("/api/v1/tickets/{ticket_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_TicketsService_GetTicket_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_GetTicket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_GetTicketByRef_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/proto.TicketsService/GetTicketByRef", runtime.WithHTTPPathPattern("/api/v1/tickets/ref/{ticket_ref}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_TicketsService_GetTicketByRef_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_GetTicketByRef_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_ListTicketMessages_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/proto.TicketsService/ListTicketMessages", runtime.WithHTTPPathPattern("/api/v1/tickets/{ticket_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_TicketsService_ListTicketMessages_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListTicketMessages_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_ListCategories_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/proto.TicketsService/ListCategories", runtime.WithHTTPPathPattern("/api/v1/tickets/categories")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_TicketsService_ListCategories_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListCategories_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterTicketsServiceHandlerFromEndpoint is same as RegisterTicketsServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterTicketsServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterTicketsServiceHandler(ctx, mux, conn) +} + +// RegisterTicketsServiceHandler registers the http handlers for service TicketsService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterTicketsServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterTicketsServiceHandlerClient(ctx, mux, NewTicketsServiceClient(conn)) +} + +// RegisterTicketsServiceHandlerClient registers the http handlers for service TicketsService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "TicketsServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "TicketsServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "TicketsServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterTicketsServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client TicketsServiceClient) error { + mux.Handle(http.MethodGet, pattern_TicketsService_ListTickets_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/proto.TicketsService/ListTickets", runtime.WithHTTPPathPattern("/api/v1/tickets")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_TicketsService_ListTickets_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListTickets_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_GetTicket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/proto.TicketsService/GetTicket", runtime.WithHTTPPathPattern("/api/v1/tickets/{ticket_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_TicketsService_GetTicket_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_GetTicket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_GetTicketByRef_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/proto.TicketsService/GetTicketByRef", runtime.WithHTTPPathPattern("/api/v1/tickets/ref/{ticket_ref}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_TicketsService_GetTicketByRef_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_GetTicketByRef_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_ListTicketMessages_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/proto.TicketsService/ListTicketMessages", runtime.WithHTTPPathPattern("/api/v1/tickets/{ticket_id}/messages")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_TicketsService_ListTicketMessages_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListTicketMessages_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_TicketsService_ListCategories_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/proto.TicketsService/ListCategories", runtime.WithHTTPPathPattern("/api/v1/tickets/categories")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_TicketsService_ListCategories_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_TicketsService_ListCategories_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_TicketsService_ListTickets_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "tickets"}, "")) + pattern_TicketsService_GetTicket_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "tickets", "ticket_id"}, "")) + pattern_TicketsService_GetTicketByRef_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "v1", "tickets", "ref", "ticket_ref"}, "")) + pattern_TicketsService_ListTicketMessages_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "tickets", "ticket_id", "messages"}, "")) + pattern_TicketsService_ListCategories_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "tickets", "categories"}, "")) +) + +var ( + forward_TicketsService_ListTickets_0 = runtime.ForwardResponseMessage + forward_TicketsService_GetTicket_0 = runtime.ForwardResponseMessage + forward_TicketsService_GetTicketByRef_0 = runtime.ForwardResponseMessage + forward_TicketsService_ListTicketMessages_0 = runtime.ForwardResponseMessage + forward_TicketsService_ListCategories_0 = runtime.ForwardResponseMessage +) diff --git a/proto/tickets_grpc.pb.go b/proto/tickets_grpc.pb.go new file mode 100644 index 0000000..95ff60f --- /dev/null +++ b/proto/tickets_grpc.pb.go @@ -0,0 +1,279 @@ +// +// Copyright (C) 2026 7Cav.us +// This file is part of 7Cav-API . +// +// Licensed under the GNU General Public License v3 (see milpacs.proto for +// the full notice). + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: tickets.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + TicketsService_ListTickets_FullMethodName = "/proto.TicketsService/ListTickets" + TicketsService_GetTicket_FullMethodName = "/proto.TicketsService/GetTicket" + TicketsService_GetTicketByRef_FullMethodName = "/proto.TicketsService/GetTicketByRef" + TicketsService_ListTicketMessages_FullMethodName = "/proto.TicketsService/ListTicketMessages" + TicketsService_ListCategories_FullMethodName = "/proto.TicketsService/ListCategories" +) + +// TicketsServiceClient is the client API for TicketsService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TicketsServiceClient interface { + ListTickets(ctx context.Context, in *ListTicketsRequest, opts ...grpc.CallOption) (*ListTicketsResponse, error) + GetTicket(ctx context.Context, in *GetTicketRequest, opts ...grpc.CallOption) (*GetTicketResponse, error) + GetTicketByRef(ctx context.Context, in *GetTicketByRefRequest, opts ...grpc.CallOption) (*GetTicketResponse, error) + ListTicketMessages(ctx context.Context, in *ListTicketMessagesRequest, opts ...grpc.CallOption) (*ListTicketMessagesResponse, error) + ListCategories(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCategoriesResponse, error) +} + +type ticketsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTicketsServiceClient(cc grpc.ClientConnInterface) TicketsServiceClient { + return &ticketsServiceClient{cc} +} + +func (c *ticketsServiceClient) ListTickets(ctx context.Context, in *ListTicketsRequest, opts ...grpc.CallOption) (*ListTicketsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListTicketsResponse) + err := c.cc.Invoke(ctx, TicketsService_ListTickets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *ticketsServiceClient) GetTicket(ctx context.Context, in *GetTicketRequest, opts ...grpc.CallOption) (*GetTicketResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetTicketResponse) + err := c.cc.Invoke(ctx, TicketsService_GetTicket_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *ticketsServiceClient) GetTicketByRef(ctx context.Context, in *GetTicketByRefRequest, opts ...grpc.CallOption) (*GetTicketResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetTicketResponse) + err := c.cc.Invoke(ctx, TicketsService_GetTicketByRef_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *ticketsServiceClient) ListTicketMessages(ctx context.Context, in *ListTicketMessagesRequest, opts ...grpc.CallOption) (*ListTicketMessagesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListTicketMessagesResponse) + err := c.cc.Invoke(ctx, TicketsService_ListTicketMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *ticketsServiceClient) ListCategories(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListCategoriesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListCategoriesResponse) + err := c.cc.Invoke(ctx, TicketsService_ListCategories_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TicketsServiceServer is the server API for TicketsService service. +// All implementations should embed UnimplementedTicketsServiceServer +// for forward compatibility. +type TicketsServiceServer interface { + ListTickets(context.Context, *ListTicketsRequest) (*ListTicketsResponse, error) + GetTicket(context.Context, *GetTicketRequest) (*GetTicketResponse, error) + GetTicketByRef(context.Context, *GetTicketByRefRequest) (*GetTicketResponse, error) + ListTicketMessages(context.Context, *ListTicketMessagesRequest) (*ListTicketMessagesResponse, error) + ListCategories(context.Context, *emptypb.Empty) (*ListCategoriesResponse, error) +} + +// UnimplementedTicketsServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTicketsServiceServer struct{} + +func (UnimplementedTicketsServiceServer) ListTickets(context.Context, *ListTicketsRequest) (*ListTicketsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTickets not implemented") +} +func (UnimplementedTicketsServiceServer) GetTicket(context.Context, *GetTicketRequest) (*GetTicketResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTicket not implemented") +} +func (UnimplementedTicketsServiceServer) GetTicketByRef(context.Context, *GetTicketByRefRequest) (*GetTicketResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTicketByRef not implemented") +} +func (UnimplementedTicketsServiceServer) ListTicketMessages(context.Context, *ListTicketMessagesRequest) (*ListTicketMessagesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTicketMessages not implemented") +} +func (UnimplementedTicketsServiceServer) ListCategories(context.Context, *emptypb.Empty) (*ListCategoriesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListCategories not implemented") +} +func (UnimplementedTicketsServiceServer) testEmbeddedByValue() {} + +// UnsafeTicketsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TicketsServiceServer will +// result in compilation errors. +type UnsafeTicketsServiceServer interface { + mustEmbedUnimplementedTicketsServiceServer() +} + +func RegisterTicketsServiceServer(s grpc.ServiceRegistrar, srv TicketsServiceServer) { + // If the following call pancis, it indicates UnimplementedTicketsServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TicketsService_ServiceDesc, srv) +} + +func _TicketsService_ListTickets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTicketsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TicketsServiceServer).ListTickets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TicketsService_ListTickets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TicketsServiceServer).ListTickets(ctx, req.(*ListTicketsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TicketsService_GetTicket_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTicketRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TicketsServiceServer).GetTicket(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TicketsService_GetTicket_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TicketsServiceServer).GetTicket(ctx, req.(*GetTicketRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TicketsService_GetTicketByRef_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTicketByRefRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TicketsServiceServer).GetTicketByRef(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TicketsService_GetTicketByRef_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TicketsServiceServer).GetTicketByRef(ctx, req.(*GetTicketByRefRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TicketsService_ListTicketMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTicketMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TicketsServiceServer).ListTicketMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TicketsService_ListTicketMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TicketsServiceServer).ListTicketMessages(ctx, req.(*ListTicketMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TicketsService_ListCategories_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TicketsServiceServer).ListCategories(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TicketsService_ListCategories_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TicketsServiceServer).ListCategories(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +// TicketsService_ServiceDesc is the grpc.ServiceDesc for TicketsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TicketsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "proto.TicketsService", + HandlerType: (*TicketsServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListTickets", + Handler: _TicketsService_ListTickets_Handler, + }, + { + MethodName: "GetTicket", + Handler: _TicketsService_GetTicket_Handler, + }, + { + MethodName: "GetTicketByRef", + Handler: _TicketsService_GetTicketByRef_Handler, + }, + { + MethodName: "ListTicketMessages", + Handler: _TicketsService_ListTicketMessages_Handler, + }, + { + MethodName: "ListCategories", + Handler: _TicketsService_ListCategories_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "tickets.proto", +} diff --git a/third_party/OpenAPI/tickets.swagger.json b/third_party/OpenAPI/tickets.swagger.json new file mode 100644 index 0000000..6f1714c --- /dev/null +++ b/third_party/OpenAPI/tickets.swagger.json @@ -0,0 +1,586 @@ +{ + "swagger": "2.0", + "info": { + "title": "7Cav Tickets API", + "description": "Read-only access to 7Cav forum tickets (nixfifty addon).", + "version": "2.2.0" + }, + "tags": [ + { + "name": "TicketsService" + } + ], + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/api/v1/tickets": { + "get": { + "operationId": "TicketsService_ListTickets", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protoListTicketsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "categoryId", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "multi" + }, + { + "name": "excludeSubcategories", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "ticketState", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi" + }, + { + "name": "statusId", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "multi" + }, + { + "name": "prefixId", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "multi" + }, + { + "name": "assignedUserId", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "multi" + }, + { + "name": "starterUserId", + "in": "query", + "required": false, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "multi" + }, + { + "name": "modifiedSince", + "in": "query", + "required": false, + "type": "integer", + "format": "int64" + }, + { + "name": "includeHidden", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "perPage", + "in": "query", + "required": false, + "type": "integer", + "format": "int64" + }, + { + "name": "afterCursor", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "TicketsService" + ] + } + }, + "/api/v1/tickets/categories": { + "get": { + "operationId": "TicketsService_ListCategories", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protoListCategoriesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "tags": [ + "TicketsService" + ] + } + }, + "/api/v1/tickets/ref/{ticketRef}": { + "get": { + "operationId": "TicketsService_GetTicketByRef", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protoGetTicketResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "ticketRef", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "TicketsService" + ] + } + }, + "/api/v1/tickets/{ticketId}": { + "get": { + "operationId": "TicketsService_GetTicket", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protoGetTicketResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "tags": [ + "TicketsService" + ] + } + }, + "/api/v1/tickets/{ticketId}/messages": { + "get": { + "operationId": "TicketsService_ListTicketMessages", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protoListTicketMessagesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "ticketId", + "in": "path", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "name": "perPage", + "in": "query", + "required": false, + "type": "integer", + "format": "int64" + }, + { + "name": "afterPosition", + "in": "query", + "required": false, + "type": "integer", + "format": "int64" + }, + { + "name": "includeHidden", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "tags": [ + "TicketsService" + ] + } + } + }, + "definitions": { + "protoCategory": { + "type": "object", + "properties": { + "categoryId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parentCategoryId": { + "type": "integer", + "format": "int64" + }, + "depth": { + "type": "integer", + "format": "int64" + }, + "displayOrder": { + "type": "integer", + "format": "int64" + }, + "ticketCount": { + "type": "integer", + "format": "int64" + } + } + }, + "protoGetTicketResponse": { + "type": "object", + "properties": { + "ticket": { + "$ref": "#/definitions/protoTicket" + }, + "firstMessages": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protoMessage" + } + }, + "totalMessageCount": { + "type": "integer", + "format": "int64" + } + } + }, + "protoListCategoriesResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protoCategory" + } + } + } + }, + "protoListTicketMessagesResponse": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protoMessage" + } + }, + "nextCursor": { + "type": "integer", + "format": "int64" + }, + "hasMore": { + "type": "boolean" + } + } + }, + "protoListTicketsResponse": { + "type": "object", + "properties": { + "tickets": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protoTicket" + } + }, + "nextCursor": { + "type": "string" + }, + "hasMore": { + "type": "boolean" + } + } + }, + "protoMessage": { + "type": "object", + "properties": { + "messageId": { + "type": "integer", + "format": "int64" + }, + "ticketId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + }, + "messageDate": { + "type": "integer", + "format": "int64" + }, + "message": { + "type": "string" + }, + "messageState": { + "type": "string" + }, + "position": { + "type": "integer", + "format": "int64" + }, + "attachCount": { + "type": "integer", + "format": "int64" + }, + "lastEditDate": { + "type": "integer", + "format": "int64" + }, + "editCount": { + "type": "integer", + "format": "int64" + } + } + }, + "protoTicket": { + "type": "object", + "properties": { + "ticketId": { + "type": "integer", + "format": "int64", + "title": "Identity" + }, + "ticketRef": { + "type": "string" + }, + "title": { + "type": "string" + }, + "categoryId": { + "type": "integer", + "format": "int64", + "title": "Category" + }, + "categoryTitle": { + "type": "string" + }, + "categoryAncestorIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + }, + "ticketState": { + "type": "string", + "title": "State / workflow" + }, + "statusId": { + "type": "integer", + "format": "int64" + }, + "statusName": { + "type": "string" + }, + "priorityId": { + "type": "integer", + "format": "int64" + }, + "priorityName": { + "type": "string" + }, + "prefixId": { + "type": "integer", + "format": "int64" + }, + "prefixName": { + "type": "string" + }, + "discussionState": { + "type": "string" + }, + "ticketLocked": { + "type": "boolean" + }, + "starterUserId": { + "type": "integer", + "format": "int64", + "title": "People" + }, + "starterUsername": { + "type": "string" + }, + "assignedUserId": { + "type": "integer", + "format": "int64" + }, + "assignedUsername": { + "type": "string" + }, + "participants": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protoTicketParticipant" + } + }, + "startDate": { + "type": "integer", + "format": "int64", + "title": "Activity" + }, + "lastMessageDate": { + "type": "integer", + "format": "int64" + }, + "lastMessageUserId": { + "type": "integer", + "format": "int64" + }, + "lastMessageUsername": { + "type": "string" + }, + "lastModifiedDate": { + "type": "integer", + "format": "int64" + }, + "replyCount": { + "type": "integer", + "format": "int64" + }, + "customFields": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Data" + }, + "forumUrl": { + "type": "string", + "title": "Convenience" + } + } + }, + "protoTicketParticipant": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64" + }, + "lastReadDate": { + "type": "integer", + "format": "int64" + } + } + }, + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + } + } +} From e164a426e4f8a3b9d39d31b061fbf8a052a14821 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 16:56:39 -0400 Subject: [PATCH 09/32] fix(proto): add bearer auth + per-RPC openapiv2 annotations to tickets Mirrors milpacs.proto convention so Swagger UI renders the Authorize button for tickets and each RPC shows a summary + description + tag group instead of just the operationId. --- proto/tickets.pb.go | 154 ++++++++++++++++------- proto/tickets.proto | 41 ++++++ third_party/OpenAPI/tickets.swagger.json | 35 +++++- 3 files changed, 178 insertions(+), 52 deletions(-) diff --git a/proto/tickets.pb.go b/proto/tickets.pb.go index 2f2e0f7..b838614 100644 --- a/proto/tickets.pb.go +++ b/proto/tickets.pb.go @@ -1271,54 +1271,116 @@ var file_tickets_proto_rawDesc = string([]byte{ 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x0a, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x52, 0x0a, - 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x32, 0xbf, 0x04, 0x0a, 0x0e, 0x54, - 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5d, 0x0a, - 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x17, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, 0x2f, 0x61, 0x70, - 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x63, 0x0a, 0x09, - 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, - 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x1d, 0x12, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, - 0x7d, 0x12, 0x72, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, - 0x52, 0x65, 0x66, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, - 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, - 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x82, 0xd3, 0xe4, - 0x93, 0x02, 0x22, 0x12, 0x20, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, - 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x72, 0x65, 0x66, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, - 0x5f, 0x72, 0x65, 0x66, 0x7d, 0x12, 0x87, 0x01, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, - 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x20, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, - 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x2c, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x12, 0x24, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x32, 0xb7, 0x0b, 0x0a, 0x0e, 0x54, + 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x9d, 0x02, + 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xd6, 0x01, 0x92, 0x41, 0xbb, 0x01, 0x0a, 0x07, 0x54, 0x69, 0x63, + 0x6b, 0x65, 0x74, 0x73, 0x12, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x73, 0x1a, 0xa1, 0x01, 0x4c, 0x69, 0x73, 0x74, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x73, 0x20, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x61, 0x6c, 0x20, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x73, 0x20, 0x28, 0x63, 0x61, 0x74, + 0x65, 0x67, 0x6f, 0x72, 0x79, 0x2c, 0x20, 0x73, 0x74, 0x61, 0x74, 0x65, 0x2c, 0x20, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x2c, 0x20, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x2c, 0x20, 0x61, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x65, 0x2c, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2c, + 0x20, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x29, + 0x2e, 0x20, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x2d, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, + 0x65, 0x64, 0x2c, 0x20, 0x73, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x6c, 0x61, + 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x65, + 0x20, 0x44, 0x45, 0x53, 0x43, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0xbf, 0x02, + 0x0a, 0x09, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x17, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, + 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xfe, + 0x01, 0x92, 0x41, 0xd7, 0x01, 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x1a, + 0x47, 0x65, 0x74, 0x20, 0x61, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x20, 0x62, 0x79, 0x20, + 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x20, 0x49, 0x44, 0x1a, 0xaf, 0x01, 0x46, 0x65, 0x74, + 0x63, 0x68, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x74, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x20, 0x62, 0x79, 0x20, 0x69, 0x74, 0x73, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, + 0x72, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x2e, 0x20, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x20, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x73, 0x20, 0x74, + 0x68, 0x65, 0x20, 0x66, 0x69, 0x72, 0x73, 0x74, 0x20, 0x31, 0x30, 0x20, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x20, 0x28, 0x6f, 0x6c, 0x64, 0x65, 0x73, 0x74, 0x20, 0x66, 0x69, 0x72, + 0x73, 0x74, 0x29, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x74, 0x68, 0x65, 0x20, 0x74, 0x6f, 0x74, 0x61, + 0x6c, 0x20, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x20, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x3b, + 0x20, 0x75, 0x73, 0x65, 0x20, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x74, 0x68, 0x65, 0x20, + 0x66, 0x75, 0x6c, 0x6c, 0x20, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x2e, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x1d, 0x12, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x73, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x7d, 0x12, + 0x8e, 0x02, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, + 0x65, 0x66, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, + 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, + 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xc3, 0x01, 0x92, 0x41, 0x97, + 0x01, 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x18, 0x47, 0x65, 0x74, 0x20, + 0x61, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x20, 0x62, 0x79, 0x20, 0x72, 0x65, 0x66, 0x20, + 0x73, 0x6c, 0x75, 0x67, 0x1a, 0x72, 0x46, 0x65, 0x74, 0x63, 0x68, 0x20, 0x61, 0x20, 0x73, 0x69, + 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x20, 0x62, 0x79, 0x20, 0x69, + 0x74, 0x73, 0x20, 0x75, 0x73, 0x65, 0x72, 0x2d, 0x66, 0x61, 0x63, 0x69, 0x6e, 0x67, 0x20, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x20, 0x72, 0x65, 0x66, 0x65, + 0x72, 0x65, 0x6e, 0x63, 0x65, 0x20, 0x28, 0x65, 0x2e, 0x67, 0x2e, 0x20, 0x4d, 0x46, 0x31, 0x55, + 0x49, 0x39, 0x48, 0x45, 0x29, 0x2e, 0x20, 0x53, 0x61, 0x6d, 0x65, 0x20, 0x72, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x20, 0x73, 0x68, 0x61, 0x70, 0x65, 0x20, 0x61, 0x73, 0x20, 0x47, 0x65, + 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x22, 0x12, 0x20, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, + 0x72, 0x65, 0x66, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x7d, + 0x12, 0xac, 0x02, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xd0, 0x01, 0x92, + 0x41, 0xa0, 0x01, 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x4c, 0x69, + 0x73, 0x74, 0x20, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x20, 0x6f, 0x6e, 0x20, 0x61, + 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x1a, 0x7a, 0x50, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, + 0x65, 0x64, 0x20, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x20, 0x6f, 0x66, 0x20, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, + 0x6c, 0x65, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x2c, 0x20, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x61, + 0x73, 0x63, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x2e, 0x20, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, + 0x20, 0x75, 0x73, 0x65, 0x73, 0x20, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x28, + 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x20, 0x70, 0x65, 0x72, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x29, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x12, 0x24, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x7b, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x7d, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, - 0x6b, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, - 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x22, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, - 0x12, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, - 0x73, 0x2f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, 0x73, 0x42, 0x9a, 0x01, 0x92, - 0x41, 0x7c, 0x12, 0x53, 0x0a, 0x10, 0x37, 0x43, 0x61, 0x76, 0x20, 0x54, 0x69, 0x63, 0x6b, 0x65, - 0x74, 0x73, 0x20, 0x41, 0x50, 0x49, 0x12, 0x38, 0x52, 0x65, 0x61, 0x64, 0x2d, 0x6f, 0x6e, 0x6c, - 0x79, 0x20, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x20, 0x74, 0x6f, 0x20, 0x37, 0x43, 0x61, 0x76, - 0x20, 0x66, 0x6f, 0x72, 0x75, 0x6d, 0x20, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x20, 0x28, - 0x6e, 0x69, 0x78, 0x66, 0x69, 0x66, 0x74, 0x79, 0x20, 0x61, 0x64, 0x64, 0x6f, 0x6e, 0x29, 0x2e, - 0x32, 0x05, 0x32, 0x2e, 0x32, 0x2e, 0x30, 0x2a, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, - 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, - 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x5a, 0x19, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x37, 0x63, 0x61, 0x76, 0x2f, - 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x82, 0x02, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, + 0x65, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x92, 0x41, 0x92, 0x01, + 0x0a, 0x07, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x20, + 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x20, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, + 0x73, 0x1a, 0x6f, 0x52, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x66, + 0x75, 0x6c, 0x6c, 0x20, 0x6e, 0x69, 0x78, 0x66, 0x69, 0x66, 0x74, 0x79, 0x20, 0x63, 0x61, 0x74, + 0x65, 0x67, 0x6f, 0x72, 0x79, 0x20, 0x74, 0x72, 0x65, 0x65, 0x20, 0x28, 0x32, 0x34, 0x20, 0x65, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x29, 0x2e, 0x20, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x20, 0x64, 0x61, 0x74, 0x61, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x74, 0x68, 0x65, 0x20, + 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x20, 0x66, 0x69, 0x6c, 0x74, + 0x65, 0x72, 0x20, 0x6f, 0x6e, 0x20, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x73, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x12, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, + 0x31, 0x2f, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x2f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, + 0x72, 0x69, 0x65, 0x73, 0x42, 0x84, 0x02, 0x92, 0x41, 0xe5, 0x01, 0x12, 0x53, 0x0a, 0x10, 0x37, + 0x43, 0x61, 0x76, 0x20, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x20, 0x41, 0x50, 0x49, 0x12, + 0x38, 0x52, 0x65, 0x61, 0x64, 0x2d, 0x6f, 0x6e, 0x6c, 0x79, 0x20, 0x61, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x20, 0x74, 0x6f, 0x20, 0x37, 0x43, 0x61, 0x76, 0x20, 0x66, 0x6f, 0x72, 0x75, 0x6d, 0x20, + 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x20, 0x28, 0x6e, 0x69, 0x78, 0x66, 0x69, 0x66, 0x74, + 0x79, 0x20, 0x61, 0x64, 0x64, 0x6f, 0x6e, 0x29, 0x2e, 0x32, 0x05, 0x32, 0x2e, 0x32, 0x2e, 0x30, + 0x2a, 0x01, 0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x5a, 0x59, 0x0a, 0x57, 0x0a, 0x06, 0x62, 0x65, 0x61, + 0x72, 0x65, 0x72, 0x12, 0x4d, 0x08, 0x02, 0x12, 0x38, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2c, 0x20, 0x70, + 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x42, 0x65, 0x61, 0x72, 0x65, + 0x72, 0x3a, 0x20, 0x42, 0x65, 0x61, 0x72, 0x65, 0x72, 0x20, 0x3c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x3e, 0x1a, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x20, 0x02, 0x62, 0x0c, 0x0a, 0x0a, 0x0a, 0x06, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, 0x12, 0x00, + 0x5a, 0x19, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x37, 0x63, 0x61, + 0x76, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, }) var ( diff --git a/proto/tickets.proto b/proto/tickets.proto index 75424ae..67df258 100644 --- a/proto/tickets.proto +++ b/proto/tickets.proto @@ -25,23 +25,64 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { schemes: HTTPS; consumes: "application/json"; produces: "application/json"; + security_definitions: { + security: { + key: "bearer" + value: { + type: TYPE_API_KEY + in: IN_HEADER + name: "Authorization" + description: "Authentication token, prefixed by Bearer: Bearer " + } + } + } + security: { + security_requirement: { + key: "bearer" + } + } }; service TicketsService { rpc ListTickets(ListTicketsRequest) returns (ListTicketsResponse) { option (google.api.http) = { get: "/api/v1/tickets" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List tickets" + description: "List tickets matching optional filters (category, state, status, prefix, assignee, starter, modified_since). Cursor-paginated, sorted by last_modified_date DESC." + tags: "Tickets" + }; } rpc GetTicket(GetTicketRequest) returns (GetTicketResponse) { option (google.api.http) = { get: "/api/v1/tickets/{ticket_id}" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get a ticket by numeric ID" + description: "Fetch a single ticket by its integer ticket_id. Response includes the first 10 messages (oldest first) and the total message count; use ListTicketMessages for the full thread." + tags: "Tickets" + }; } rpc GetTicketByRef(GetTicketByRefRequest) returns (GetTicketResponse) { option (google.api.http) = { get: "/api/v1/tickets/ref/{ticket_ref}" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get a ticket by ref slug" + description: "Fetch a single ticket by its user-facing alphanumeric reference (e.g. MF1UI9HE). Same response shape as GetTicket." + tags: "Tickets" + }; } rpc ListTicketMessages(ListTicketMessagesRequest) returns (ListTicketMessagesResponse) { option (google.api.http) = { get: "/api/v1/tickets/{ticket_id}/messages" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List messages on a ticket" + description: "Paginated thread of messages for a single ticket, ordered by position ascending. Cursor uses position (unique per ticket)." + tags: "Tickets" + }; } rpc ListCategories(google.protobuf.Empty) returns (ListCategoriesResponse) { option (google.api.http) = { get: "/api/v1/tickets/categories" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List ticket categories" + description: "Returns the full nixfifty category tree (24 entries). Reference data for the category_id filter on ListTickets." + tags: "Tickets" + }; } } diff --git a/third_party/OpenAPI/tickets.swagger.json b/third_party/OpenAPI/tickets.swagger.json index 6f1714c..a63b05a 100644 --- a/third_party/OpenAPI/tickets.swagger.json +++ b/third_party/OpenAPI/tickets.swagger.json @@ -22,6 +22,8 @@ "paths": { "/api/v1/tickets": { "get": { + "summary": "List tickets", + "description": "List tickets matching optional filters (category, state, status, prefix, assignee, starter, modified_since). Cursor-paginated, sorted by last_modified_date DESC.", "operationId": "TicketsService_ListTickets", "responses": { "200": { @@ -137,12 +139,14 @@ } ], "tags": [ - "TicketsService" + "Tickets" ] } }, "/api/v1/tickets/categories": { "get": { + "summary": "List ticket categories", + "description": "Returns the full nixfifty category tree (24 entries). Reference data for the category_id filter on ListTickets.", "operationId": "TicketsService_ListCategories", "responses": { "200": { @@ -159,12 +163,14 @@ } }, "tags": [ - "TicketsService" + "Tickets" ] } }, "/api/v1/tickets/ref/{ticketRef}": { "get": { + "summary": "Get a ticket by ref slug", + "description": "Fetch a single ticket by its user-facing alphanumeric reference (e.g. MF1UI9HE). Same response shape as GetTicket.", "operationId": "TicketsService_GetTicketByRef", "responses": { "200": { @@ -189,12 +195,14 @@ } ], "tags": [ - "TicketsService" + "Tickets" ] } }, "/api/v1/tickets/{ticketId}": { "get": { + "summary": "Get a ticket by numeric ID", + "description": "Fetch a single ticket by its integer ticket_id. Response includes the first 10 messages (oldest first) and the total message count; use ListTicketMessages for the full thread.", "operationId": "TicketsService_GetTicket", "responses": { "200": { @@ -220,12 +228,14 @@ } ], "tags": [ - "TicketsService" + "Tickets" ] } }, "/api/v1/tickets/{ticketId}/messages": { "get": { + "summary": "List messages on a ticket", + "description": "Paginated thread of messages for a single ticket, ordered by position ascending. Cursor uses position (unique per ticket).", "operationId": "TicketsService_ListTicketMessages", "responses": { "200": { @@ -271,7 +281,7 @@ } ], "tags": [ - "TicketsService" + "Tickets" ] } } @@ -582,5 +592,18 @@ } } } - } + }, + "securityDefinitions": { + "bearer": { + "type": "apiKey", + "description": "Authentication token, prefixed by Bearer: Bearer \u003ctoken\u003e", + "name": "Authorization", + "in": "header" + } + }, + "security": [ + { + "bearer": [] + } + ] } From 1c90e28a6d84808e20c62d24a29fcddc32f6a7e2 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:02:57 -0400 Subject: [PATCH 10/32] feat(xenforo): GORM models for xf_nf_tickets_* tables --- xenforo/tickets.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 xenforo/tickets.go diff --git a/xenforo/tickets.go b/xenforo/tickets.go new file mode 100644 index 0000000..49b943a --- /dev/null +++ b/xenforo/tickets.go @@ -0,0 +1,68 @@ +package xenforo + +// Ticket maps to xf_nf_tickets_ticket. Field names follow the existing +// xenforo/* convention (CamelCase from snake_case columns). +type Ticket struct { + TicketID uint32 `gorm:"column:ticket_id;primaryKey"` + TicketRef string `gorm:"column:ticket_ref"` + Title string `gorm:"column:title"` + UserID uint32 `gorm:"column:user_id"` + Username string `gorm:"column:username"` + StartDate uint32 `gorm:"column:start_date"` + FirstMessageID uint32 `gorm:"column:first_message_id"` + FirstMessageDate uint32 `gorm:"column:first_message_date"` + Priority uint32 `gorm:"column:priority"` + StatusID uint32 `gorm:"column:status_id"` + TicketState string `gorm:"column:ticket_state"` + TicketLocked uint32 `gorm:"column:ticket_locked"` + DiscussionState string `gorm:"column:discussion_state"` + AssignedUserID uint32 `gorm:"column:assigned_user_id"` + AssignedUsername string `gorm:"column:assigned_username"` + TicketCategoryID uint32 `gorm:"column:ticket_category_id"` + LastMessageID uint32 `gorm:"column:last_message_id"` + LastMessageDate uint32 `gorm:"column:last_message_date"` + LastMessageUserID uint32 `gorm:"column:last_message_user_id"` + LastMessageUsername string `gorm:"column:last_message_username"` + LastModifiedDate uint32 `gorm:"column:last_modified_date"` + ReplyCount uint32 `gorm:"column:reply_count"` + PrefixID uint32 `gorm:"column:prefix_id"` + StarterUserID uint32 `gorm:"column:starter_user_id"` + StarterUsername string `gorm:"column:starter_username"` + // Custom fields come from the normalized FieldValues, not the blob. + FieldValues []TicketFieldValue `gorm:"foreignKey:TicketID;references:TicketID"` + Participants []TicketParticipant `gorm:"foreignKey:TicketID;references:TicketID"` +} + +func (Ticket) TableName() string { return "xf_nf_tickets_ticket" } + +type TicketMessage struct { + MessageID uint32 `gorm:"column:message_id;primaryKey"` + TicketID uint32 `gorm:"column:ticket_id"` + UserID uint32 `gorm:"column:user_id"` + Username string `gorm:"column:username"` + MessageDate uint32 `gorm:"column:message_date"` + Message string `gorm:"column:message"` + MessageState string `gorm:"column:message_state"` + Position uint32 `gorm:"column:position"` + AttachCount uint32 `gorm:"column:attach_count"` + LastEditDate uint32 `gorm:"column:last_edit_date"` + EditCount uint32 `gorm:"column:edit_count"` +} + +func (TicketMessage) TableName() string { return "xf_nf_tickets_message" } + +type TicketParticipant struct { + TicketID uint32 `gorm:"column:ticket_id;primaryKey"` + UserID uint32 `gorm:"column:user_id;primaryKey"` + LastReadDate uint32 `gorm:"column:last_read_date"` +} + +func (TicketParticipant) TableName() string { return "xf_nf_tickets_ticket_participant" } + +type TicketFieldValue struct { + TicketID uint32 `gorm:"column:ticket_id;primaryKey"` + FieldID string `gorm:"column:field_id;primaryKey"` + FieldValue string `gorm:"column:field_value"` +} + +func (TicketFieldValue) TableName() string { return "xf_nf_tickets_ticket_field_value" } From 228f71c7a8f970356b00fa521e041679f92323c5 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:11:01 -0400 Subject: [PATCH 11/32] feat(datastores): ListTickets with filters + cursor pagination --- datastores/datastore.go | 28 +++++- datastores/tickets.go | 180 +++++++++++++++++++++++++++++++++++ datastores/tickets_test.go | 188 +++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 datastores/tickets_test.go diff --git a/datastores/datastore.go b/datastores/datastore.go index 1ce3bf1..adf7fcb 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -19,10 +19,13 @@ package datastores import ( - "github.com/7cav/api/proto" - "github.com/7cav/api/xenforo" + "context" "log" "os" + + "github.com/7cav/api/proto" + "github.com/7cav/api/referencecache" + "github.com/7cav/api/xenforo" ) var ( @@ -61,4 +64,25 @@ type Datastore interface { GetTableUpdates() ([]xenforo.TableInfo, error) FindProfileByGamertag(gamertag string) (*proto.Profile, error) ValidateApiKey(rawKey string) (*ApiKeyResult, error) + + // Tickets + ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*proto.Ticket, nextCursor string, hasMore bool, err error) + GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*proto.Ticket, error) + GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*proto.Ticket, error) + GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*proto.Message, totalCount uint32, err error) + ListTicketMessages(ctx context.Context, ticketID, afterPosition, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor uint32, hasMore bool, err error) + ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error) +} + +// TicketReferenceCache is the slice of referencecache.ReferenceCache that +// tickets datastore methods need. Defined here (not pulled in via dependency) +// so the Datastore interface stays self-describing and easy to mock in tests. +type TicketReferenceCache interface { + StatusName(id uint32) string + PriorityName(id uint32) string + PrefixName(id uint32) string + Category(id uint32) *referencecache.CategoryRecord + CategoryAncestors(id uint32) []uint32 + CategoryTree() []*referencecache.CategoryRecord + ExpandSubtree(ids []uint32) []uint32 } diff --git a/datastores/tickets.go b/datastores/tickets.go index 8f1e102..b03f394 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -2,11 +2,33 @@ package datastores import ( "context" + "encoding/base64" + "fmt" "strconv" + "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "github.com/7cav/api/xenforo" + "github.com/spf13/viper" ) +// ListTicketsFilter carries the conjunctive filter knobs supported by +// ListTickets. Empty slices and zero-valued scalars mean "no filter." +type ListTicketsFilter struct { + CategoryIDs []uint32 + ExcludeSubcategories bool + TicketStates []string + StatusIDs []uint32 + PrefixIDs []uint32 + AssignedUserIDs []uint32 + StarterUserIDs []uint32 + ModifiedSince uint32 + IncludeHidden bool + + PerPage uint32 + AfterCursor string +} + // Compile-time assertion: Mysql must implement referencecache.Loader. var _ referencecache.Loader = (*Mysql)(nil) @@ -48,6 +70,164 @@ func (ds *Mysql) loadPhraseMap(ctx context.Context, prefix string) (map[uint32]s return out, nil } +func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + perPage := f.PerPage + if perPage == 0 || perPage > 100 { + if perPage == 0 { + perPage = 50 + } else { + perPage = 100 + } + } + + q := ds.Db.WithContext(ctx). + Model(&xenforo.Ticket{}). + Preload("Participants"). + Preload("FieldValues") + + // Visibility default: only visible discussion_state unless include_hidden. + if !f.IncludeHidden { + q = q.Where("discussion_state = ?", "visible") + } + + // Category filter with subtree expansion (default expands; opt-out via ExcludeSubcategories). + if len(f.CategoryIDs) > 0 { + ids := f.CategoryIDs + if !f.ExcludeSubcategories { + ids = rc.ExpandSubtree(ids) + } + q = q.Where("ticket_category_id IN ?", ids) + } + if len(f.TicketStates) > 0 { + q = q.Where("ticket_state IN ?", f.TicketStates) + } + if len(f.StatusIDs) > 0 { + q = q.Where("status_id IN ?", f.StatusIDs) + } + if len(f.PrefixIDs) > 0 { + q = q.Where("prefix_id IN ?", f.PrefixIDs) + } + if len(f.AssignedUserIDs) > 0 { + q = q.Where("assigned_user_id IN ?", f.AssignedUserIDs) + } + if len(f.StarterUserIDs) > 0 { + q = q.Where("starter_user_id IN ?", f.StarterUserIDs) + } + if f.ModifiedSince > 0 { + q = q.Where("last_modified_date >= ?", f.ModifiedSince) + } + + if f.AfterCursor != "" { + ts, id, err := decodeCursor(f.AfterCursor) + if err != nil { + return nil, "", false, fmt.Errorf("invalid cursor: %w", err) + } + // Tuple comparison for stable cursor under non-unique sort key. + q = q.Where("(last_modified_date < ?) OR (last_modified_date = ? AND ticket_id < ?)", ts, ts, id) + } + + // Fetch perPage+1 so we know whether there's another page without a count query. + var rows []xenforo.Ticket + q = q.Order("last_modified_date DESC, ticket_id DESC").Limit(int(perPage) + 1) + tx := q.Find(&rows) + if tx.Error != nil { + return nil, "", false, tx.Error + } + + hasMore := len(rows) > int(perPage) + if hasMore { + rows = rows[:perPage] + } + + out := make([]*proto.Ticket, 0, len(rows)) + for i := range rows { + out = append(out, generateTicketProto(&rows[i], rc, ds.forumBaseURL())) + } + + var nextCursor string + if hasMore && len(rows) > 0 { + last := rows[len(rows)-1] + nextCursor = encodeCursor(last.LastModifiedDate, last.TicketID) + } + return out, nextCursor, hasMore, nil +} + +// generateTicketProto maps a xenforo.Ticket to proto.Ticket, resolving +// reference-cached names and assembling the custom_fields map. +func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase string) *proto.Ticket { + out := &proto.Ticket{ + TicketId: t.TicketID, + TicketRef: t.TicketRef, + Title: t.Title, + CategoryId: t.TicketCategoryID, + CategoryAncestorIds: rc.CategoryAncestors(t.TicketCategoryID), + TicketState: t.TicketState, + StatusId: t.StatusID, + StatusName: rc.StatusName(t.StatusID), + PriorityId: t.Priority, + PriorityName: rc.PriorityName(t.Priority), + PrefixId: t.PrefixID, + PrefixName: rc.PrefixName(t.PrefixID), + DiscussionState: t.DiscussionState, + TicketLocked: t.TicketLocked != 0, + StarterUserId: t.StarterUserID, + StarterUsername: t.StarterUsername, + AssignedUserId: t.AssignedUserID, + AssignedUsername: t.AssignedUsername, + StartDate: t.StartDate, + LastMessageDate: t.LastMessageDate, + LastMessageUserId: t.LastMessageUserID, + LastMessageUsername: t.LastMessageUsername, + LastModifiedDate: t.LastModifiedDate, + ReplyCount: t.ReplyCount, + CustomFields: map[string]string{}, + } + if cat := rc.Category(t.TicketCategoryID); cat != nil { + out.CategoryTitle = cat.Title + } + for _, fv := range t.FieldValues { + out.CustomFields[fv.FieldID] = fv.FieldValue + } + for _, p := range t.Participants { + out.Participants = append(out.Participants, &proto.TicketParticipant{ + UserId: p.UserID, LastReadDate: p.LastReadDate, + }) + } + if forumBase != "" { + out.ForumUrl = forumBase + "/tickets/" + t.TicketRef + "/" + } + return out +} + +// forumBaseURL returns the configured FORUM_BASE_URL, trimmed of trailing slashes. +// Reads via viper to match the rest of the codebase's config style. +func (ds *Mysql) forumBaseURL() string { + v := viper.GetString("FORUM_BASE_URL") + for len(v) > 0 && v[len(v)-1] == '/' { + v = v[:len(v)-1] + } + return v +} + +// encodeCursor packs (last_modified_date, ticket_id) into a single opaque +// base64 string. Callers must not parse it. +func encodeCursor(lastModified, ticketID uint32) string { + raw := fmt.Sprintf("%d:%d", lastModified, ticketID) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func decodeCursor(c string) (uint32, uint32, error) { + raw, err := base64.RawURLEncoding.DecodeString(c) + if err != nil { + return 0, 0, err + } + var ts, id uint32 + if _, err := fmt.Sscanf(string(raw), "%d:%d", &ts, &id); err != nil { + return 0, 0, err + } + return ts, id, nil +} + func (ds *Mysql) LoadCategories(ctx context.Context) ([]*referencecache.CategoryRecord, error) { var rows []struct { ID uint32 `gorm:"column:ticket_category_id"` diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go new file mode 100644 index 0000000..897df28 --- /dev/null +++ b/datastores/tickets_test.go @@ -0,0 +1,188 @@ +package datastores + +import ( + "context" + "regexp" + "testing" + + "github.com/7cav/api/referencecache" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeRefCache struct { + statuses map[uint32]string + priorities map[uint32]string + prefixes map[uint32]string + cats map[uint32]*referencecache.CategoryRecord + subtree func([]uint32) []uint32 +} + +func (f *fakeRefCache) StatusName(id uint32) string { return f.statuses[id] } +func (f *fakeRefCache) PriorityName(id uint32) string { return f.priorities[id] } +func (f *fakeRefCache) PrefixName(id uint32) string { return f.prefixes[id] } +func (f *fakeRefCache) Category(id uint32) *referencecache.CategoryRecord { return f.cats[id] } +func (f *fakeRefCache) CategoryAncestors(id uint32) []uint32 { + cat := f.cats[id] + if cat == nil || cat.ParentID == 0 { + return nil + } + return []uint32{cat.ParentID} +} +func (f *fakeRefCache) CategoryTree() []*referencecache.CategoryRecord { + out := make([]*referencecache.CategoryRecord, 0, len(f.cats)) + for _, c := range f.cats { + out = append(out, c) + } + return out +} +func (f *fakeRefCache) ExpandSubtree(ids []uint32) []uint32 { + if f.subtree != nil { + return f.subtree(ids) + } + return ids +} + +func newFakeRefCache() *fakeRefCache { + return &fakeRefCache{ + statuses: map[uint32]string{1: "Open", 11: "Closed"}, + priorities: map[uint32]string{1: "Low"}, + prefixes: map[uint32]string{}, + cats: map[uint32]*referencecache.CategoryRecord{ + 5: {ID: 5, Title: "S1 Personnel Admin", Lft: 1, Rgt: 12}, + 17: {ID: 17, ParentID: 5, Title: "S1 Citations", Lft: 2, Rgt: 3}, + }, + } +} + +func TestListTickets_NoFiltersHappyPath(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + + rc := newFakeRefCache() + + rows := sqlmock.NewRows([]string{ + "ticket_id", "ticket_ref", "title", + "user_id", "username", "start_date", + "priority", "status_id", "ticket_state", + "ticket_locked", "discussion_state", + "assigned_user_id", "assigned_username", + "ticket_category_id", "last_message_id", + "last_message_date", "last_message_user_id", + "last_message_username", "last_modified_date", + "reply_count", "prefix_id", + "starter_user_id", "starter_username", + }).AddRow( + 7499, "MF1UI9HE", "HALO Combat Jump", + 1648, "Hilberg.A", uint32(1736294298), + uint32(1), uint32(11), "open", + uint32(0), "visible", + uint32(0), "", + uint32(17), uint32(55112), + uint32(1736557390), uint32(7804), + "Angels.N", uint32(1736556824), + uint32(4), uint32(0), + uint32(1648), "Hilberg.A", + ) + + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_ticket`")). + WillReturnRows(rows) + + // field_values + participants preload queries (GORM orders alphabetically by association name) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_field_value")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "field_id", "field_value"})) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_participant")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "user_id", "last_read_date"})) + + tickets, next, more, err := (&ds).ListTickets(context.Background(), rc, &ListTicketsFilter{PerPage: 50}) + require.NoError(t, err) + assert.Empty(t, next, "no cursor returned for a single-page result set") + assert.False(t, more) + require.Len(t, tickets, 1) + got := tickets[0] + assert.Equal(t, uint32(7499), got.TicketId) + assert.Equal(t, "MF1UI9HE", got.TicketRef) + assert.Equal(t, "Closed", got.StatusName, "status name resolved from reference cache") + assert.Equal(t, "S1 Citations", got.CategoryTitle) + assert.Equal(t, []uint32{5}, got.CategoryAncestorIds) +} + +func TestListTickets_FilterByCategoryExpandsSubtree(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + + rc := newFakeRefCache() + rc.subtree = func(in []uint32) []uint32 { + assert.Equal(t, []uint32{5}, in) + return []uint32{5, 17, 16, 14, 15, 24} + } + + rows := sqlmock.NewRows([]string{"ticket_id"}) + mock.ExpectQuery(regexp.QuoteMeta("ticket_category_id IN")). + WithArgs("visible", uint32(5), uint32(17), uint32(16), uint32(14), uint32(15), uint32(24), 51). + WillReturnRows(rows) + + _, _, _, err := (&ds).ListTickets(context.Background(), rc, &ListTicketsFilter{ + CategoryIDs: []uint32{5}, + }) + require.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListTickets_ExcludeSubcategoriesSkipsExpansion(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + rc := newFakeRefCache() + rc.subtree = func(in []uint32) []uint32 { + t.Fatalf("subtree should not have been called") + return nil + } + mock.ExpectQuery(regexp.QuoteMeta("ticket_category_id IN")). + WithArgs("visible", uint32(5), 51). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id"})) + _, _, _, err := (&ds).ListTickets(context.Background(), rc, &ListTicketsFilter{ + CategoryIDs: []uint32{5}, + ExcludeSubcategories: true, + }) + require.NoError(t, err) +} + +func TestListTickets_CursorPagination(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + rc := newFakeRefCache() + + // Two rows, perPage=1, so hasMore=true and a cursor comes back. + header := []string{ + "ticket_id", "ticket_ref", "title", "user_id", "username", "start_date", + "priority", "status_id", "ticket_state", "ticket_locked", "discussion_state", + "assigned_user_id", "assigned_username", "ticket_category_id", + "last_message_id", "last_message_date", "last_message_user_id", + "last_message_username", "last_modified_date", "reply_count", "prefix_id", + "starter_user_id", "starter_username", + } + rows := sqlmock.NewRows(header). + AddRow(2, "B", "t2", 0, "", uint32(0), uint32(0), uint32(0), "open", uint32(0), "visible", uint32(0), "", uint32(0), uint32(0), uint32(0), uint32(0), "", uint32(2000), uint32(0), uint32(0), uint32(0), ""). + AddRow(1, "A", "t1", 0, "", uint32(0), uint32(0), uint32(0), "open", uint32(0), "visible", uint32(0), "", uint32(0), uint32(0), uint32(0), uint32(0), "", uint32(1000), uint32(0), uint32(0), uint32(0), "") + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_ticket`")).WillReturnRows(rows) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_field_value")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "field_id", "field_value"})) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_participant")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "user_id", "last_read_date"})) + + tickets, next, more, err := (&ds).ListTickets(context.Background(), rc, &ListTicketsFilter{PerPage: 1}) + require.NoError(t, err) + assert.True(t, more) + require.NotEmpty(t, next) + require.Len(t, tickets, 1) + assert.Equal(t, uint32(2), tickets[0].TicketId, "newest first by last_modified_date DESC") +} + +func TestListTickets_CursorRoundTrip(t *testing.T) { + c := encodeCursor(2000, 42) + ts, id, err := decodeCursor(c) + require.NoError(t, err) + assert.Equal(t, uint32(2000), ts) + assert.Equal(t, uint32(42), id) +} From 1f377ccfbe91476a418167e41ff8605cfa25297c Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:11:56 -0400 Subject: [PATCH 12/32] feat(datastores): GetTicket + GetTicketByRef --- datastores/tickets.go | 35 ++++++++++++++++++++++++++++++++++ datastores/tickets_test.go | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/datastores/tickets.go b/datastores/tickets.go index b03f394..1a54bf5 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "strconv" + "strings" "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" @@ -228,6 +229,40 @@ func decodeCursor(c string) (uint32, uint32, error) { return ts, id, nil } +func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBase string) (*proto.Ticket, error) { + var row xenforo.Ticket + tx := ds.Db.WithContext(ctx). + Preload("Participants"). + Preload("FieldValues"). + Where("ticket_id = ?", ticketID). + First(&row) + if tx.Error != nil { + return nil, tx.Error + } + base := forumBase + if base == "" { + base = ds.forumBaseURL() + } + return generateTicketProto(&row, rc, strings.TrimRight(base, "/")), nil +} + +func (ds *Mysql) GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBase string) (*proto.Ticket, error) { + var row xenforo.Ticket + tx := ds.Db.WithContext(ctx). + Preload("Participants"). + Preload("FieldValues"). + Where("ticket_ref = ?", ref). + First(&row) + if tx.Error != nil { + return nil, tx.Error + } + base := forumBase + if base == "" { + base = ds.forumBaseURL() + } + return generateTicketProto(&row, rc, strings.TrimRight(base, "/")), nil +} + func (ds *Mysql) LoadCategories(ctx context.Context) ([]*referencecache.CategoryRecord, error) { var rows []struct { ID uint32 `gorm:"column:ticket_category_id"` diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index 897df28..1e60123 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -186,3 +186,42 @@ func TestListTickets_CursorRoundTrip(t *testing.T) { assert.Equal(t, uint32(2000), ts) assert.Equal(t, uint32(42), id) } + +func TestGetTicket_HappyPath(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + rc := newFakeRefCache() + + rows := sqlmock.NewRows([]string{ + "ticket_id", "ticket_ref", "title", "user_id", "username", "start_date", + "priority", "status_id", "ticket_state", "ticket_locked", "discussion_state", + "assigned_user_id", "assigned_username", "ticket_category_id", + "last_message_id", "last_message_date", "last_message_user_id", + "last_message_username", "last_modified_date", "reply_count", "prefix_id", + "starter_user_id", "starter_username", + }).AddRow(7499, "MF1UI9HE", "T", 0, "", uint32(0), uint32(0), uint32(11), "open", uint32(0), "visible", uint32(0), "", uint32(17), uint32(0), uint32(0), uint32(0), "", uint32(0), uint32(0), uint32(0), uint32(0), "") + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_ticket`")). + WithArgs(uint32(7499), 1). + WillReturnRows(rows) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_field_value")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "field_id", "field_value"})) + mock.ExpectQuery(regexp.QuoteMeta("xf_nf_tickets_ticket_participant")). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id", "user_id", "last_read_date"})) + + ticket, err := (&ds).GetTicket(context.Background(), rc, 7499, "https://7cav.us") + require.NoError(t, err) + require.NotNil(t, ticket) + assert.Equal(t, "MF1UI9HE", ticket.TicketRef) + assert.Equal(t, "https://7cav.us/tickets/MF1UI9HE/", ticket.ForumUrl) +} + +func TestGetTicket_NotFound(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + rc := newFakeRefCache() + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_ticket`")). + WithArgs(uint32(9999), 1). + WillReturnRows(sqlmock.NewRows([]string{"ticket_id"})) + _, err := (&ds).GetTicket(context.Background(), rc, 9999, "") + require.Error(t, err) +} From 8bff766085e08beca115e3b818350d130248a633 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:12:41 -0400 Subject: [PATCH 13/32] feat(datastores): GetTicketFirstMessages with count --- datastores/tickets.go | 42 ++++++++++++++++++++++++++++++++++++++ datastores/tickets_test.go | 27 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/datastores/tickets.go b/datastores/tickets.go index 1a54bf5..1876ac8 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -246,6 +246,48 @@ func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketI return generateTicketProto(&row, rc, strings.TrimRight(base, "/")), nil } +func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) ([]*proto.Message, uint32, error) { + var total int64 + q := ds.Db.WithContext(ctx).Model(&xenforo.TicketMessage{}).Where("ticket_id = ?", ticketID) + if !includeHidden { + q = q.Where("message_state = ?", "visible") + } + if tx := q.Count(&total); tx.Error != nil { + return nil, 0, tx.Error + } + + var rows []xenforo.TicketMessage + q2 := ds.Db.WithContext(ctx).Where("ticket_id = ?", ticketID) + if !includeHidden { + q2 = q2.Where("message_state = ?", "visible") + } + tx := q2.Order("position ASC").Limit(n).Find(&rows) + if tx.Error != nil { + return nil, 0, tx.Error + } + out := make([]*proto.Message, 0, len(rows)) + for i := range rows { + out = append(out, messageToProto(&rows[i])) + } + return out, uint32(total), nil +} + +func messageToProto(m *xenforo.TicketMessage) *proto.Message { + return &proto.Message{ + MessageId: m.MessageID, + TicketId: m.TicketID, + UserId: m.UserID, + Username: m.Username, + MessageDate: m.MessageDate, + Message: m.Message, + MessageState: m.MessageState, + Position: m.Position, + AttachCount: m.AttachCount, + LastEditDate: m.LastEditDate, + EditCount: m.EditCount, + } +} + func (ds *Mysql) GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBase string) (*proto.Ticket, error) { var row xenforo.Ticket tx := ds.Db.WithContext(ctx). diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index 1e60123..52badc6 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -225,3 +225,30 @@ func TestGetTicket_NotFound(t *testing.T) { _, err := (&ds).GetTicket(context.Background(), rc, 9999, "") require.Error(t, err) } + +func TestGetTicketFirstMessages_HappyPath(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + // Count query (returns total count). + mock.ExpectQuery(regexp.QuoteMeta("SELECT count")). + WithArgs(uint32(7499), "visible"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(15)) + // Page query (up to N rows). + mrows := sqlmock.NewRows([]string{ + "message_id", "ticket_id", "user_id", "username", + "message_date", "message", "message_state", + "position", "attach_count", "last_edit_date", "edit_count", + }) + for i := 1; i <= 10; i++ { + mrows = mrows.AddRow(uint32(i), uint32(7499), uint32(0), "", uint32(0), "m", "visible", uint32(i), uint32(0), uint32(0), uint32(0)) + } + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_message`")). + WithArgs(uint32(7499), "visible", 10). + WillReturnRows(mrows) + + msgs, total, err := (&ds).GetTicketFirstMessages(context.Background(), 7499, 10, false) + require.NoError(t, err) + assert.Equal(t, uint32(15), total) + assert.Len(t, msgs, 10) + assert.Equal(t, uint32(1), msgs[0].Position) +} From d212f1016b6916b9d3e06cb7d2661cd8833aff1d Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:13:22 -0400 Subject: [PATCH 14/32] feat(datastores): ListTicketMessages with cursor pagination --- datastores/tickets.go | 32 ++++++++++++++++++++++++++++++++ datastores/tickets_test.go | 23 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/datastores/tickets.go b/datastores/tickets.go index 1876ac8..1489435 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -272,6 +272,38 @@ func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n return out, uint32(total), nil } +func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID, afterPosition, perPage uint32, includeHidden bool) ([]*proto.Message, uint32, bool, error) { + if perPage == 0 || perPage > 100 { + if perPage == 0 { + perPage = 50 + } else { + perPage = 100 + } + } + q := ds.Db.WithContext(ctx).Where("ticket_id = ? AND position > ?", ticketID, afterPosition) + if !includeHidden { + q = q.Where("message_state = ?", "visible") + } + var rows []xenforo.TicketMessage + tx := q.Order("position ASC").Limit(int(perPage) + 1).Find(&rows) + if tx.Error != nil { + return nil, 0, false, tx.Error + } + hasMore := len(rows) > int(perPage) + if hasMore { + rows = rows[:perPage] + } + out := make([]*proto.Message, 0, len(rows)) + for i := range rows { + out = append(out, messageToProto(&rows[i])) + } + var next uint32 + if hasMore && len(rows) > 0 { + next = rows[len(rows)-1].Position + } + return out, next, hasMore, nil +} + func messageToProto(m *xenforo.TicketMessage) *proto.Message { return &proto.Message{ MessageId: m.MessageID, diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index 52badc6..cb712a4 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -252,3 +252,26 @@ func TestGetTicketFirstMessages_HappyPath(t *testing.T) { assert.Len(t, msgs, 10) assert.Equal(t, uint32(1), msgs[0].Position) } + +func TestListTicketMessages_Pagination(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + // 3 messages requested (perPage=2 + 1 lookahead), so 1 lookahead means hasMore=true + rows := sqlmock.NewRows([]string{ + "message_id", "ticket_id", "user_id", "username", + "message_date", "message", "message_state", + "position", "attach_count", "last_edit_date", "edit_count", + }). + AddRow(uint32(11), uint32(1), uint32(0), "", uint32(0), "", "visible", uint32(11), uint32(0), uint32(0), uint32(0)). + AddRow(uint32(12), uint32(1), uint32(0), "", uint32(0), "", "visible", uint32(12), uint32(0), uint32(0), uint32(0)). + AddRow(uint32(13), uint32(1), uint32(0), "", uint32(0), "", "visible", uint32(13), uint32(0), uint32(0), uint32(0)) + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_message`")). + WithArgs(uint32(1), uint32(10), "visible", 3). + WillReturnRows(rows) + + msgs, next, more, err := (&ds).ListTicketMessages(context.Background(), 1, 10, 2, false) + require.NoError(t, err) + assert.True(t, more) + assert.Equal(t, uint32(12), next, "cursor is position of last returned message") + assert.Len(t, msgs, 2) +} From 8bb32afb2204e595a3654134bcec2966254dc006 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:14:10 -0400 Subject: [PATCH 15/32] feat(datastores): ListCategories from reference cache --- datastores/tickets.go | 17 +++++++++++++++++ datastores/tickets_test.go | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/datastores/tickets.go b/datastores/tickets.go index 1489435..94c1175 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -304,6 +304,23 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID, afterPosition return out, next, hasMore, nil } +func (ds *Mysql) ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error) { + tree := rc.CategoryTree() + out := make([]*proto.Category, 0, len(tree)) + for _, c := range tree { + out = append(out, &proto.Category{ + CategoryId: c.ID, + Title: c.Title, + Description: c.Description, + ParentCategoryId: c.ParentID, + Depth: c.Depth, + DisplayOrder: c.DisplayOrder, + TicketCount: c.TicketCount, + }) + } + return out, nil +} + func messageToProto(m *xenforo.TicketMessage) *proto.Message { return &proto.Message{ MessageId: m.MessageID, diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index cb712a4..d775137 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -275,3 +275,15 @@ func TestListTicketMessages_Pagination(t *testing.T) { assert.Equal(t, uint32(12), next, "cursor is position of last returned message") assert.Len(t, msgs, 2) } + +func TestListCategories_PassThroughFromCache(t *testing.T) { + ds := Mysql{} + rc := newFakeRefCache() + cats, err := ds.ListCategories(context.Background(), rc) + require.NoError(t, err) + require.Len(t, cats, 2) + // fakeRefCache.cats is a map; sort the result for stable assertion. + titles := []string{cats[0].Title, cats[1].Title} + assert.Contains(t, titles, "S1 Personnel Admin") + assert.Contains(t, titles, "S1 Citations") +} From 471bb89e7fc071ea8b21110a09b3970e9f4d5c92 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:32:02 -0400 Subject: [PATCH 16/32] feat(grpc): TicketsService.ListTickets with scope check --- servers/grpc/tickets.go | 45 +++++++++++++ servers/grpc/tickets_test.go | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 servers/grpc/tickets.go create mode 100644 servers/grpc/tickets_test.go diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go new file mode 100644 index 0000000..7d4d1f2 --- /dev/null +++ b/servers/grpc/tickets.go @@ -0,0 +1,45 @@ +package grpc + +import ( + "context" + + "github.com/7cav/api/datastores" + "github.com/7cav/api/proto" + "github.com/7cav/api/referencecache" +) + +// TicketsService implements proto.TicketsServiceServer. +type TicketsService struct { + proto.UnimplementedTicketsServiceServer + + Datastore datastores.Datastore + ReferenceCache referencecache.ReferenceCache +} + +func (s *TicketsService) ListTickets(ctx context.Context, req *proto.ListTicketsRequest) (*proto.ListTicketsResponse, error) { + if err := RequireScope(ctx, "read:tickets"); err != nil { + return nil, err + } + filter := &datastores.ListTicketsFilter{ + CategoryIDs: req.CategoryId, + ExcludeSubcategories: req.ExcludeSubcategories, + TicketStates: req.TicketState, + StatusIDs: req.StatusId, + PrefixIDs: req.PrefixId, + AssignedUserIDs: req.AssignedUserId, + StarterUserIDs: req.StarterUserId, + ModifiedSince: req.ModifiedSince, + IncludeHidden: req.IncludeHidden, + PerPage: req.PerPage, + AfterCursor: req.AfterCursor, + } + tickets, next, more, err := s.Datastore.ListTickets(ctx, s.ReferenceCache, filter) + if err != nil { + return nil, err + } + return &proto.ListTicketsResponse{ + Tickets: tickets, + NextCursor: next, + HasMore: more, + }, nil +} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go new file mode 100644 index 0000000..b5f79d1 --- /dev/null +++ b/servers/grpc/tickets_test.go @@ -0,0 +1,120 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/7cav/api/datastores" + "github.com/7cav/api/proto" + "github.com/7cav/api/referencecache" + "github.com/7cav/api/xenforo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fakeDatastore implements datastores.Datastore with just the methods we need +// for tickets-handler tests stubbed. Other methods panic so the test surface +// stays explicit. +type fakeDatastore struct { + listTickets func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) + getTicket func(uint32) (*proto.Ticket, error) + getByRef func(string) (*proto.Ticket, error) + firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) + listMsgs func(uint32, uint32, uint32, bool) ([]*proto.Message, uint32, bool, error) + listCats func() ([]*proto.Category, error) +} + +func (f *fakeDatastore) ListTickets(_ context.Context, _ datastores.TicketReferenceCache, fi *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + return f.listTickets(fi) +} +func (f *fakeDatastore) GetTicket(_ context.Context, _ datastores.TicketReferenceCache, id uint32, _ string) (*proto.Ticket, error) { + return f.getTicket(id) +} +func (f *fakeDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketReferenceCache, ref string, _ string) (*proto.Ticket, error) { + return f.getByRef(ref) +} +func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, id uint32, n int, _ bool) ([]*proto.Message, uint32, error) { + return f.firstMsgs(id, n) +} +func (f *fakeDatastore) ListTicketMessages(_ context.Context, id, after, per uint32, hidden bool) ([]*proto.Message, uint32, bool, error) { + return f.listMsgs(id, after, per, hidden) +} +func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { + return f.listCats() +} + +// Milpacs methods omitted; tickets tests don't use them. +func (f *fakeDatastore) FindProfilesById(_ ...uint64) ([]*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfilesByUsername(string) ([]*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindRosterByType(proto.RosterType) (*proto.Roster, error) { panic("unused") } +func (f *fakeDatastore) FindLiteRosterByType(proto.RosterType) (*proto.LiteRoster, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByKeycloakID(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByDiscordID(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) FindProfilesByPosition(string) (*proto.LiteRoster, error) { panic("unused") } +func (f *fakeDatastore) FindS1UniformsRosterByType(proto.RosterType) (*proto.S1UniformsRoster, error) { + panic("unused") +} +func (f *fakeDatastore) FindAllRanks() ([]*proto.RankExpanded, error) { panic("unused") } +func (f *fakeDatastore) FindAllPositionGroups() ([]*proto.PositionGroup, error) { panic("unused") } +func (f *fakeDatastore) FindAwol() ([]*proto.Awol, error) { panic("unused") } +func (f *fakeDatastore) GetTableUpdates() ([]xenforo.TableInfo, error) { panic("unused") } +func (f *fakeDatastore) FindProfileByGamertag(string) (*proto.Profile, error) { panic("unused") } +func (f *fakeDatastore) ValidateApiKey(string) (*datastores.ApiKeyResult, error) { panic("unused") } + +func withTicketsKey(scopes ...string) context.Context { + m := map[string]struct{}{} + for _, s := range scopes { + m[s] = struct{}{} + } + return ContextWithKey(context.Background(), &datastores.ApiKeyResult{Scopes: m}) +} + +func TestListTickets_RequiresScope(t *testing.T) { + svc := &TicketsService{Datastore: &fakeDatastore{}, ReferenceCache: &referencecache.Cache{}} + ctx := withTicketsKey() // no scopes + _, err := svc.ListTickets(ctx, &proto.ListTicketsRequest{}) + require.Error(t, err) + st, _ := status.FromError(err) + assert.Equal(t, codes.PermissionDenied, st.Code()) +} + +func TestListTickets_HappyPath(t *testing.T) { + called := false + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + called = true + assert.Equal(t, []uint32{5}, f.CategoryIDs) + assert.Equal(t, []string{"open"}, f.TicketStates) + return []*proto.Ticket{{TicketId: 1}}, "next123", true, nil + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + ctx := withTicketsKey("read:tickets") + resp, err := svc.ListTickets(ctx, &proto.ListTicketsRequest{ + CategoryId: []uint32{5}, + TicketState: []string{"open"}, + }) + require.NoError(t, err) + assert.True(t, called) + require.Len(t, resp.Tickets, 1) + assert.Equal(t, "next123", resp.NextCursor) + assert.True(t, resp.HasMore) +} + +func TestListTickets_DatastoreError(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listTickets: func(*datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + return nil, "", false, errors.New("boom") + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + _, err := svc.ListTickets(withTicketsKey("read:tickets"), &proto.ListTicketsRequest{}) + require.Error(t, err) +} From a552fd61a145240b52be656a1d1df25f792f4a94 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:32:44 -0400 Subject: [PATCH 17/32] feat(grpc): GetTicket + GetTicketByRef handlers --- servers/grpc/tickets.go | 40 ++++++++++++++++++++++++++++++++ servers/grpc/tickets_test.go | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index 7d4d1f2..01cd00c 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -43,3 +43,43 @@ func (s *TicketsService) ListTickets(ctx context.Context, req *proto.ListTickets HasMore: more, }, nil } + +const firstMessagesCount = 10 + +func (s *TicketsService) GetTicket(ctx context.Context, req *proto.GetTicketRequest) (*proto.GetTicketResponse, error) { + if err := RequireScope(ctx, "read:tickets"); err != nil { + return nil, err + } + ticket, err := s.Datastore.GetTicket(ctx, s.ReferenceCache, req.TicketId, "") + if err != nil { + return nil, err + } + msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) + if err != nil { + return nil, err + } + return &proto.GetTicketResponse{ + Ticket: ticket, + FirstMessages: msgs, + TotalMessageCount: total, + }, nil +} + +func (s *TicketsService) GetTicketByRef(ctx context.Context, req *proto.GetTicketByRefRequest) (*proto.GetTicketResponse, error) { + if err := RequireScope(ctx, "read:tickets"); err != nil { + return nil, err + } + ticket, err := s.Datastore.GetTicketByRef(ctx, s.ReferenceCache, req.TicketRef, "") + if err != nil { + return nil, err + } + msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) + if err != nil { + return nil, err + } + return &proto.GetTicketResponse{ + Ticket: ticket, + FirstMessages: msgs, + TotalMessageCount: total, + }, nil +} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index b5f79d1..36c8cc0 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -118,3 +118,47 @@ func TestListTickets_DatastoreError(t *testing.T) { _, err := svc.ListTickets(withTicketsKey("read:tickets"), &proto.ListTicketsRequest{}) require.Error(t, err) } + +func TestGetTicket_PopulatesFirstMessages(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + getTicket: func(id uint32) (*proto.Ticket, error) { + assert.Equal(t, uint32(7499), id) + return &proto.Ticket{TicketId: 7499, TicketRef: "MF1UI9HE"}, nil + }, + firstMsgs: func(id uint32, n int) ([]*proto.Message, uint32, error) { + assert.Equal(t, uint32(7499), id) + assert.Equal(t, 10, n) + return []*proto.Message{{MessageId: 1}, {MessageId: 2}}, 15, nil + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + resp, err := svc.GetTicket(withTicketsKey("read:tickets"), &proto.GetTicketRequest{TicketId: 7499}) + require.NoError(t, err) + assert.Equal(t, "MF1UI9HE", resp.Ticket.TicketRef) + assert.Len(t, resp.FirstMessages, 2) + assert.Equal(t, uint32(15), resp.TotalMessageCount) +} + +func TestGetTicket_RequiresScope(t *testing.T) { + svc := &TicketsService{Datastore: &fakeDatastore{}, ReferenceCache: &referencecache.Cache{}} + _, err := svc.GetTicket(withTicketsKey(), &proto.GetTicketRequest{TicketId: 1}) + require.Error(t, err) +} + +func TestGetTicketByRef_HappyPath(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + getByRef: func(ref string) (*proto.Ticket, error) { + assert.Equal(t, "MF1UI9HE", ref) + return &proto.Ticket{TicketId: 1}, nil + }, + firstMsgs: func(uint32, int) ([]*proto.Message, uint32, error) { return nil, 0, nil }, + }, + ReferenceCache: &referencecache.Cache{}, + } + resp, err := svc.GetTicketByRef(withTicketsKey("read:tickets"), &proto.GetTicketByRefRequest{TicketRef: "MF1UI9HE"}) + require.NoError(t, err) + assert.Equal(t, uint32(1), resp.Ticket.TicketId) +} From 61aa892f613cae69cf6ec996b4ddc941f70afe4c Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:33:26 -0400 Subject: [PATCH 18/32] feat(grpc): ListTicketMessages handler --- servers/grpc/tickets.go | 15 +++++++++++++++ servers/grpc/tickets_test.go | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index 01cd00c..3ece442 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -83,3 +83,18 @@ func (s *TicketsService) GetTicketByRef(ctx context.Context, req *proto.GetTicke TotalMessageCount: total, }, nil } + +func (s *TicketsService) ListTicketMessages(ctx context.Context, req *proto.ListTicketMessagesRequest) (*proto.ListTicketMessagesResponse, error) { + if err := RequireScope(ctx, "read:tickets"); err != nil { + return nil, err + } + msgs, next, more, err := s.Datastore.ListTicketMessages(ctx, req.TicketId, req.AfterPosition, req.PerPage, req.IncludeHidden) + if err != nil { + return nil, err + } + return &proto.ListTicketMessagesResponse{ + Messages: msgs, + NextCursor: next, + HasMore: more, + }, nil +} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index 36c8cc0..280eb85 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -162,3 +162,25 @@ func TestGetTicketByRef_HappyPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, uint32(1), resp.Ticket.TicketId) } + +func TestListTicketMessages_HappyPath(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listMsgs: func(id, after, per uint32, hidden bool) ([]*proto.Message, uint32, bool, error) { + assert.Equal(t, uint32(7499), id) + assert.Equal(t, uint32(10), after) + assert.Equal(t, uint32(50), per) + assert.False(t, hidden) + return []*proto.Message{{MessageId: 11}}, 11, true, nil + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + resp, err := svc.ListTicketMessages(withTicketsKey("read:tickets"), &proto.ListTicketMessagesRequest{ + TicketId: 7499, AfterPosition: 10, PerPage: 50, + }) + require.NoError(t, err) + require.Len(t, resp.Messages, 1) + assert.Equal(t, uint32(11), resp.NextCursor) + assert.True(t, resp.HasMore) +} From 6a5923fdab953e00f130ede2b7460ddc267bf96e Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:34:37 -0400 Subject: [PATCH 19/32] feat(grpc): ListCategories handler --- servers/grpc/tickets.go | 12 ++++++++++++ servers/grpc/tickets_test.go | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index 3ece442..9402809 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -6,6 +6,7 @@ import ( "github.com/7cav/api/datastores" "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "google.golang.org/protobuf/types/known/emptypb" ) // TicketsService implements proto.TicketsServiceServer. @@ -98,3 +99,14 @@ func (s *TicketsService) ListTicketMessages(ctx context.Context, req *proto.List HasMore: more, }, nil } + +func (s *TicketsService) ListCategories(ctx context.Context, _ *emptypb.Empty) (*proto.ListCategoriesResponse, error) { + if err := RequireScope(ctx, "read:tickets"); err != nil { + return nil, err + } + cats, err := s.Datastore.ListCategories(ctx, s.ReferenceCache) + if err != nil { + return nil, err + } + return &proto.ListCategoriesResponse{Categories: cats}, nil +} diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index 280eb85..aca9c30 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" ) // fakeDatastore implements datastores.Datastore with just the methods we need @@ -184,3 +185,18 @@ func TestListTicketMessages_HappyPath(t *testing.T) { assert.Equal(t, uint32(11), resp.NextCursor) assert.True(t, resp.HasMore) } + +func TestListCategories_HappyPath(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listCats: func() ([]*proto.Category, error) { + return []*proto.Category{{CategoryId: 5, Title: "S1"}}, nil + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + resp, err := svc.ListCategories(withTicketsKey("read:tickets"), &emptypb.Empty{}) + require.NoError(t, err) + require.Len(t, resp.Categories, 1) + assert.Equal(t, "S1", resp.Categories[0].Title) +} From b03d1e27b43de6598c7120de942d38476fa99bc0 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:40:18 -0400 Subject: [PATCH 20/32] fix(grpc): map ticket-fetch errors to gRPC status codes - gorm.ErrRecordNotFound -> codes.NotFound (matches MilpacsService convention) - Other datastore errors -> codes.Internal with operation context - Strengthen TestListTickets_HappyPath to assert all 11 filter fields - Add TestGetTicket_NotFoundReturns404Code --- servers/grpc/tickets.go | 24 ++++++++++----- servers/grpc/tickets_test.go | 59 ++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index 9402809..e3d00d4 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -2,11 +2,15 @@ package grpc import ( "context" + "errors" "github.com/7cav/api/datastores" "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "gorm.io/gorm" ) // TicketsService implements proto.TicketsServiceServer. @@ -36,7 +40,7 @@ func (s *TicketsService) ListTickets(ctx context.Context, req *proto.ListTickets } tickets, next, more, err := s.Datastore.ListTickets(ctx, s.ReferenceCache, filter) if err != nil { - return nil, err + return nil, status.Errorf(codes.Internal, "list tickets: %v", err) } return &proto.ListTicketsResponse{ Tickets: tickets, @@ -53,11 +57,14 @@ func (s *TicketsService) GetTicket(ctx context.Context, req *proto.GetTicketRequ } ticket, err := s.Datastore.GetTicket(ctx, s.ReferenceCache, req.TicketId, "") if err != nil { - return nil, err + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "ticket %d not found", req.TicketId) + } + return nil, status.Errorf(codes.Internal, "fetch ticket: %v", err) } msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) if err != nil { - return nil, err + return nil, status.Errorf(codes.Internal, "fetch ticket messages: %v", err) } return &proto.GetTicketResponse{ Ticket: ticket, @@ -72,11 +79,14 @@ func (s *TicketsService) GetTicketByRef(ctx context.Context, req *proto.GetTicke } ticket, err := s.Datastore.GetTicketByRef(ctx, s.ReferenceCache, req.TicketRef, "") if err != nil { - return nil, err + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(codes.NotFound, "ticket %q not found", req.TicketRef) + } + return nil, status.Errorf(codes.Internal, "fetch ticket: %v", err) } msgs, total, err := s.Datastore.GetTicketFirstMessages(ctx, ticket.TicketId, firstMessagesCount, false) if err != nil { - return nil, err + return nil, status.Errorf(codes.Internal, "fetch ticket messages: %v", err) } return &proto.GetTicketResponse{ Ticket: ticket, @@ -91,7 +101,7 @@ func (s *TicketsService) ListTicketMessages(ctx context.Context, req *proto.List } msgs, next, more, err := s.Datastore.ListTicketMessages(ctx, req.TicketId, req.AfterPosition, req.PerPage, req.IncludeHidden) if err != nil { - return nil, err + return nil, status.Errorf(codes.Internal, "list ticket messages: %v", err) } return &proto.ListTicketMessagesResponse{ Messages: msgs, @@ -106,7 +116,7 @@ func (s *TicketsService) ListCategories(ctx context.Context, _ *emptypb.Empty) ( } cats, err := s.Datastore.ListCategories(ctx, s.ReferenceCache) if err != nil { - return nil, err + return nil, status.Errorf(codes.Internal, "list ticket categories: %v", err) } return &proto.ListCategoriesResponse{Categories: cats}, nil } diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index aca9c30..27c43b3 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" + "gorm.io/gorm" ) // fakeDatastore implements datastores.Datastore with just the methods we need @@ -83,30 +84,70 @@ func TestListTickets_RequiresScope(t *testing.T) { } func TestListTickets_HappyPath(t *testing.T) { - called := false + var got *datastores.ListTicketsFilter svc := &TicketsService{ Datastore: &fakeDatastore{ listTickets: func(f *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { - called = true - assert.Equal(t, []uint32{5}, f.CategoryIDs) - assert.Equal(t, []string{"open"}, f.TicketStates) + got = f return []*proto.Ticket{{TicketId: 1}}, "next123", true, nil }, }, ReferenceCache: &referencecache.Cache{}, } ctx := withTicketsKey("read:tickets") - resp, err := svc.ListTickets(ctx, &proto.ListTicketsRequest{ - CategoryId: []uint32{5}, - TicketState: []string{"open"}, - }) + req := &proto.ListTicketsRequest{ + CategoryId: []uint32{5}, + ExcludeSubcategories: true, + TicketState: []string{"open"}, + StatusId: []uint32{1, 3}, + PrefixId: []uint32{2}, + AssignedUserId: []uint32{100}, + StarterUserId: []uint32{200}, + ModifiedSince: 1736000000, + IncludeHidden: true, + PerPage: 25, + AfterCursor: "cursor-abc", + } + resp, err := svc.ListTickets(ctx, req) require.NoError(t, err) - assert.True(t, called) + require.NotNil(t, got) + + wantFilter := &datastores.ListTicketsFilter{ + CategoryIDs: []uint32{5}, + ExcludeSubcategories: true, + TicketStates: []string{"open"}, + StatusIDs: []uint32{1, 3}, + PrefixIDs: []uint32{2}, + AssignedUserIDs: []uint32{100}, + StarterUserIDs: []uint32{200}, + ModifiedSince: 1736000000, + IncludeHidden: true, + PerPage: 25, + AfterCursor: "cursor-abc", + } + assert.Equal(t, wantFilter, got) + require.Len(t, resp.Tickets, 1) assert.Equal(t, "next123", resp.NextCursor) assert.True(t, resp.HasMore) } +func TestGetTicket_NotFoundReturns404Code(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + getTicket: func(id uint32) (*proto.Ticket, error) { + return nil, gorm.ErrRecordNotFound + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + _, err := svc.GetTicket(withTicketsKey("read:tickets"), &proto.GetTicketRequest{TicketId: 99999}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.NotFound, st.Code()) +} + func TestListTickets_DatastoreError(t *testing.T) { svc := &TicketsService{ Datastore: &fakeDatastore{ From 1524b924898f9416f77a0a65e8518247e737d7c4 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:51:21 -0400 Subject: [PATCH 21/32] feat(server): register TicketsService on gRPC + HTTP gateway --- servers/gateway/gateway.go | 6 ++++++ servers/server.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/servers/gateway/gateway.go b/servers/gateway/gateway.go index efcd8be..e9e99ec 100644 --- a/servers/gateway/gateway.go +++ b/servers/gateway/gateway.go @@ -143,6 +143,12 @@ func (service *Service) Server() *http.Server { return nil } + err = proto.RegisterTicketsServiceHandler(context.Background(), gwMux, conn) + if err != nil { + Error.Println("failed to register tickets gateway: ", err) + return nil + } + openApi := getOpenAPIHandler() handler := authMiddleware(service.Datastore, diff --git a/servers/server.go b/servers/server.go index 9b0f945..715a98e 100644 --- a/servers/server.go +++ b/servers/server.go @@ -173,6 +173,12 @@ func servGRPC(server *MicroServer, lis net.Listener, grpcOpts []grpc.ServerOptio server.grpcServer = grpc.NewServer(grpcOpts...) milpacs.RegisterMilpacServiceServer(server.grpcServer, service) + ticketsService := &grpcServices.TicketsService{ + Datastore: ds, + ReferenceCache: server.referenceCache, + } + milpacs.RegisterTicketsServiceServer(server.grpcServer, ticketsService) + if err := server.grpcServer.Serve(lis); err != nil { Error.Fatalf("unable to start external gRPC servers: ", err) } From 08e90e4be1f8cff9adea974ee9b5014bac485b32 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 17:51:39 -0400 Subject: [PATCH 22/32] feat(middleware): bypass Redis cache for /api/v1/tickets/* --- middleware/cache.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/middleware/cache.go b/middleware/cache.go index c900e94..97dd5b1 100644 --- a/middleware/cache.go +++ b/middleware/cache.go @@ -19,6 +19,10 @@ var ( func CacheMiddleware(cache *cache.RedisCache, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/v1/tickets") { + next.ServeHTTP(w, r) + return + } start := time.Now() acceptEncoding := r.Header.Get("Accept-Encoding") r.Header.Del("Accept-Encoding") From 69b37055f3250a36ce8f450813ef99b86a5e26d6 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 18:01:14 -0400 Subject: [PATCH 23/32] chore: bump version to 2.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - servers/server.go const version + proto/milpacs.proto openapiv2_swagger.info.version bumped lockstep per CLAUDE.md - proto/tickets.proto already declares 2.2.0 (set in Phase 3) - make generate output includes catch-up bytes for milpacs.pb.go + third_party/OpenAPI/milpacs.swagger.json which had pre-existing drift (stuck at 1.7.x) — the catch-up isn't new work, just the regeneration finally syncing them to the proto source --- proto/milpacs.pb.go | 4 ++-- proto/milpacs.proto | 2 +- servers/server.go | 2 +- third_party/OpenAPI/milpacs.swagger.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/proto/milpacs.pb.go b/proto/milpacs.pb.go index ecc099e..6b4d4ac 100644 --- a/proto/milpacs.pb.go +++ b/proto/milpacs.pb.go @@ -2589,8 +2589,8 @@ var file_milpacs_proto_rawDesc = string([]byte{ 0x61, 0x6d, 0x65, 0x72, 0x74, 0x61, 0x67, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x24, 0x12, 0x22, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x69, 0x6c, 0x70, 0x61, 0x63, 0x2f, 0x67, 0x61, 0x6d, 0x65, 0x72, 0x74, 0x61, 0x67, 0x2f, 0x7b, 0x67, 0x61, 0x6d, 0x65, 0x72, 0x74, 0x61, 0x67, - 0x7d, 0x42, 0xc8, 0x01, 0x92, 0x41, 0xa9, 0x01, 0x12, 0x07, 0x32, 0x05, 0x31, 0x2e, 0x37, 0x2e, - 0x34, 0x2a, 0x01, 0x02, 0x5a, 0x59, 0x0a, 0x57, 0x0a, 0x06, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, + 0x7d, 0x42, 0xc8, 0x01, 0x92, 0x41, 0xa9, 0x01, 0x12, 0x07, 0x32, 0x05, 0x32, 0x2e, 0x32, 0x2e, + 0x30, 0x2a, 0x01, 0x02, 0x5a, 0x59, 0x0a, 0x57, 0x0a, 0x06, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, 0x12, 0x4d, 0x08, 0x02, 0x12, 0x38, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x2c, 0x20, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x42, 0x65, 0x61, 0x72, 0x65, 0x72, 0x3a, 0x20, diff --git a/proto/milpacs.proto b/proto/milpacs.proto index c67843a..a85d667 100644 --- a/proto/milpacs.proto +++ b/proto/milpacs.proto @@ -29,7 +29,7 @@ import "google/protobuf/empty.proto"; // These annotations are used when generating the OpenAPI file. option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { - version: "2.1.1"; + version: "2.2.0"; }; external_docs: { url: "https://github.com/7cav/api"; diff --git a/servers/server.go b/servers/server.go index 715a98e..54711cf 100644 --- a/servers/server.go +++ b/servers/server.go @@ -41,7 +41,7 @@ import ( "gorm.io/gorm" ) -const version = "2.1.1" +const version = "2.2.0" type MicroServer struct { addr string diff --git a/third_party/OpenAPI/milpacs.swagger.json b/third_party/OpenAPI/milpacs.swagger.json index 64ae217..d3eb58a 100644 --- a/third_party/OpenAPI/milpacs.swagger.json +++ b/third_party/OpenAPI/milpacs.swagger.json @@ -2,7 +2,7 @@ "swagger": "2.0", "info": { "title": "milpacs.proto", - "version": "1.7.6" + "version": "2.2.0" }, "tags": [ { From 56f59a67962cebfb92b6a60f18073813bc370fb2 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:26:10 -0400 Subject: [PATCH 24/32] feat(auth): add shared ParseBearerToken helper Adds a case-insensitive Bearer-scheme parser in datastores so the gRPC interceptor and HTTP middleware can share one implementation. RFC 7235 says auth-scheme tokens are case-insensitive; lowercase 'bearer' previously returned 401 (caught as G7 in the 2026-05-16 staging smoke test). Call-site swap follows in the next commit. --- datastores/auth_test.go | 32 ++++++++++++++++++++++++++++++++ datastores/datastore.go | 18 ++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/datastores/auth_test.go b/datastores/auth_test.go index 0343d3b..a5b99f7 100644 --- a/datastores/auth_test.go +++ b/datastores/auth_test.go @@ -2,6 +2,7 @@ package datastores import ( "regexp" + "strings" "testing" "github.com/DATA-DOG/go-sqlmock" @@ -72,3 +73,34 @@ func TestValidateApiKey_QueryError(t *testing.T) { assert.Error(t, err) assert.Nil(t, result) } + +func TestParseBearerToken(t *testing.T) { + cases := []struct { + name string + raw string + maxLen int + want string + }{ + {"canonical scheme", "Bearer abc", 128, "abc"}, + {"lowercase scheme", "bearer abc", 128, "abc"}, + {"uppercase scheme", "BEARER abc", 128, "abc"}, + {"mixed-case scheme", "bEaReR abc", 128, "abc"}, + {"basic scheme rejected", "Basic abc", 128, ""}, + {"empty input", "", 128, ""}, + {"whitespace only", " ", 128, ""}, + {"scheme with no token", "Bearer ", 128, ""}, + {"scheme with whitespace token", "Bearer ", 128, ""}, + {"oversize token", "Bearer " + strings.Repeat("x", 200), 128, ""}, + {"max-len boundary", "Bearer " + strings.Repeat("x", 128), 128, strings.Repeat("x", 128)}, + {"one over max-len", "Bearer " + strings.Repeat("x", 129), 128, ""}, + {"outer whitespace tolerated", " Bearer abc ", 128, "abc"}, + {"inner extra spaces collapsed", "Bearer abc", 128, "abc"}, + {"missing scheme separator", "Bearerabc", 128, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := ParseBearerToken(c.raw, c.maxLen) + assert.Equal(t, c.want, got) + }) + } +} diff --git a/datastores/datastore.go b/datastores/datastore.go index adf7fcb..b30fd5b 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -22,6 +22,7 @@ import ( "context" "log" "os" + "strings" "github.com/7cav/api/proto" "github.com/7cav/api/referencecache" @@ -49,6 +50,23 @@ func (r *ApiKeyResult) HasScope(name string) bool { return ok } +// ParseBearerToken extracts the raw API token from an Authorization header +// value. Returns "" if no Bearer scheme is present, the token is empty, +// or the result exceeds maxLen. Scheme name is matched case-insensitively +// per RFC 7235. +func ParseBearerToken(raw string, maxLen int) string { + raw = strings.TrimSpace(raw) + const prefix = "Bearer " + if len(raw) < len(prefix) || !strings.EqualFold(raw[:len(prefix)], prefix) { + return "" + } + tok := strings.TrimSpace(raw[len(prefix):]) + if tok == "" || len(tok) > maxLen { + return "" + } + return tok +} + type Datastore interface { FindProfilesById(userId ...uint64) ([]*proto.Profile, error) FindProfilesByUsername(username string) ([]*proto.Profile, error) From 1e664a16316d51cc5101471be334c69ec03605a0 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:29:40 -0400 Subject: [PATCH 25/32] refactor(grpc): use shared ParseBearerToken in auth interceptor Replaces the inline TrimPrefix with the case-insensitive helper from the previous commit. No functional change beyond G7's case-sensitivity fix. --- servers/grpc/authentication.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/servers/grpc/authentication.go b/servers/grpc/authentication.go index 77e91c2..3261424 100644 --- a/servers/grpc/authentication.go +++ b/servers/grpc/authentication.go @@ -20,7 +20,6 @@ package grpc import ( "context" - "strings" "github.com/7cav/api/datastores" "google.golang.org/grpc" @@ -43,9 +42,8 @@ func NewAuthInterceptor(ds datastores.Datastore) grpc.UnaryServerInterceptor { return nil, status.Errorf(codes.Unauthenticated, "missing authorization token") } - raw := strings.TrimSpace(authHeaders[0]) - token := strings.TrimSpace(strings.TrimPrefix(raw, "Bearer ")) - if token == "" || len(token) > maxTokenLen { + token := datastores.ParseBearerToken(authHeaders[0], maxTokenLen) + if token == "" { return nil, status.Errorf(codes.Unauthenticated, "missing authorization token") } From 0c5e953a0dfd3fcf8f5646d9ae4a54680a20d7f3 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:32:46 -0400 Subject: [PATCH 26/32] refactor(gateway): use shared ParseBearerToken in HTTP middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes G7 from the 2026-05-16 staging smoke test — lowercase 'bearer' Authorization headers now authenticate correctly. Both auth surfaces share one parser, eliminating the lockstep-or-drift footgun called out in CLAUDE.md. --- servers/gateway/gateway.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/servers/gateway/gateway.go b/servers/gateway/gateway.go index e9e99ec..2c606d2 100644 --- a/servers/gateway/gateway.go +++ b/servers/gateway/gateway.go @@ -68,8 +68,8 @@ const maxTokenLen = 128 func authMiddleware(ds datastores.Datastore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if token == "" || len(token) > maxTokenLen { + token := datastores.ParseBearerToken(r.Header.Get("Authorization"), maxTokenLen) + if token == "" { Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return From b80e2ffeef98d7b26563b2e27cc500d7cf154e71 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:36:40 -0400 Subject: [PATCH 27/32] feat(tickets): add ErrInvalidCursor sentinel decodeCursor now wraps a sentinel error so the handler layer can map malformed cursors to codes.InvalidArgument. Handler mapping follows in the next commit. --- datastores/tickets.go | 11 ++++++++--- datastores/tickets_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/datastores/tickets.go b/datastores/tickets.go index 94c1175..a1edce7 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -3,6 +3,7 @@ package datastores import ( "context" "encoding/base64" + "errors" "fmt" "strconv" "strings" @@ -30,6 +31,10 @@ type ListTicketsFilter struct { AfterCursor string } +// ErrInvalidCursor signals a malformed cursor string. Handlers should map +// errors wrapping this sentinel to codes.InvalidArgument → HTTP 400. +var ErrInvalidCursor = errors.New("invalid cursor") + // Compile-time assertion: Mysql must implement referencecache.Loader. var _ referencecache.Loader = (*Mysql)(nil) @@ -121,7 +126,7 @@ func (ds *Mysql) ListTickets(ctx context.Context, rc TicketReferenceCache, f *Li if f.AfterCursor != "" { ts, id, err := decodeCursor(f.AfterCursor) if err != nil { - return nil, "", false, fmt.Errorf("invalid cursor: %w", err) + return nil, "", false, err } // Tuple comparison for stable cursor under non-unique sort key. q = q.Where("(last_modified_date < ?) OR (last_modified_date = ? AND ticket_id < ?)", ts, ts, id) @@ -220,11 +225,11 @@ func encodeCursor(lastModified, ticketID uint32) string { func decodeCursor(c string) (uint32, uint32, error) { raw, err := base64.RawURLEncoding.DecodeString(c) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } var ts, id uint32 if _, err := fmt.Sscanf(string(raw), "%d:%d", &ts, &id); err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } return ts, id, nil } diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index d775137..94ecb26 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -2,6 +2,8 @@ package datastores import ( "context" + "encoding/base64" + "errors" "regexp" "testing" @@ -287,3 +289,36 @@ func TestListCategories_PassThroughFromCache(t *testing.T) { assert.Contains(t, titles, "S1 Personnel Admin") assert.Contains(t, titles, "S1 Citations") } + +func TestDecodeCursor_GarbageReturnsErrInvalidCursor(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"not base64", "garbage-not-base64"}, + {"base64 but not ts:id", base64URL("nope")}, + {"only one int", base64URL("1234")}, + {"empty string after base64 decode", base64URL("")}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, _, err := decodeCursor(c.in) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidCursor), + "want errors.Is(err, ErrInvalidCursor); got %v", err) + }) + } +} + +func TestDecodeCursor_ValidRoundTrip(t *testing.T) { + enc := encodeCursor(1736294298, 7499) + ts, id, err := decodeCursor(enc) + require.NoError(t, err) + assert.Equal(t, uint32(1736294298), ts) + assert.Equal(t, uint32(7499), id) +} + +// base64URL returns base64.RawURLEncoding.EncodeToString([]byte(s)). +func base64URL(s string) string { + return base64.RawURLEncoding.EncodeToString([]byte(s)) +} From 0283af544c337e9277a93804dec5901b0ded9c15 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:40:22 -0400 Subject: [PATCH 28/32] fix(tickets): map invalid cursor to InvalidArgument (400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes C3 from the 2026-05-16 staging smoke test — malformed after_cursor on /api/v1/tickets now returns HTTP 400 instead of 500. Detected via errors.Is on the ErrInvalidCursor sentinel introduced in the previous commit. --- servers/grpc/tickets.go | 3 +++ servers/grpc/tickets_test.go | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index e3d00d4..6ab34cc 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -40,6 +40,9 @@ func (s *TicketsService) ListTickets(ctx context.Context, req *proto.ListTickets } tickets, next, more, err := s.Datastore.ListTickets(ctx, s.ReferenceCache, filter) if err != nil { + if errors.Is(err, datastores.ErrInvalidCursor) { + return nil, status.Errorf(codes.InvalidArgument, "invalid after_cursor") + } return nil, status.Errorf(codes.Internal, "list tickets: %v", err) } return &proto.ListTicketsResponse{ diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index 27c43b3..dbf3a13 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -3,6 +3,7 @@ package grpc import ( "context" "errors" + "fmt" "testing" "github.com/7cav/api/datastores" @@ -241,3 +242,22 @@ func TestListCategories_HappyPath(t *testing.T) { require.Len(t, resp.Categories, 1) assert.Equal(t, "S1", resp.Categories[0].Title) } + +func TestListTickets_InvalidCursorMapsTo400(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listTickets: func(_ *datastores.ListTicketsFilter) ([]*proto.Ticket, string, bool, error) { + // Simulate datastore returning a wrapped ErrInvalidCursor. + return nil, "", false, fmt.Errorf("decode: %w", datastores.ErrInvalidCursor) + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + _, err := svc.ListTickets(withTicketsKey("read:tickets"), &proto.ListTicketsRequest{ + AfterCursor: "garbage", + }) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "expected gRPC status error") + assert.Equal(t, codes.InvalidArgument, st.Code()) +} From 887d43e2f1abd363ecc7773304cb6e485580c480 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:44:49 -0400 Subject: [PATCH 29/32] docs(tickets): clarify repeat-the-param semantics on ListTickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds doc-comments to all six repeated filters on ListTicketsRequest so the OpenAPI page surfaces the supported multi-select form. Closes A4 from the 2026-05-16 staging smoke test — comma-separated form (?status_id=1,3) was never wired and the parent spec wrongly promised it would work. Aligning the contract with reality is the YAGNI fix; a custom unmarshaller is deferred until a hand-typed-URL consumer asks for it. --- proto/tickets.pb.go | 40 +++++++++++++++--------- proto/tickets.proto | 12 +++++++ third_party/OpenAPI/tickets.swagger.json | 6 ++++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/proto/tickets.pb.go b/proto/tickets.pb.go index b838614..7a6c27e 100644 --- a/proto/tickets.pb.go +++ b/proto/tickets.pb.go @@ -567,20 +567,32 @@ func (x *TicketParticipant) GetLastReadDate() uint32 { } type ListTicketsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - CategoryId []uint32 `protobuf:"varint,1,rep,packed,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"` - ExcludeSubcategories bool `protobuf:"varint,2,opt,name=exclude_subcategories,json=excludeSubcategories,proto3" json:"exclude_subcategories,omitempty"` - TicketState []string `protobuf:"bytes,3,rep,name=ticket_state,json=ticketState,proto3" json:"ticket_state,omitempty"` - StatusId []uint32 `protobuf:"varint,4,rep,packed,name=status_id,json=statusId,proto3" json:"status_id,omitempty"` - PrefixId []uint32 `protobuf:"varint,5,rep,packed,name=prefix_id,json=prefixId,proto3" json:"prefix_id,omitempty"` - AssignedUserId []uint32 `protobuf:"varint,6,rep,packed,name=assigned_user_id,json=assignedUserId,proto3" json:"assigned_user_id,omitempty"` - StarterUserId []uint32 `protobuf:"varint,7,rep,packed,name=starter_user_id,json=starterUserId,proto3" json:"starter_user_id,omitempty"` - ModifiedSince uint32 `protobuf:"varint,8,opt,name=modified_since,json=modifiedSince,proto3" json:"modified_since,omitempty"` - IncludeHidden bool `protobuf:"varint,9,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` - PerPage uint32 `protobuf:"varint,50,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` - AfterCursor string `protobuf:"bytes,51,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // Multi-select by repeating the param: ?category_id=1&category_id=5. + // Comma-separated form (?category_id=1,5) is NOT supported. + CategoryId []uint32 `protobuf:"varint,1,rep,packed,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"` + ExcludeSubcategories bool `protobuf:"varint,2,opt,name=exclude_subcategories,json=excludeSubcategories,proto3" json:"exclude_subcategories,omitempty"` + // Multi-select by repeating the param: ?ticket_state=open&ticket_state=resolved. + // Comma-separated form is NOT supported. + TicketState []string `protobuf:"bytes,3,rep,name=ticket_state,json=ticketState,proto3" json:"ticket_state,omitempty"` + // Multi-select by repeating the param: ?status_id=1&status_id=3. + // Comma-separated form is NOT supported. + StatusId []uint32 `protobuf:"varint,4,rep,packed,name=status_id,json=statusId,proto3" json:"status_id,omitempty"` + // Multi-select by repeating the param: ?prefix_id=1&prefix_id=2. + // Comma-separated form is NOT supported. + PrefixId []uint32 `protobuf:"varint,5,rep,packed,name=prefix_id,json=prefixId,proto3" json:"prefix_id,omitempty"` + // Multi-select by repeating the param: ?assigned_user_id=1&assigned_user_id=2. + // Comma-separated form is NOT supported. + AssignedUserId []uint32 `protobuf:"varint,6,rep,packed,name=assigned_user_id,json=assignedUserId,proto3" json:"assigned_user_id,omitempty"` + // Multi-select by repeating the param: ?starter_user_id=1&starter_user_id=2. + // Comma-separated form is NOT supported. + StarterUserId []uint32 `protobuf:"varint,7,rep,packed,name=starter_user_id,json=starterUserId,proto3" json:"starter_user_id,omitempty"` + ModifiedSince uint32 `protobuf:"varint,8,opt,name=modified_since,json=modifiedSince,proto3" json:"modified_since,omitempty"` + IncludeHidden bool `protobuf:"varint,9,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` + PerPage uint32 `protobuf:"varint,50,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` + AfterCursor string `protobuf:"bytes,51,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListTicketsRequest) Reset() { diff --git a/proto/tickets.proto b/proto/tickets.proto index 67df258..393fda8 100644 --- a/proto/tickets.proto +++ b/proto/tickets.proto @@ -160,12 +160,24 @@ message TicketParticipant { } message ListTicketsRequest { + // Multi-select by repeating the param: ?category_id=1&category_id=5. + // Comma-separated form (?category_id=1,5) is NOT supported. repeated uint32 category_id = 1; bool exclude_subcategories = 2; + // Multi-select by repeating the param: ?ticket_state=open&ticket_state=resolved. + // Comma-separated form is NOT supported. repeated string ticket_state = 3; + // Multi-select by repeating the param: ?status_id=1&status_id=3. + // Comma-separated form is NOT supported. repeated uint32 status_id = 4; + // Multi-select by repeating the param: ?prefix_id=1&prefix_id=2. + // Comma-separated form is NOT supported. repeated uint32 prefix_id = 5; + // Multi-select by repeating the param: ?assigned_user_id=1&assigned_user_id=2. + // Comma-separated form is NOT supported. repeated uint32 assigned_user_id = 6; + // Multi-select by repeating the param: ?starter_user_id=1&starter_user_id=2. + // Comma-separated form is NOT supported. repeated uint32 starter_user_id = 7; uint32 modified_since = 8; bool include_hidden = 9; diff --git a/third_party/OpenAPI/tickets.swagger.json b/third_party/OpenAPI/tickets.swagger.json index a63b05a..c91e6d0 100644 --- a/third_party/OpenAPI/tickets.swagger.json +++ b/third_party/OpenAPI/tickets.swagger.json @@ -42,6 +42,7 @@ "parameters": [ { "name": "categoryId", + "description": "Multi-select by repeating the param: ?category_id=1\u0026category_id=5.\nComma-separated form (?category_id=1,5) is NOT supported.", "in": "query", "required": false, "type": "array", @@ -59,6 +60,7 @@ }, { "name": "ticketState", + "description": "Multi-select by repeating the param: ?ticket_state=open\u0026ticket_state=resolved.\nComma-separated form is NOT supported.", "in": "query", "required": false, "type": "array", @@ -69,6 +71,7 @@ }, { "name": "statusId", + "description": "Multi-select by repeating the param: ?status_id=1\u0026status_id=3.\nComma-separated form is NOT supported.", "in": "query", "required": false, "type": "array", @@ -80,6 +83,7 @@ }, { "name": "prefixId", + "description": "Multi-select by repeating the param: ?prefix_id=1\u0026prefix_id=2.\nComma-separated form is NOT supported.", "in": "query", "required": false, "type": "array", @@ -91,6 +95,7 @@ }, { "name": "assignedUserId", + "description": "Multi-select by repeating the param: ?assigned_user_id=1\u0026assigned_user_id=2.\nComma-separated form is NOT supported.", "in": "query", "required": false, "type": "array", @@ -102,6 +107,7 @@ }, { "name": "starterUserId", + "description": "Multi-select by repeating the param: ?starter_user_id=1\u0026starter_user_id=2.\nComma-separated form is NOT supported.", "in": "query", "required": false, "type": "array", From ce0aa19d5040f9c63d9cb58564b25b836f8e8976 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sat, 16 May 2026 23:57:25 -0400 Subject: [PATCH 30/32] feat(tickets): total_message_count + opaque message cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes E1 from the 2026-05-16 staging smoke test. - Adds total_message_count to Ticket (derived from reply_count + 1, no extra query). Both ListTickets and GetTicket responses carry it. - Deprecates GetTicketResponse.total_message_count (use ticket.total_message_count). Removal target: v2.3.0. - Switches ListTicketMessages cursor from uint32 after_position to opaque string after_cursor + base64-encoded position. Closes the proto3-zero-value gap that made position=0 messages unreachable (smoke run: ticket 6899 paginated 80/81 messages). - Maps ErrInvalidCursor → codes.InvalidArgument on ListTicketMessages to match ListTickets. Field-number reuse on ListTicketMessagesRequest.after_cursor and ListTicketMessagesResponse.next_cursor is intentional and safe: the previous uint32 types never shipped — v2.2.0 is unreleased. --- datastores/datastore.go | 2 +- datastores/tickets.go | 41 +++++++++-- datastores/tickets_test.go | 47 ++++++++++++- proto/tickets.pb.go | 87 +++++++++++++++--------- proto/tickets.proto | 14 +++- servers/grpc/tickets.go | 5 +- servers/grpc/tickets_test.go | 33 +++++++-- third_party/OpenAPI/tickets.swagger.json | 18 +++-- 8 files changed, 189 insertions(+), 58 deletions(-) diff --git a/datastores/datastore.go b/datastores/datastore.go index b30fd5b..e8c0279 100644 --- a/datastores/datastore.go +++ b/datastores/datastore.go @@ -88,7 +88,7 @@ type Datastore interface { GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*proto.Ticket, error) GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*proto.Ticket, error) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*proto.Message, totalCount uint32, err error) - ListTicketMessages(ctx context.Context, ticketID, afterPosition, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor uint32, hasMore bool, err error) + ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor string, hasMore bool, err error) ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error) } diff --git a/datastores/tickets.go b/datastores/tickets.go index a1edce7..781f979 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -186,6 +186,7 @@ func generateTicketProto(t *xenforo.Ticket, rc TicketReferenceCache, forumBase s LastMessageUsername: t.LastMessageUsername, LastModifiedDate: t.LastModifiedDate, ReplyCount: t.ReplyCount, + TotalMessageCount: t.ReplyCount + 1, CustomFields: map[string]string{}, } if cat := rc.Category(t.TicketCategoryID); cat != nil { @@ -234,6 +235,32 @@ func decodeCursor(c string) (uint32, uint32, error) { return ts, id, nil } +// encodeMessageCursor encodes a ticket-message position into an opaque +// base64 cursor. Position alone is sufficient because XF's +// xf_nf_tickets_message table guarantees (ticket_id, position) is unique +// per the message_id_position index. +func encodeMessageCursor(position uint32) string { + return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", position))) +} + +// decodeMessageCursor decodes an opaque message cursor back to a position. +// Empty string is a valid input meaning "start from the beginning"; any +// other malformed input returns an error wrapping ErrInvalidCursor. +func decodeMessageCursor(c string) (uint32, error) { + if c == "" { + return 0, nil + } + raw, err := base64.RawURLEncoding.DecodeString(c) + if err != nil { + return 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) + } + var pos uint32 + if _, err := fmt.Sscanf(string(raw), "%d", &pos); err != nil { + return 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) + } + return pos, nil +} + func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBase string) (*proto.Ticket, error) { var row xenforo.Ticket tx := ds.Db.WithContext(ctx). @@ -277,7 +304,7 @@ func (ds *Mysql) GetTicketFirstMessages(ctx context.Context, ticketID uint32, n return out, uint32(total), nil } -func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID, afterPosition, perPage uint32, includeHidden bool) ([]*proto.Message, uint32, bool, error) { +func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) ([]*proto.Message, string, bool, error) { if perPage == 0 || perPage > 100 { if perPage == 0 { perPage = 50 @@ -285,14 +312,18 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID, afterPosition perPage = 100 } } - q := ds.Db.WithContext(ctx).Where("ticket_id = ? AND position > ?", ticketID, afterPosition) + afterPos, err := decodeMessageCursor(afterCursor) + if err != nil { + return nil, "", false, err + } + q := ds.Db.WithContext(ctx).Where("ticket_id = ? AND position > ?", ticketID, afterPos) if !includeHidden { q = q.Where("message_state = ?", "visible") } var rows []xenforo.TicketMessage tx := q.Order("position ASC").Limit(int(perPage) + 1).Find(&rows) if tx.Error != nil { - return nil, 0, false, tx.Error + return nil, "", false, tx.Error } hasMore := len(rows) > int(perPage) if hasMore { @@ -302,9 +333,9 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID, afterPosition for i := range rows { out = append(out, messageToProto(&rows[i])) } - var next uint32 + var next string if hasMore && len(rows) > 0 { - next = rows[len(rows)-1].Position + next = encodeMessageCursor(rows[len(rows)-1].Position) } return out, next, hasMore, nil } diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index 94ecb26..c7565de 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/7cav/api/referencecache" + "github.com/7cav/api/xenforo" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -271,10 +272,10 @@ func TestListTicketMessages_Pagination(t *testing.T) { WithArgs(uint32(1), uint32(10), "visible", 3). WillReturnRows(rows) - msgs, next, more, err := (&ds).ListTicketMessages(context.Background(), 1, 10, 2, false) + msgs, next, more, err := (&ds).ListTicketMessages(context.Background(), 1, encodeMessageCursor(10), 2, false) require.NoError(t, err) assert.True(t, more) - assert.Equal(t, uint32(12), next, "cursor is position of last returned message") + assert.Equal(t, encodeMessageCursor(12), next, "cursor is base64 of position of last returned message") assert.Len(t, msgs, 2) } @@ -322,3 +323,45 @@ func TestDecodeCursor_ValidRoundTrip(t *testing.T) { func base64URL(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } + +func TestEncodeDecodeMessageCursor_RoundTrip(t *testing.T) { + enc := encodeMessageCursor(42) + pos, err := decodeMessageCursor(enc) + require.NoError(t, err) + assert.Equal(t, uint32(42), pos) +} + +func TestDecodeMessageCursor_EmptyMeansBeginning(t *testing.T) { + pos, err := decodeMessageCursor("") + require.NoError(t, err) + assert.Equal(t, uint32(0), pos) +} + +func TestDecodeMessageCursor_GarbageReturnsErrInvalidCursor(t *testing.T) { + // Note: base64URL("") == "" which is the documented "start from beginning" + // sentinel (see TestDecodeMessageCursor_EmptyMeansBeginning), so it is + // intentionally NOT in the garbage list here. + cases := []string{ + "garbage-not-base64", + base64URL("notanumber"), + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := decodeMessageCursor(in) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidCursor), + "want errors.Is(err, ErrInvalidCursor); got %v", err) + }) + } +} + +func TestGenerateTicketProto_TotalMessageCountDerived(t *testing.T) { + rc := newFakeRefCache() + row := &xenforo.Ticket{ + TicketID: 7499, + ReplyCount: 80, + } + out := generateTicketProto(row, rc, "") + assert.Equal(t, uint32(81), out.TotalMessageCount, + "total_message_count must equal reply_count + 1 (starter post + replies)") +} diff --git a/proto/tickets.pb.go b/proto/tickets.pb.go index 7a6c27e..2fc9eff 100644 --- a/proto/tickets.pb.go +++ b/proto/tickets.pb.go @@ -64,6 +64,9 @@ type Ticket struct { LastMessageUsername string `protobuf:"bytes,43,opt,name=last_message_username,json=lastMessageUsername,proto3" json:"last_message_username,omitempty"` LastModifiedDate uint32 `protobuf:"varint,44,opt,name=last_modified_date,json=lastModifiedDate,proto3" json:"last_modified_date,omitempty"` ReplyCount uint32 `protobuf:"varint,45,opt,name=reply_count,json=replyCount,proto3" json:"reply_count,omitempty"` + // Total messages in this ticket including the starter post. Equivalent + // to reply_count + 1. + TotalMessageCount uint32 `protobuf:"varint,46,opt,name=total_message_count,json=totalMessageCount,proto3" json:"total_message_count,omitempty"` // Data CustomFields map[string]string `protobuf:"bytes,50,rep,name=custom_fields,json=customFields,proto3" json:"custom_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Convenience @@ -284,6 +287,13 @@ func (x *Ticket) GetReplyCount() uint32 { return 0 } +func (x *Ticket) GetTotalMessageCount() uint32 { + if x != nil { + return x.TotalMessageCount + } + return 0 +} + func (x *Ticket) GetCustomFields() map[string]string { if x != nil { return x.CustomFields @@ -851,10 +861,13 @@ func (x *GetTicketByRefRequest) GetTicketRef() string { } type GetTicketResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Ticket *Ticket `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket,omitempty"` - FirstMessages []*Message `protobuf:"bytes,2,rep,name=first_messages,json=firstMessages,proto3" json:"first_messages,omitempty"` - TotalMessageCount uint32 `protobuf:"varint,3,opt,name=total_message_count,json=totalMessageCount,proto3" json:"total_message_count,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Ticket *Ticket `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket,omitempty"` + FirstMessages []*Message `protobuf:"bytes,2,rep,name=first_messages,json=firstMessages,proto3" json:"first_messages,omitempty"` + // DEPRECATED: use ticket.total_message_count. Removal target: v2.3.0. + // + // Deprecated: Marked as deprecated in tickets.proto. + TotalMessageCount uint32 `protobuf:"varint,3,opt,name=total_message_count,json=totalMessageCount,proto3" json:"total_message_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -903,6 +916,7 @@ func (x *GetTicketResponse) GetFirstMessages() []*Message { return nil } +// Deprecated: Marked as deprecated in tickets.proto. func (x *GetTicketResponse) GetTotalMessageCount() uint32 { if x != nil { return x.TotalMessageCount @@ -911,11 +925,14 @@ func (x *GetTicketResponse) GetTotalMessageCount() uint32 { } type ListTicketMessagesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - TicketId uint32 `protobuf:"varint,1,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` - PerPage uint32 `protobuf:"varint,2,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` - AfterPosition uint32 `protobuf:"varint,3,opt,name=after_position,json=afterPosition,proto3" json:"after_position,omitempty"` - IncludeHidden bool `protobuf:"varint,4,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + TicketId uint32 `protobuf:"varint,1,opt,name=ticket_id,json=ticketId,proto3" json:"ticket_id,omitempty"` + PerPage uint32 `protobuf:"varint,2,opt,name=per_page,json=perPage,proto3" json:"per_page,omitempty"` + // Opaque cursor; empty string = first page. Round-trip the next_cursor + // from the previous response. Pagination is by message position + // ascending. + AfterCursor string `protobuf:"bytes,3,opt,name=after_cursor,json=afterCursor,proto3" json:"after_cursor,omitempty"` + IncludeHidden bool `protobuf:"varint,4,opt,name=include_hidden,json=includeHidden,proto3" json:"include_hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -964,11 +981,11 @@ func (x *ListTicketMessagesRequest) GetPerPage() uint32 { return 0 } -func (x *ListTicketMessagesRequest) GetAfterPosition() uint32 { +func (x *ListTicketMessagesRequest) GetAfterCursor() string { if x != nil { - return x.AfterPosition + return x.AfterCursor } - return 0 + return "" } func (x *ListTicketMessagesRequest) GetIncludeHidden() bool { @@ -979,10 +996,11 @@ func (x *ListTicketMessagesRequest) GetIncludeHidden() bool { } type ListTicketMessagesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Messages []*Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` - NextCursor uint32 `protobuf:"varint,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` - HasMore bool `protobuf:"varint,3,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Messages []*Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"` + // Opaque cursor; empty when has_more=false. + NextCursor string `protobuf:"bytes,2,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,3,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1024,11 +1042,11 @@ func (x *ListTicketMessagesResponse) GetMessages() []*Message { return nil } -func (x *ListTicketMessagesResponse) GetNextCursor() uint32 { +func (x *ListTicketMessagesResponse) GetNextCursor() string { if x != nil { return x.NextCursor } - return 0 + return "" } func (x *ListTicketMessagesResponse) GetHasMore() bool { @@ -1093,7 +1111,7 @@ var file_tickets_proto_rawDesc = string([]byte{ 0x6e, 0x73, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x22, 0x96, 0x09, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, + 0x6f, 0x22, 0xc6, 0x09, 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, @@ -1156,7 +1174,10 @@ var file_tickets_proto_rawDesc = string([]byte{ 0x01, 0x28, 0x0d, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x44, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x70, 0x6c, 0x79, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x2d, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x72, 0x65, 0x70, 0x6c, - 0x79, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x79, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x2e, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x32, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, @@ -1250,25 +1271,25 @@ var file_tickets_proto_rawDesc = string([]byte{ 0x69, 0x63, 0x6b, 0x65, 0x74, 0x42, 0x79, 0x52, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x66, - 0x22, 0xa1, 0x01, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, + 0x22, 0xa5, 0x01, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x06, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x06, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x35, 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x0d, 0x66, 0x69, 0x72, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x65, + 0x61, 0x67, 0x65, 0x73, 0x12, 0x32, 0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x43, - 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, - 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, - 0x19, 0x0a, 0x08, 0x70, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x07, 0x70, 0x65, 0x72, 0x50, 0x61, 0x67, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x66, - 0x74, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x0d, 0x61, 0x66, 0x74, 0x65, 0x72, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x68, 0x69, 0x64, + 0x0d, 0x42, 0x02, 0x18, 0x01, 0x52, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x9d, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, + 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x74, 0x69, 0x63, 0x6b, 0x65, + 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x65, 0x72, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x70, 0x65, 0x72, 0x50, 0x61, 0x67, 0x65, 0x12, 0x21, + 0x0a, 0x0c, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x66, 0x74, 0x65, 0x72, 0x43, 0x75, 0x72, 0x73, 0x6f, + 0x72, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x48, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x22, 0x84, 0x01, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, @@ -1276,7 +1297,7 @@ var file_tickets_proto_rawDesc = string([]byte{ 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, - 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, + 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x68, 0x61, 0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61, 0x73, 0x4d, 0x6f, 0x72, 0x65, 0x22, 0x49, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x69, 0x65, diff --git a/proto/tickets.proto b/proto/tickets.proto index 393fda8..de93f75 100644 --- a/proto/tickets.proto +++ b/proto/tickets.proto @@ -122,6 +122,9 @@ message Ticket { string last_message_username = 43; uint32 last_modified_date = 44; uint32 reply_count = 45; + // Total messages in this ticket including the starter post. Equivalent + // to reply_count + 1. + uint32 total_message_count = 46; // Data map custom_fields = 50; @@ -203,19 +206,24 @@ message GetTicketByRefRequest { message GetTicketResponse { Ticket ticket = 1; repeated Message first_messages = 2; - uint32 total_message_count = 3; + // DEPRECATED: use ticket.total_message_count. Removal target: v2.3.0. + uint32 total_message_count = 3 [deprecated = true]; } message ListTicketMessagesRequest { uint32 ticket_id = 1; uint32 per_page = 2; - uint32 after_position = 3; + // Opaque cursor; empty string = first page. Round-trip the next_cursor + // from the previous response. Pagination is by message position + // ascending. + string after_cursor = 3; bool include_hidden = 4; } message ListTicketMessagesResponse { repeated Message messages = 1; - uint32 next_cursor = 2; + // Opaque cursor; empty when has_more=false. + string next_cursor = 2; bool has_more = 3; } diff --git a/servers/grpc/tickets.go b/servers/grpc/tickets.go index 6ab34cc..4740f63 100644 --- a/servers/grpc/tickets.go +++ b/servers/grpc/tickets.go @@ -102,8 +102,11 @@ func (s *TicketsService) ListTicketMessages(ctx context.Context, req *proto.List if err := RequireScope(ctx, "read:tickets"); err != nil { return nil, err } - msgs, next, more, err := s.Datastore.ListTicketMessages(ctx, req.TicketId, req.AfterPosition, req.PerPage, req.IncludeHidden) + msgs, next, more, err := s.Datastore.ListTicketMessages(ctx, req.TicketId, req.AfterCursor, req.PerPage, req.IncludeHidden) if err != nil { + if errors.Is(err, datastores.ErrInvalidCursor) { + return nil, status.Errorf(codes.InvalidArgument, "invalid after_cursor") + } return nil, status.Errorf(codes.Internal, "list ticket messages: %v", err) } return &proto.ListTicketMessagesResponse{ diff --git a/servers/grpc/tickets_test.go b/servers/grpc/tickets_test.go index dbf3a13..8089130 100644 --- a/servers/grpc/tickets_test.go +++ b/servers/grpc/tickets_test.go @@ -26,7 +26,7 @@ type fakeDatastore struct { getTicket func(uint32) (*proto.Ticket, error) getByRef func(string) (*proto.Ticket, error) firstMsgs func(uint32, int) ([]*proto.Message, uint32, error) - listMsgs func(uint32, uint32, uint32, bool) ([]*proto.Message, uint32, bool, error) + listMsgs func(uint32, string, uint32, bool) ([]*proto.Message, string, bool, error) listCats func() ([]*proto.Category, error) } @@ -42,7 +42,7 @@ func (f *fakeDatastore) GetTicketByRef(_ context.Context, _ datastores.TicketRef func (f *fakeDatastore) GetTicketFirstMessages(_ context.Context, id uint32, n int, _ bool) ([]*proto.Message, uint32, error) { return f.firstMsgs(id, n) } -func (f *fakeDatastore) ListTicketMessages(_ context.Context, id, after, per uint32, hidden bool) ([]*proto.Message, uint32, bool, error) { +func (f *fakeDatastore) ListTicketMessages(_ context.Context, id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { return f.listMsgs(id, after, per, hidden) } func (f *fakeDatastore) ListCategories(_ context.Context, _ datastores.TicketReferenceCache) ([]*proto.Category, error) { @@ -209,22 +209,22 @@ func TestGetTicketByRef_HappyPath(t *testing.T) { func TestListTicketMessages_HappyPath(t *testing.T) { svc := &TicketsService{ Datastore: &fakeDatastore{ - listMsgs: func(id, after, per uint32, hidden bool) ([]*proto.Message, uint32, bool, error) { + listMsgs: func(id uint32, after string, per uint32, hidden bool) ([]*proto.Message, string, bool, error) { assert.Equal(t, uint32(7499), id) - assert.Equal(t, uint32(10), after) + assert.Equal(t, "Y3Vyc29yOjEw", after, "handler must pass cursor through unchanged") assert.Equal(t, uint32(50), per) assert.False(t, hidden) - return []*proto.Message{{MessageId: 11}}, 11, true, nil + return []*proto.Message{{MessageId: 11}}, "Y3Vyc29yOjEx", true, nil }, }, ReferenceCache: &referencecache.Cache{}, } resp, err := svc.ListTicketMessages(withTicketsKey("read:tickets"), &proto.ListTicketMessagesRequest{ - TicketId: 7499, AfterPosition: 10, PerPage: 50, + TicketId: 7499, AfterCursor: "Y3Vyc29yOjEw", PerPage: 50, }) require.NoError(t, err) require.Len(t, resp.Messages, 1) - assert.Equal(t, uint32(11), resp.NextCursor) + assert.Equal(t, "Y3Vyc29yOjEx", resp.NextCursor) assert.True(t, resp.HasMore) } @@ -261,3 +261,22 @@ func TestListTickets_InvalidCursorMapsTo400(t *testing.T) { require.True(t, ok, "expected gRPC status error") assert.Equal(t, codes.InvalidArgument, st.Code()) } + +func TestListTicketMessages_InvalidCursorMapsTo400(t *testing.T) { + svc := &TicketsService{ + Datastore: &fakeDatastore{ + listMsgs: func(_ uint32, _ string, _ uint32, _ bool) ([]*proto.Message, string, bool, error) { + return nil, "", false, fmt.Errorf("decode: %w", datastores.ErrInvalidCursor) + }, + }, + ReferenceCache: &referencecache.Cache{}, + } + _, err := svc.ListTicketMessages(withTicketsKey("read:tickets"), &proto.ListTicketMessagesRequest{ + TicketId: 7499, + AfterCursor: "garbage", + }) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "expected gRPC status error") + assert.Equal(t, codes.InvalidArgument, st.Code()) +} diff --git a/third_party/OpenAPI/tickets.swagger.json b/third_party/OpenAPI/tickets.swagger.json index c91e6d0..9403665 100644 --- a/third_party/OpenAPI/tickets.swagger.json +++ b/third_party/OpenAPI/tickets.swagger.json @@ -273,11 +273,11 @@ "format": "int64" }, { - "name": "afterPosition", + "name": "afterCursor", + "description": "Opaque cursor; empty string = first page. Round-trip the next_cursor\nfrom the previous response. Pagination is by message position\nascending.", "in": "query", "required": false, - "type": "integer", - "format": "int64" + "type": "string" }, { "name": "includeHidden", @@ -339,7 +339,8 @@ }, "totalMessageCount": { "type": "integer", - "format": "int64" + "format": "int64", + "description": "DEPRECATED: use ticket.total_message_count. Removal target: v2.3.0." } } }, @@ -366,8 +367,8 @@ } }, "nextCursor": { - "type": "integer", - "format": "int64" + "type": "string", + "description": "Opaque cursor; empty when has_more=false." }, "hasMore": { "type": "boolean" @@ -544,6 +545,11 @@ "type": "integer", "format": "int64" }, + "totalMessageCount": { + "type": "integer", + "format": "int64", + "description": "Total messages in this ticket including the starter post. Equivalent\nto reply_count + 1." + }, "customFields": { "type": "object", "additionalProperties": { From 0e0db63d45e3d0ca9a5ff640edbb0ddbde2f2896 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sun, 17 May 2026 00:04:25 -0400 Subject: [PATCH 31/32] fix(tickets): tighten cursor decoders to reject trailing garbage fmt.Sscanf accepts a leading numeric prefix and silently discards the rest, so base64('42abc') decoded to (pos=42, nil) instead of erroring. Replaces both decodeCursor and decodeMessageCursor with strconv.ParseUint which is strict. Adds trailing-garbage test cases to both garbage suites and a position=0 round-trip test that pins the regression target from smoke ticket 6899 (the original reason for the opaque-cursor switch in the previous commit). --- datastores/tickets.go | 20 ++++++++++++++------ datastores/tickets_test.go | 12 ++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/datastores/tickets.go b/datastores/tickets.go index 781f979..bff2573 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -228,11 +228,19 @@ func decodeCursor(c string) (uint32, uint32, error) { if err != nil { return 0, 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } - var ts, id uint32 - if _, err := fmt.Sscanf(string(raw), "%d:%d", &ts, &id); err != nil { + parts := strings.Split(string(raw), ":") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("%w: expected ts:id, got %d parts", ErrInvalidCursor, len(parts)) + } + ts, err := strconv.ParseUint(parts[0], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) + } + id, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { return 0, 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } - return ts, id, nil + return uint32(ts), uint32(id), nil } // encodeMessageCursor encodes a ticket-message position into an opaque @@ -254,11 +262,11 @@ func decodeMessageCursor(c string) (uint32, error) { if err != nil { return 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } - var pos uint32 - if _, err := fmt.Sscanf(string(raw), "%d", &pos); err != nil { + pos, err := strconv.ParseUint(string(raw), 10, 32) + if err != nil { return 0, fmt.Errorf("%w: %v", ErrInvalidCursor, err) } - return pos, nil + return uint32(pos), nil } func (ds *Mysql) GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBase string) (*proto.Ticket, error) { diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index c7565de..cdc7a92 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -300,6 +300,8 @@ func TestDecodeCursor_GarbageReturnsErrInvalidCursor(t *testing.T) { {"base64 but not ts:id", base64URL("nope")}, {"only one int", base64URL("1234")}, {"empty string after base64 decode", base64URL("")}, + {"trailing garbage on ts", base64URL("100abc:5")}, + {"trailing garbage on id", base64URL("100:5junk")}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -344,6 +346,8 @@ func TestDecodeMessageCursor_GarbageReturnsErrInvalidCursor(t *testing.T) { cases := []string{ "garbage-not-base64", base64URL("notanumber"), + base64URL("42abc"), + base64URL(" 10"), } for _, in := range cases { t.Run(in, func(t *testing.T) { @@ -365,3 +369,11 @@ func TestGenerateTicketProto_TotalMessageCountDerived(t *testing.T) { assert.Equal(t, uint32(81), out.TotalMessageCount, "total_message_count must equal reply_count + 1 (starter post + replies)") } + +func TestEncodeDecodeMessageCursor_PositionZeroRoundTrips(t *testing.T) { + enc := encodeMessageCursor(0) + require.NotEmpty(t, enc, "position=0 must encode to a non-empty cursor (regression: smoke ticket 6899)") + pos, err := decodeMessageCursor(enc) + require.NoError(t, err) + assert.Equal(t, uint32(0), pos) +} From d9212d4ed35a6e02ead8f06c0b1d7df3950c1dfc Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Sun, 17 May 2026 00:21:06 -0400 Subject: [PATCH 32/32] fix(tickets): include position=0 in ListTicketMessages first page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-test on staging caught that the prior opaque-cursor fix only addressed the wire-format zero-value collapse; the underlying SQL boundary `position > afterPos` still excluded position=0 on the empty- cursor first page. Confirmed on ticket 6899 — paginated 80/81 again. - SQL boundary changed to `position >= ?` so afterPos=0 (empty cursor) includes the starter post. - Cursor semantic shifts: encodes the NEXT position to include (last_returned + 1) rather than the last position returned. Round- trip property unchanged; consumers treat the cursor as opaque. - Adds TestListTicketMessages_EmptyCursorReachesPositionZero pinning the regression target. --- datastores/tickets.go | 19 +++++++++++-------- datastores/tickets_test.go | 27 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/datastores/tickets.go b/datastores/tickets.go index bff2573..148ed01 100644 --- a/datastores/tickets.go +++ b/datastores/tickets.go @@ -243,17 +243,20 @@ func decodeCursor(c string) (uint32, uint32, error) { return uint32(ts), uint32(id), nil } -// encodeMessageCursor encodes a ticket-message position into an opaque -// base64 cursor. Position alone is sufficient because XF's -// xf_nf_tickets_message table guarantees (ticket_id, position) is unique -// per the message_id_position index. +// encodeMessageCursor encodes the next message position to include into an +// opaque base64 cursor. Position alone is sufficient because XF's +// xf_nf_tickets_message table guarantees (ticket_id, position) is unique per +// the message_id_position index. The encoded value is the position of the +// next message to return on the next page, NOT the last position returned — +// this lets empty-cursor map to "include position 0" naturally. func encodeMessageCursor(position uint32) string { return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", position))) } // decodeMessageCursor decodes an opaque message cursor back to a position. -// Empty string is a valid input meaning "start from the beginning"; any -// other malformed input returns an error wrapping ErrInvalidCursor. +// Empty string is a valid input meaning "start from the beginning" (returns +// 0, which combined with the SQL boundary `position >= 0` includes position 0). +// Any other malformed input returns an error wrapping ErrInvalidCursor. func decodeMessageCursor(c string) (uint32, error) { if c == "" { return 0, nil @@ -324,7 +327,7 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterC if err != nil { return nil, "", false, err } - q := ds.Db.WithContext(ctx).Where("ticket_id = ? AND position > ?", ticketID, afterPos) + q := ds.Db.WithContext(ctx).Where("ticket_id = ? AND position >= ?", ticketID, afterPos) if !includeHidden { q = q.Where("message_state = ?", "visible") } @@ -343,7 +346,7 @@ func (ds *Mysql) ListTicketMessages(ctx context.Context, ticketID uint32, afterC } var next string if hasMore && len(rows) > 0 { - next = encodeMessageCursor(rows[len(rows)-1].Position) + next = encodeMessageCursor(rows[len(rows)-1].Position + 1) } return out, next, hasMore, nil } diff --git a/datastores/tickets_test.go b/datastores/tickets_test.go index cdc7a92..34e041b 100644 --- a/datastores/tickets_test.go +++ b/datastores/tickets_test.go @@ -275,10 +275,35 @@ func TestListTicketMessages_Pagination(t *testing.T) { msgs, next, more, err := (&ds).ListTicketMessages(context.Background(), 1, encodeMessageCursor(10), 2, false) require.NoError(t, err) assert.True(t, more) - assert.Equal(t, encodeMessageCursor(12), next, "cursor is base64 of position of last returned message") + assert.Equal(t, encodeMessageCursor(13), next, "cursor is base64 of next position to include (last returned + 1)") assert.Len(t, msgs, 2) } +// TestListTicketMessages_EmptyCursorReachesPositionZero pins the regression +// target from smoke ticket 6899: empty cursor must yield SQL position >= 0 +// so the starter post (position=0) is returned on page one. Earlier the +// implementation used `position > 0` and silently excluded position=0. +func TestListTicketMessages_EmptyCursorReachesPositionZero(t *testing.T) { + ds, mock, cleanup := newMockDS(t) + defer cleanup() + rows := sqlmock.NewRows([]string{ + "message_id", "ticket_id", "user_id", "username", + "message_date", "message", "message_state", + "position", "attach_count", "last_edit_date", "edit_count", + }). + AddRow(uint32(1), uint32(1), uint32(0), "", uint32(0), "", "visible", uint32(0), uint32(0), uint32(0), uint32(0)). + AddRow(uint32(2), uint32(1), uint32(0), "", uint32(0), "", "visible", uint32(1), uint32(0), uint32(0), uint32(0)) + mock.ExpectQuery(regexp.QuoteMeta("FROM `xf_nf_tickets_message`")). + WithArgs(uint32(1), uint32(0), "visible", 51). + WillReturnRows(rows) + + msgs, _, more, err := (&ds).ListTicketMessages(context.Background(), 1, "", 0, false) + require.NoError(t, err) + assert.False(t, more) + require.Len(t, msgs, 2) + assert.Equal(t, uint32(0), msgs[0].Position, "position=0 must be present (regression: smoke ticket 6899)") +} + func TestListCategories_PassThroughFromCache(t *testing.T) { ds := Mysql{} rc := newFakeRefCache()