Repository: pennersr/shove
Branch: main
Commit: 8a31c25c40e0
Files: 39
Total size: 138.3 KB
Directory structure:
gitextract_y77ellyt/
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .gitlab-ci.yml
├── .woodpecker.yaml
├── LICENSE
├── README.md
├── cmd/
│ └── shove/
│ └── main.go
├── go.mod
├── go.sum
├── internal/
│ ├── queue/
│ │ ├── memory/
│ │ │ ├── message.go
│ │ │ └── queue.go
│ │ ├── queue.go
│ │ └── redis/
│ │ └── queue.go
│ ├── server/
│ │ ├── feedback.go
│ │ ├── metrics.go
│ │ ├── push.go
│ │ ├── server.go
│ │ └── worker.go
│ └── services/
│ ├── apns/
│ │ ├── apns.go
│ │ └── message.go
│ ├── email/
│ │ ├── encode.go
│ │ ├── encode_test.go
│ │ ├── message.go
│ │ ├── send.go
│ │ └── service.go
│ ├── fcm/
│ │ ├── fcm.go
│ │ └── message.go
│ ├── pump.go
│ ├── services.go
│ ├── squasher.go
│ ├── telegram/
│ │ ├── message.go
│ │ └── telegram.go
│ ├── webhook/
│ │ ├── message.go
│ │ └── webhook.go
│ └── webpush/
│ ├── message.go
│ ├── message_test.go
│ └── webpush.go
├── pkg/
│ └── shove/
│ └── client.go
└── scripts/
└── email.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: pennersr
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .gitignore
================================================
*~
/cmd/shove/shove
/shove
================================================
FILE: .gitlab-ci.yml
================================================
image: golang:1.20-bullseye
variables:
REPO_NAME: gitlab.com/pennersr/shove
# The problem is that to be able to use go get, one needs to put
# the repository in the $GOPATH. So for example if your gitlab domain
# is gitlab.com, and that your repository is namespace/project, and
# the default GOPATH being /go, then you'd need to have your
# repository in /go/src/gitlab.com/namespace/project
# Thus, making a symbolic link corrects this.
before_script:
- mkdir -p $GOPATH/src/$(dirname $REPO_NAME)
- ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME
- cd $GOPATH/src/$REPO_NAME
stages:
- test
- build
- deploy
format:
stage: test
script:
- go fmt $(go list ./... | grep -v /vendor/)
- go vet $(go list ./... | grep -v /vendor/)
- go test -race $(go list ./... | grep -v /vendor/)
compile:
stage: build
script:
- go build -race -ldflags "-extldflags '-static'" -o $CI_PROJECT_DIR/shove ./cmd/shove
artifacts:
paths:
- shove
================================================
FILE: .woodpecker.yaml
================================================
when:
- event: pull_request
- event: [push, tag, manual]
branch: main
steps:
build:
image: golang:1.21-bookworm
commands:
- go build -race -ldflags "-extldflags '-static'" -o $CI_PROJECT_DIR/shove ./cmd/shove
lint:
image: golang:1.21-bookworm
commands:
- go fmt $(go list ./... | grep -v /vendor/)
- go vet $(go list ./... | grep -v /vendor/)
test:
image: golang:1.21-bookworm
commands:
- go test -race $(go list ./... | grep -v /vendor/)
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2019 Raymond Penners and contributors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# When push comes to shove...
[](https://goreportcard.com/report/codeberg.org/pennersr/shove) [](https://www.gnu.org/software/emacs/) [](https://ci.codeberg.org/repos/13727)
## Background
This is the replacement for [Pulsus](https://github.com/pennersr/pulsus) which has been steadily serving up to 100M push notifications. But, given that it was still using the binary APNS protocol it was due for an upgrade.
## Overview
Design:
- Asynchronous: a push client can just fire & forget.
- Multiple workers per push service.
- Less moving parts: when using Redis, you can push directly to the queue, bypassing the need for the Shove server to be up and running.
Supported push services:
- APNS
- Email: supports automatic creation of email digests in case the rate limit
is exceeded
- FCM
- Telegram: supports squashing multiple messages into one in case the rate limit
is exceeded
- Webhook: issue arbitrary webhook posts
- Web Push
Features:
- Feedback: asynchronously receive information on invalid device tokens.
- Queueing: both in-memory and persistent via Redis.
- Exponential back-off in case of failure.
- Prometheus support.
- Squashing of messages in case rate limits are exceeded.
## Why?
- https://github.com/appleboy/gorush/issues/386#issuecomment-479191179
- https://github.com/mercari/gaurun/issues/115
## Usage
### Running
Usage:
$ shove -h
Usage of ./shove:
-api-addr string
API address to listen to (default ":8322")
-apns-certificate-path string
APNS certificate path
-apns-sandbox-certificate-path string
APNS sandbox certificate path
-apns-workers int
The number of workers pushing APNS messages (default 4)
-email-host string
Email host
-email-port int
Email port (default 25)
-email-rate-amount int
Email max. rate (amount)
-email-rate-per int
Email max. rate (per seconds)
-email-tls
Use TLS
-email-tls-insecure
Skip TLS verification
-fcm-credentials-file string
Path to FCM service account JSON file
-fcm-workers int
The number of workers pushing FCM messages (default 4)
-queue-redis string
Use Redis queue (Redis URL)
-telegram-bot-token string
Telegram bot token
-telegram-rate-amount int
Telegram max. rate (amount)
-telegram-rate-per int
Telegram max. rate (per seconds)
-telegram-workers int
The number of workers pushing Telegram messages (default 2)
-webhook-workers int
The number of workers pushing Webhook messages
-webpush-vapid-private-key string
VAPID public key
-webpush-vapid-public-key string
VAPID public key
-webpush-workers int
The number of workers pushing Web messages (default 8)
Start the server:
$ shove \
-api-addr localhost:8322 \
-queue-redis redis://redis:6379 \
-fcm-credentials-file /etc/shove/fcm/credentials.json \
-apns-certificate-path /etc/shove/apns/production/bundle.pem -apns-sandbox-certificate-path /etc/shove/apns/sandbox/bundle.pem \
-webpush-vapid-public-key=$VAPID_PUBLIC_KEY -webpush-vapid-private-key=$VAPID_PRIVATE_KEY \
-telegram-bot-token $TELEGRAM_BOT_TOKEN
### APNS
Push an APNS notification:
$ curl -i --data '{"service": "apns", "headers": {"apns-priority": 10, "apns-topic": "com.shove.app"}, "payload": {"aps": { "alert": "hi"}}, "token": "81b8ecff8cb6d22154404d43b9aeaaf6219dfbef2abb2fe313f3725f4505cb47"}' http://localhost:8322/api/push/apns
A successful push results in:
HTTP/1.1 202 Accepted
Date: Tue, 07 May 2019 19:00:15 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8
OK
### FCM
Push an FCM notification:
$ curl -i --data '{"message": {"notification": {"body": "Hello world!", "title": "Test"}, "token": "c7VmdNNHQaGTLkmi....15CmMs"}}' http://localhost:8322/api/push/fcm
### Webhook
Push a Webhook call, containing arbitrary body content:
$ curl -i --data '{"url": "http://localhost:8000/api/webhook", "headers": {"foo": "bar"}, "body": "Hello world!"}' http://localhost:8322/api/push/webhook
Or, post JSON:
$ curl -i --data '{"url": "http://localhost:8000/api/webhook", "headers": {"foo": "bar"}, "data": {"hello": "world!"}}' http://localhost:8322/api/push/webhook
### WebPush
Push a WebPush notification:
$ curl -i --data '{"subscription": {"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAAc4BA....UrjGlg","keys":{"auth":"Hbj3ap...al9ew","p256dh":"BeKdTC3...KLGBJlgF"}}, "headers": {"ttl": 3600, "urgency": "high"}, "token": "use-this-for-feedback-instead-of-subscription", "payload": {"hello":"world"}}' http://localhost:8322/api/push/webpush
The subscription (serialized as a JSON string) is used for receiving
feedback. Alternatively, you can specify an optional `token` parameter as done
in the example above.
### Telegram
Push a Telegram notification:
$ curl -i --data '{"method": "sendMessage", "payload": {"chat_id": "12345678", "text": "Hello!"}}' http://localhost:8322/api/push/telegram
Note that the Telegram Bot API documents `chat_id` as "Integer or String" --
Shove requires strings to be passed. For users that disconnected from your bot
the chat ID will be communicated back through the feedback mechanism. Here, the
token will equal the unreachable chat ID.
### Receive Feedback
Outdated/invalid tokens are communicated back. To receive those, you can periodically query the feedback channel to receive token feedback, and remove those from your database:
$ curl -X POST 'http://localhost:8322/api/feedback'
{
"feedback": [
{"service":"apns-sandbox",
"token":"881becff86cbd221544044d3b9aeaaf6314dfbef2abb2fe313f3725f4505cb47",
"reason":"invalid"}
]
}
### Email
In order to keep your SMTP server safe from being blacklisted, the email service
supports rate limitting. When the rate is exceeded, multiple mails are
automatically digested.
$ shove \
-email-host localhost \
-email-port 1025 \
-api-addr localhost:8322 \
-email-rate-amount 3 \
-email-rate-per 10 \
-queue-redis redis://localhost:6379
Push an email:
$ curl -i -X POST --data @./scripts/email.json http://localhost:8322/api/push/email
If you send too many emails, you'll notice that they are digested, and at a
later time, one digest mail is being sent:
2021/03/23 21:15:57 Using Redis queue at redis://localhost:6379
2021/03/23 21:15:57 Initializing Email service
2021/03/23 21:15:57 Serving on localhost:8322
2021/03/23 21:15:57 Shove server started
2021/03/23 21:15:57 email: Worker started
2021/03/23 21:15:57 email: Digester started
2021/03/23 21:15:58 email: Sending email
2021/03/23 21:15:59 email: Sending email
2021/03/23 21:15:59 email: Sending email
2021/03/23 21:16:00 email: Rate to john@doe.org exceeded, email digested
2021/03/23 21:16:12 email: Rate to john@doe.org exceeded, email digested
2021/03/23 21:16:18 email: Sending digest email
### Redis Queues
Shove is being used to push a high volume of notifications in a production
environment, consisting of various microservices interacting together. In such a
scenario, it is important that the various services are not too tightly coupled
to one another. For that purpose, Shove offers the ability to post
notifications directly to a Redis queue.
Posting directly to the Redis queue, instead of using the HTTP service
endpoints, has the advantage that you can take Shove offline without disturbing
the operation of the clients pushing the notifications.
Shove intentionally tries to make as little assumptions on the notification
payloads being pushed, as they are mostly handed over as is to the upstream
services. So, when using Shove this way, the client is responsible for handing
over a raw payload. Here's an example:
package main
import (
"encoding/json"
"codeberg.org/pennersr/shove/pkg/shove"
"log"
"os"
)
type FCMNotification struct {
To string `json:"to"`
Data map[string]string `json:"data,omitempty"`
}
func main() {
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
redis_URL = "redis://localhost:6379"
}
client := shove.NewRedisClient(redisURL)
notification := FCMNotification{
To: "token....",
Data: map[string]string{},
}
raw, err := json.Marshal(notification)
if err != nil {
log.Fatal(err)
}
err = client.PushRaw("fcm", raw)
if err != nil {
log.Fatal(err)
}
}
## Status
Used in production, over at:
- [Drakdoo: Indicator based signals & alerts](https://www.drakdoo.com): 365.251.428 alerts fired and counting.
================================================
FILE: cmd/shove/main.go
================================================
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/pennersr/shove/internal/queue"
"codeberg.org/pennersr/shove/internal/queue/memory"
"codeberg.org/pennersr/shove/internal/queue/redis"
"codeberg.org/pennersr/shove/internal/server"
"codeberg.org/pennersr/shove/internal/services"
"codeberg.org/pennersr/shove/internal/services/apns"
"codeberg.org/pennersr/shove/internal/services/email"
"codeberg.org/pennersr/shove/internal/services/fcm"
"codeberg.org/pennersr/shove/internal/services/telegram"
"codeberg.org/pennersr/shove/internal/services/webhook"
"codeberg.org/pennersr/shove/internal/services/webpush"
"golang.org/x/exp/slog"
)
var debug = flag.Bool("debug", false, "Enable debug logging")
var apiAddr = flag.String("api-addr", ":8322", "API address to listen to")
var apnsCertificate = flag.String("apns-certificate-path", "", "APNS certificate path")
var apnsSandboxCertificate = flag.String("apns-sandbox-certificate-path", "", "APNS sandbox certificate path")
var apnsWorkers = flag.Int("apns-workers", 4, "The number of workers pushing APNS messages")
var fcmCredentialsFile = flag.String("fcm-credentials-file", "", "FCM credentials file")
var fcmWorkers = flag.Int("fcm-workers", 4, "The number of workers pushing FCM messages")
var redisURL = flag.String("queue-redis", "", "Use Redis queue (Redis URL)")
var webhookWorkers = flag.Int("webhook-workers", 0, "The number of workers pushing Webhook messages")
var webPushVAPIDPublicKey = flag.String("webpush-vapid-public-key", "", "VAPID public key")
var webPushVAPIDPrivateKey = flag.String("webpush-vapid-private-key", "", "VAPID public key")
var webPushWorkers = flag.Int("webpush-workers", 8, "The number of workers pushing Web messages")
var telegramBotToken = flag.String("telegram-bot-token", "", "Telegram bot token")
var telegramWorkers = flag.Int("telegram-workers", 2, "The number of workers pushing Telegram messages")
var telegramRateAmount = flag.Int("telegram-rate-amount", 0, "Telegram max. rate (amount)")
var telegramRatePer = flag.Int("telegram-rate-per", 0, "Telegram max. rate (per seconds)")
var emailHost = flag.String("email-host", "", "Email host")
var emailPort = flag.Int("email-port", 25, "Email port")
var emailPlainAuth = flag.Bool("email-plain-auth", false, "Email plain auth(username and password)")
var emailUsername = flag.String("email-username", "", "Email username")
var emailPassword = flag.String("email-password", "", "Email password")
var emailTLS = flag.Bool("email-tls", false, "Use TLS")
var emailTLSInsecure = flag.Bool("email-tls-insecure", false, "Skip TLS verification")
var emailRateAmount = flag.Int("email-rate-amount", 0, "Email max. rate (amount)")
var emailRatePer = flag.Int("email-rate-per", 0, "Email max. rate (per seconds)")
func newLogger() *slog.Logger {
var opts *slog.HandlerOptions
if *debug {
opts = &slog.HandlerOptions{
Level: slog.LevelDebug,
}
}
logger := slog.New(slog.NewTextHandler(os.Stderr, opts))
return logger
}
func newServiceLogger(service string) *slog.Logger {
logger := newLogger()
return logger.With(
slog.String("service", service),
)
}
func main() {
flag.Parse()
logger := newLogger()
slog.SetDefault(logger)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
var qf queue.QueueFactory
if *redisURL == "" {
slog.Info("Using non-persistent in-memory queue")
qf = memory.MemoryQueueFactory{}
} else {
slog.Info("Using Redis queue at", "address", *redisURL)
qf = redis.NewQueueFactory(*redisURL)
}
s := server.NewServer(*apiAddr, qf)
if *apnsCertificate != "" {
apns, err := apns.NewAPNS(*apnsCertificate, true, newServiceLogger("apns"))
if err != nil {
slog.Error("Failed to setup APNS service", "error", err)
os.Exit(1)
}
if err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {
slog.Error("Failed to add APNS service", "error", err)
os.Exit(1)
}
}
if *apnsSandboxCertificate != "" {
apns, err := apns.NewAPNS(*apnsSandboxCertificate, false, newServiceLogger("apns-sandbox"))
if err != nil {
slog.Error("Failed to setup APNS sandbox service", "error", err)
os.Exit(1)
}
if err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {
slog.Error("Failed to add APNS sandbox service", "error", err)
os.Exit(1)
}
}
if *fcmCredentialsFile != "" {
fcm, err := fcm.NewFCM(*fcmCredentialsFile, newServiceLogger("fcm"))
if err != nil {
slog.Error("Failed to setup FCM service", "error", err)
os.Exit(1)
}
if err := s.AddService(fcm, *fcmWorkers, services.SquashConfig{}); err != nil {
slog.Error("Failed to add FCM service", "error", err)
os.Exit(1)
}
}
if *webhookWorkers > 0 {
wh, err := webhook.NewWebhook(newServiceLogger("webhook"))
if err != nil {
slog.Error("Failed to setup Webhook service", "error", err)
os.Exit(1)
}
if err := s.AddService(wh, *webhookWorkers, services.SquashConfig{}); err != nil {
slog.Error("Failed to add Webhook service", "error", err)
os.Exit(1)
}
}
if *webPushVAPIDPrivateKey != "" {
web, err := webpush.NewWebPush(*webPushVAPIDPublicKey, *webPushVAPIDPrivateKey, newServiceLogger("webpush"))
if err != nil {
slog.Error("Failed to setup WebPush service", "error", err)
os.Exit(1)
}
if err := s.AddService(web, *webPushWorkers, services.SquashConfig{}); err != nil {
slog.Error("Failed to add WebPush service", "error", err)
os.Exit(1)
}
}
if *telegramBotToken != "" {
tg, err := telegram.NewTelegramService(*telegramBotToken, newServiceLogger("telegram"))
if err != nil {
slog.Error("Failed to setup Telegram service", "error", err)
os.Exit(1)
}
if err := s.AddService(tg, *telegramWorkers, services.SquashConfig{
RateMax: *telegramRateAmount,
RatePer: time.Second * time.Duration(*telegramRatePer),
}); err != nil {
slog.Error("Failed to add Telegram service", "error", err)
os.Exit(1)
}
}
if *emailHost != "" {
config := email.EmailConfig{
EmailHost: *emailHost,
EmailPort: *emailPort,
TLS: *emailTLS,
TLSInsecure: *emailTLSInsecure,
Log: newServiceLogger("email"),
PlainAuth: *emailPlainAuth,
EmailUsername: *emailUsername,
EmailPassword: *emailPassword,
}
email, err := email.NewEmailService(config)
if err != nil {
slog.Error("Failed to setup email service", "error", err)
os.Exit(1)
}
if err := s.AddService(email, 1, services.SquashConfig{
RateMax: *emailRateAmount,
RatePer: time.Second * time.Duration(*emailRatePer),
}); err != nil {
slog.Error("Failed to add email service", "error", err)
os.Exit(1)
}
}
go func() {
slog.Info("Serving", "address", *apiAddr)
err := s.Serve()
if err != nil {
slog.Error("Serve failed", "error", err)
os.Exit(1)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.Shutdown(ctx)
slog.Info("Exiting")
}
================================================
FILE: go.mod
================================================
module codeberg.org/pennersr/shove
go 1.21
toolchain go1.22.6
require (
codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d
firebase.google.com/go v3.13.0+incompatible
github.com/SherClockHolmes/webpush-go v1.2.0
github.com/gomodule/redigo v2.0.0+incompatible
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/prometheus/client_golang v1.14.0
github.com/sideshow/apns2 v0.23.0
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
google.golang.org/api v0.189.0
)
require (
cloud.google.com/go v0.115.0 // indirect
cloud.google.com/go/auth v0.7.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
cloud.google.com/go/firestore v1.16.0 // indirect
cloud.google.com/go/iam v1.1.10 // indirect
cloud.google.com/go/longrunning v0.5.9 // indirect
cloud.google.com/go/storage v1.41.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect
google.golang.org/grpc v1.64.1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
================================================
FILE: go.sum
================================================
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.16.0 h1:YwmDHcyrxVRErWcgxunzEaZxtNbc8QoFYA/JOEwDPgc=
cloud.google.com/go/firestore v1.16.0/go.mod h1:+22v/7p+WNBSQwdSwP57vz47aZiY+HrDkrOsJNhk7rg=
cloud.google.com/go/iam v1.1.10 h1:ZSAr64oEhQSClwBL670MsJAW5/RLiC6kfw3Bqmd5ZDI=
cloud.google.com/go/iam v1.1.10/go.mod h1:iEgMq62sg8zx446GCaijmA2Miwg5o3UbO+nI47WHJps=
cloud.google.com/go/longrunning v0.5.9 h1:haH9pAuXdPAMqHvzX0zlWQigXT7B0+CL4/2nXXdBo5k=
cloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.41.0 h1:RusiwatSu6lHeEXe3kglxakAmAbfV+rhtPqA6i8RBx0=
cloud.google.com/go/storage v1.41.0/go.mod h1:J1WCa/Z2FcgdEDuPUY8DxT5I+d9mFKsCepp5vR6Sq80=
codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d h1:ltVUgmkwSzpb/l/MvUOj2UgHRwMVtnxh6XKipVMglUo=
codeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d/go.mod h1:hE2GlgcqKql1wcqp9wjN+2mCsECP8EQS/YbiCrWDxdQ=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
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.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
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.4.1/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.1/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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/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/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sideshow/apns2 v0.23.0 h1:lpkikaZ995GIcKk6AFsYzHyezCrsrfEDvUWcWkEGErY=
github.com/sideshow/apns2 v0.23.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
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/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=
google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=
google.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
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.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
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.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
================================================
FILE: internal/queue/memory/message.go
================================================
package memory
type memoryQueuedMessage struct {
msg []byte
pending bool
idx int
}
func (qm *memoryQueuedMessage) Message() []byte {
return qm.msg
}
================================================
FILE: internal/queue/memory/queue.go
================================================
package memory
import (
"codeberg.org/pennersr/shove/internal/queue"
"context"
"errors"
"sync"
)
// MemoryQueueFactory ...
type MemoryQueueFactory struct{}
type memoryQueue struct {
buf []*memoryQueuedMessage
lock sync.Mutex
cond *sync.Cond
shuttingDown bool
}
func (mq *memoryQueue) Queue(msg []byte) (err error) {
mq.lock.Lock()
qm := &memoryQueuedMessage{
msg: msg,
idx: -1,
}
for i := 0; i < len(mq.buf); i++ {
if mq.buf[i] == nil {
qm.idx = i
mq.buf[i] = qm
break
}
}
if qm.idx < 0 {
qm.idx = len(mq.buf)
mq.buf = append(mq.buf, qm)
}
mq.lock.Unlock()
mq.cond.Signal()
return nil
}
func (mq *memoryQueue) Shutdown() (err error) {
mq.lock.Lock()
mq.shuttingDown = true
mq.cond.Broadcast()
mq.lock.Unlock()
return
}
func (mq *memoryQueue) Remove(qm queue.QueuedMessage) (err error) {
mq.lock.Lock()
mqm := qm.(*memoryQueuedMessage)
mq.buf[mqm.idx] = nil
mq.lock.Unlock()
return nil
}
func (mq *memoryQueue) Requeue(qm queue.QueuedMessage) (err error) {
mq.lock.Lock()
mqm := qm.(*memoryQueuedMessage)
mqm.pending = false
mq.lock.Unlock()
mq.cond.Signal()
return
}
func (mq *memoryQueue) getNextMessage() *memoryQueuedMessage {
for i := 0; i < len(mq.buf); i++ {
m := mq.buf[i]
if m != nil && !m.pending {
m.pending = true
return m
}
}
return nil
}
func (mq *memoryQueue) Get(ctx context.Context) (queue.QueuedMessage, error) {
mq.cond.L.Lock()
defer mq.cond.L.Unlock()
for ctx.Err() == nil {
if mq.shuttingDown {
break
}
msg := mq.getNextMessage()
if msg == nil {
mq.cond.Wait()
continue
}
return msg, nil
}
return nil, errors.New("queue shut down")
}
// NewQueue ...
func (mqf MemoryQueueFactory) NewQueue(id string) (q queue.Queue, err error) {
mq := &memoryQueue{}
mq.cond = sync.NewCond(&mq.lock)
q = mq
return
}
================================================
FILE: internal/queue/queue.go
================================================
package queue
import (
"context"
)
// Queue ...
type Queue interface {
Queue([]byte) error
Get(ctx context.Context) (QueuedMessage, error)
Remove(QueuedMessage) error
Requeue(QueuedMessage) error
Shutdown() error
}
// QueuedMessage ...
type QueuedMessage interface {
Message() []byte
}
// QueueFactory ...
type QueueFactory interface {
NewQueue(id string) (Queue, error)
}
================================================
FILE: internal/queue/redis/queue.go
================================================
package redis
import (
"context"
"time"
"codeberg.org/pennersr/redq"
"codeberg.org/pennersr/shove/internal/queue"
"github.com/gomodule/redigo/redis"
)
type redisQueueFactory struct {
pool *redis.Pool
}
type redisQueue struct {
q *redq.RedQueue
}
// NewQueueFactory ...
func NewQueueFactory(url string) queue.QueueFactory {
qf := &redisQueueFactory{
pool: &redis.Pool{
MaxIdle: 3,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) {
return redis.DialURL(url)
},
},
}
return qf
}
func (rq redisQueue) Queue(msg []byte) (err error) {
return rq.q.Queue(msg)
}
func (rq redisQueue) Get(ctx context.Context) (qm queue.QueuedMessage, err error) {
qm, err = rq.q.Get(ctx)
return
}
func (rq redisQueue) Remove(qm queue.QueuedMessage) (err error) {
return rq.q.Remove(qm.(redq.QueuedMessage))
}
func (rq redisQueue) Requeue(qm queue.QueuedMessage) (err error) {
return rq.q.Requeue(qm.(redq.QueuedMessage))
}
func (rq redisQueue) Shutdown() (err error) {
return rq.q.Close()
}
func (rqf *redisQueueFactory) NewQueue(id string) (q queue.Queue, err error) {
waitingList := ListName(id)
rq, err := redq.NewQueue(rqf.pool, waitingList)
if err != nil {
return
}
q = redisQueue{q: rq}
return
}
// ListName returns the Redis list name used for queueing.
func ListName(serviceID string) string {
return "shove:" + serviceID
}
================================================
FILE: internal/server/feedback.go
================================================
package server
import (
"encoding/json"
"golang.org/x/exp/slog"
"net/http"
)
type tokenFeedback struct {
Service string `json:"service"`
Token string `json:"token"`
Replacement string `json:"replacement_token,omitempty"`
Reason string `json:"reason"`
}
func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Invalid request method.", 405)
return
}
s.feedbackLock.Lock()
j, err := json.Marshal(struct {
Feedback []tokenFeedback `json:"feedback"`
}{Feedback: s.feedback})
if err != nil {
s.feedbackLock.Unlock()
http.Error(w, err.Error(), 500)
return
}
s.feedback = make([]tokenFeedback, 0)
s.feedbackLock.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Write(j)
}
// TokenInvalid ...
func (s *Server) TokenInvalid(serviceID, token string) {
s.feedbackLock.Lock()
s.feedback = append(s.feedback, tokenFeedback{serviceID, token, "", "invalid"})
s.feedbackLock.Unlock()
slog.Info("Invalid token", "service", serviceID, "token", token)
}
// ReplaceToken ...
func (s *Server) ReplaceToken(serviceID, token, replacement string) {
s.feedbackLock.Lock()
s.feedback = append(s.feedback, tokenFeedback{serviceID, token, replacement, "replaced"})
s.feedbackLock.Unlock()
slog.Info("Token replaced", "service", serviceID)
}
================================================
FILE: internal/server/metrics.go
================================================
package server
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"time"
)
var (
pushSuccessCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "shove_push_success_total",
Help: "The total number of successful push notifications sent",
}, []string{
"service",
})
pushErrorCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "shove_push_error_total",
Help: "The total number of push notifications errored",
}, []string{
"service",
})
)
// CountPush ...
func (s *Server) CountPush(serviceID string, success bool, duration time.Duration) {
if success {
pushSuccessCounter.WithLabelValues(serviceID).Inc()
} else {
pushErrorCounter.WithLabelValues(serviceID).Inc()
}
}
================================================
FILE: internal/server/push.go
================================================
package server
import (
"fmt"
"io/ioutil"
"net/http"
"strings"
)
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
service := strings.TrimPrefix(r.URL.Path, "/api/push/")
wrk, ok := s.workers[service]
if !ok {
http.NotFound(w, r)
return
}
if r.Method != "POST" {
http.Error(w, "Invalid request method.", 405)
return
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = wrk.push(body)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusAccepted)
fmt.Fprintf(w, "OK")
}
================================================
FILE: internal/server/server.go
================================================
package server
import (
"codeberg.org/pennersr/shove/internal/queue"
"codeberg.org/pennersr/shove/internal/services"
"context"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/exp/slog"
"net/http"
"sync"
)
// Server ...
type Server struct {
server *http.Server
shuttingDown bool
queueFactory queue.QueueFactory
workers map[string]*worker
feedbackLock sync.Mutex
feedback []tokenFeedback
}
// NewServer ...
func NewServer(addr string, qf queue.QueueFactory) (s *Server) {
mux := http.NewServeMux()
h := &http.Server{
Addr: addr,
Handler: mux,
}
s = &Server{
server: h,
queueFactory: qf,
workers: make(map[string]*worker),
feedback: make([]tokenFeedback, 0),
}
mux.HandleFunc("/api/push/", s.handlePush)
mux.HandleFunc("/api/feedback", s.handleFeedback)
mux.Handle("/metrics", promhttp.Handler())
return s
}
// Serve ...
func (s *Server) Serve() (err error) {
slog.Info("Shove server started")
err = s.server.ListenAndServe()
if s.shuttingDown {
err = nil
}
return
}
// Shutdown ...
func (s *Server) Shutdown(ctx context.Context) (err error) {
s.shuttingDown = true
s.server.Shutdown(ctx)
if err = s.server.Shutdown(ctx); err != nil {
slog.Error("Shutting down Shove server", "error", err)
return
}
slog.Info("Shove server stopped")
for _, w := range s.workers {
err = w.shutdown()
if err != nil {
return
}
}
return
}
// AddService ...
func (s *Server) AddService(pp services.PushService, workers int, squash services.SquashConfig) (err error) {
slog.Info("Initializing service", "service", pp)
q, err := s.queueFactory.NewQueue(pp.ID())
if err != nil {
return
}
w, err := newWorker(pp, q)
if err != nil {
return
}
go w.serve(workers, squash, s)
s.workers[pp.ID()] = w
return
}
================================================
FILE: internal/server/worker.go
================================================
package server
import (
"codeberg.org/pennersr/shove/internal/queue"
"codeberg.org/pennersr/shove/internal/services"
"context"
"golang.org/x/exp/slog"
)
type worker struct {
queue queue.Queue
service services.PushService
ctx context.Context
cancel context.CancelFunc
finished chan (bool)
}
func newWorker(pp services.PushService, queue queue.Queue) (w *worker, err error) {
w = &worker{
queue: queue,
service: pp,
finished: make(chan bool),
}
w.ctx, w.cancel = context.WithCancel(context.Background())
return
}
func (w *worker) push(msg []byte) (err error) {
if err = w.service.Validate(msg); err != nil {
return
}
err = w.queue.Queue(msg)
return
}
func (w *worker) serve(workers int, squash services.SquashConfig, fc services.FeedbackCollector) {
pump := services.NewPump(workers, squash, w.service)
err := pump.Serve(w.ctx, w.queue, fc)
if err != nil {
slog.Error("Serve failed", "error", err)
}
w.finished <- true
}
func (w *worker) shutdown() (err error) {
if err = w.queue.Shutdown(); err != nil {
return
}
w.cancel()
<-w.finished
return
}
================================================
FILE: internal/services/apns/apns.go
================================================
package apns
import (
"codeberg.org/pennersr/shove/internal/services"
"crypto/tls"
"github.com/sideshow/apns2"
"github.com/sideshow/apns2/certificate"
"golang.org/x/exp/slog"
"time"
)
// APNS ...
type APNS struct {
production bool
log *slog.Logger
cert tls.Certificate
}
// NewAPNS ...
func NewAPNS(pemFile string, production bool, log *slog.Logger) (apns *APNS, err error) {
cert, err := certificate.FromPemFile(pemFile, "")
if err != nil {
return
}
apns = &APNS{
cert: cert,
production: production,
log: log,
}
return
}
func (apns *APNS) Logger() *slog.Logger {
return apns.log
}
func (apns *APNS) NewClient() (pclient services.PumpClient, err error) {
client := apns2.NewClient(apns.cert)
if apns.production {
client.Production()
} else {
client.Development()
}
pclient = client
return
}
// ID ...
func (apns *APNS) ID() string {
if apns.production {
return "apns"
}
return "apns-sandbox"
}
// String ...
func (apns *APNS) String() string {
if apns.production {
return "APNS"
}
return "APNS-sandbox"
}
func (apns *APNS) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
panic("not implemented")
}
func (apns *APNS) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
client := pclient.(*apns2.Client)
notif := smsg.(apnsNotification)
t := time.Now()
resp, err := client.Push(notif.notification)
duration := time.Now().Sub(t)
sent := false
if err != nil {
apns.log.Error("Push message failed", "error", err)
status = services.PushStatusTempFail
} else {
reason := resp.Reason
if reason == "" {
reason = "OK"
}
apns.log.Info("Pushed", "reason", reason, "duration", duration)
sent = resp.Sent()
if resp.Reason == apns2.ReasonBadDeviceToken || resp.Reason == apns2.ReasonUnregistered {
fc.TokenInvalid(apns.ID(), notif.notification.DeviceToken)
}
retry := resp.StatusCode >= 500
if sent {
status = services.PushStatusSuccess
} else if retry {
status = services.PushStatusTempFail
} else {
status = services.PushStatusHardFail
}
}
fc.CountPush(apns.ID(), sent, duration)
return
}
================================================
FILE: internal/services/apns/message.go
================================================
package apns
import (
"codeberg.org/pennersr/shove/internal/services"
"encoding/json"
"errors"
"github.com/sideshow/apns2"
"time"
)
type apnsMessage struct {
Token string `json:"token"`
Headers map[string]json.RawMessage `json:"headers,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type apnsNotification struct {
notification *apns2.Notification
}
func (notif apnsNotification) GetSquashKey() string {
panic("not implemented")
}
func (apns *APNS) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
var msg apnsMessage
if err = json.Unmarshal(data, &msg); err != nil {
return
}
if msg.Token == "" {
err = errors.New("token required")
return
}
notif := new(apns2.Notification)
notif.DeviceToken = msg.Token
topic, ok := msg.Headers["apns-topic"]
if !ok {
err = errors.New("APNS requires a topic")
return
}
err = json.Unmarshal(topic, ¬if.Topic)
if err != nil {
return
}
prio, ok := msg.Headers["apns-priority"]
if ok {
err = json.Unmarshal(prio, ¬if.Priority)
if err != nil {
return
}
}
collapse, ok := msg.Headers["apns-collapse-id"]
if ok {
err = json.Unmarshal(collapse, ¬if.CollapseID)
if err != nil {
return
}
}
exp, ok := msg.Headers["apns-expiration"]
if ok {
var epoch int64
err = json.Unmarshal(exp, &epoch)
if err != nil {
return
}
notif.Expiration = time.Unix(epoch, 0)
}
notif.Payload = msg.Payload
smsg = apnsNotification{notification: notif}
return
}
// Validate ...
func (apns *APNS) Validate(data []byte) (err error) {
_, err = apns.ConvertMessage(data)
return
}
================================================
FILE: internal/services/email/encode.go
================================================
package email
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/mail"
"net/textproto"
jwemail "github.com/jordan-wright/email"
)
func encodeSMTPAddress(s string) (string, error) {
addr, err := mail.ParseAddress(s)
if err != nil {
return "", err
}
return addr.Address, nil
}
func encodeSMTPAddresses(from string, to []string) (encFrom string, encTo []string, err error) {
encFrom, err = encodeSMTPAddress(from)
if err != nil {
return
}
encTo = make([]string, 0, len(to))
for _, t := range to {
t, err = encodeSMTPAddress(t)
if err != nil {
return
}
encTo = append(encTo, t)
}
return
}
func encodeAddress(s string) (string, error) {
addr, err := mail.ParseAddress(s)
if err != nil {
return "", err
}
return addr.String(), nil
}
func encodeRFC2047(s string) string {
return mime.QEncoding.Encode("UTF-8", s)
}
func encodeEmail(em email) ([]byte, error) {
e := jwemail.NewEmail()
e.From = em.From
e.To = em.To
e.Subject = em.Subject
e.Text = []byte(em.Text)
e.HTML = []byte(em.HTML)
for _, atm := range em.Attachments {
e.Attachments = append(e.Attachments, &jwemail.Attachment{
Filename: atm.Filename,
Content: atm.Content,
ContentType: atm.ContentType,
Header: textproto.MIMEHeader{},
})
}
return e.Bytes()
}
func encodeEmailDigest(emails []email) ([]byte, error) {
if len(emails) == 0 {
return nil, errors.New("no emails specified")
}
if len(emails) == 1 {
return encodeEmail(emails[0])
}
mixedContent := &bytes.Buffer{}
mixedWriter := multipart.NewWriter(mixedContent)
digWriter, err := nestedMultipart(mixedWriter, "multipart/digest")
if err != nil {
return nil, err
}
// Actual content alternatives (finally!)
for i, em := range emails {
filename := fmt.Sprintf("message-%d.eml", i+1)
s, _ := encodeEmail(em)
var childContent io.Writer
childContent, _ = digWriter.CreatePart(textproto.MIMEHeader{
"Content-Type": {fmt.Sprintf("message/rfc822; name=\"%s\"", filename)},
"Content-Disposition": {"inline; filename=" + filename},
})
childContent.Write([]byte(s))
}
digWriter.Close()
mixedWriter.Close()
headers := make(map[string]string)
headers["To"] = emails[0].To[0]
headers["From"] = emails[0].From
subject := emails[0].Digest.Subject
if subject == "" {
subject = emails[0].Subject
}
headers["Subject"] = subject
headers["Content-Type"] = "multipart/mixed; boundary=" + mixedWriter.Boundary()
headers["MIME-Version"] = "1.0"
var out bytes.Buffer
err = encodeHeaders(&out, headers)
if err != nil {
return nil, err
}
out.WriteString(mixedContent.String())
return out.Bytes(), nil
}
func encodeHeaders(out *bytes.Buffer, headers map[string]string) error {
for k, v := range headers {
out.WriteString(k)
out.WriteString(": ")
if k == "To" || k == "From" {
var err error
v, err = encodeAddress(v)
if err != nil {
return err
}
} else if k == "Subject" {
v = encodeRFC2047(v)
}
out.WriteString(v)
out.WriteString("\r\n")
}
out.WriteString("\r\n")
return nil
}
func nestedMultipart(enclosingWriter *multipart.Writer, contentType string) (nestedWriter *multipart.Writer, err error) {
boundary, err := randomBoundary()
if err != nil {
return
}
contentWithBoundary := contentType + "; boundary=\"" + boundary + "\""
contentBuffer, err := enclosingWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {contentWithBoundary}})
if err != nil {
return
}
nestedWriter = multipart.NewWriter(contentBuffer)
nestedWriter.SetBoundary(boundary)
return
}
func randomBoundary() (string, error) {
var buf [30]byte
_, err := io.ReadFull(rand.Reader, buf[:])
if err != nil {
return "", err
}
return fmt.Sprintf("%x", buf[:]), nil
}
================================================
FILE: internal/services/email/encode_test.go
================================================
package email
import (
"testing"
)
var png1Pixel = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x37, 0x6e, 0xf9,
0x24, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x60, 0x01, 0x00, 0x00,
0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x06, 0x00, 0x05, 0x57, 0xbf, 0xab, 0xd4, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
func TestEncodeEmailDigest(t *testing.T) {
emails := []email{
{
Subject: "Hello Jane!",
From: "john@doe.org",
To: []string{"jane@doe.org"},
Text: "Hello Jane!\n\nBye\n",
HTML: "<p>Hello <b>Jane!</b></p>",
Attachments: []attachment{
{
Filename: "px1.png",
ContentType: "image/png",
Content: png1Pixel,
},
},
},
{
Subject: "One last thing...!",
From: "john@doe.org",
To: []string{"jane@doe.org"},
Text: "Don't forget the milk...",
HTML: "<p>Don't forget the <b>milk</b></p>",
Attachments: []attachment{
{
Filename: "px1.png",
ContentType: "image/png",
Content: png1Pixel,
},
},
},
}
out, err := encodeEmailDigest(emails)
if err != nil {
t.Fatal(err)
}
t.Log(string(out))
}
func TestEncodeEmail(t *testing.T) {
e := email{
Subject: "Hello!",
From: "john@doe.org",
To: []string{"jane@doe.org"},
Text: "Hello\n\nBye\n",
HTML: "<p>Hello <b>world</b></p>",
Attachments: []attachment{
{
Filename: "px1.png",
ContentType: "image/png",
Content: png1Pixel,
},
},
}
out, err := encodeEmail(e)
if err != nil {
t.Fatal(err)
}
t.Log(string(out))
}
func TestEncodeSMTPAddresses(t *testing.T) {
from, to, err := encodeSMTPAddresses("John <john@doe.org>", []string{"jane@doe.org", "Nobody <noreply@mail.org>"})
if err != nil {
t.Fatal(err)
}
if from != "john@doe.org" {
t.Fatal(from)
}
if to[0] != "jane@doe.org" {
t.Fatal(to[0])
}
if to[1] != "noreply@mail.org" {
t.Fatal(to[0])
}
}
func TestEncodeSMTPAddressesErrors(t *testing.T) {
_, _, err := encodeSMTPAddresses("John <john@doe.org", []string{"jane@doe.org", "Nobody <noreply@mail.org>"})
if err == nil {
t.Fail()
}
_, _, err = encodeSMTPAddresses("John <john@doe.org>", []string{"<jane@doe.org", "Nobody <noreply@mail.org>"})
if err == nil {
t.Fail()
}
}
================================================
FILE: internal/services/email/message.go
================================================
package email
import (
"codeberg.org/pennersr/shove/internal/services"
"encoding/json"
"errors"
)
type attachment struct {
Filename string `json:"filename"`
ContentType string `json:"content-type"`
Content []byte `json:"content"`
}
type email struct {
Subject string `json:"subject"`
To []string `json:"to"`
From string `json:"from"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments []attachment `json:"attachments"`
Digest struct {
Subject string `json:"subject"`
} `json:"digest"`
}
func (em email) GetSquashKey() string {
return em.To[0]
}
func (es *EmailService) ConvertMessage(data []byte) (services.ServiceMessage, error) {
var em email
if err := json.Unmarshal(data, &em); err != nil {
return em, err
}
if len(em.To) == 0 {
return em, errors.New("missing: `to`")
}
if len(em.To) != 1 {
return em, errors.New("only one `to` is supported")
}
if len(em.From) == 0 {
return em, errors.New("missing: `from`")
}
if len(em.Subject) == 0 {
return em, errors.New("missing: `subject`")
}
return em, nil
}
func (es *EmailService) Validate(data []byte) error {
_, err := es.ConvertMessage(data)
return err
}
================================================
FILE: internal/services/email/send.go
================================================
package email
import (
"crypto/tls"
"fmt"
"net/smtp"
"time"
"codeberg.org/pennersr/shove/internal/services"
)
func (ec EmailConfig) send(from string, to []string, body []byte, fc services.FeedbackCollector) error {
t := time.Now()
addr := fmt.Sprintf("%s:%d", ec.EmailHost, ec.EmailPort)
var auth smtp.Auth
// Only use it with TLS because net/smtp throws an error when tls is not enabled when using plain auth
if ec.PlainAuth && ec.TLS {
auth = smtp.PlainAuth("", ec.EmailUsername, ec.EmailPassword, ec.EmailHost)
}
var err error
from, to, err = encodeSMTPAddresses(from, to)
if err == nil {
if !ec.TLS {
err = smtp.SendMail(addr, auth, from, to, body)
} else {
err = ec.sendMailTLS(addr, auth, from, to, body)
}
}
duration := time.Since(t)
fc.CountPush(serviceID, err == nil, duration)
if err != nil {
ec.Log.Error("Send failed", "error", err)
return err
}
return nil
}
func (ec EmailConfig) sendMailTLS(addr string, auth smtp.Auth, from string, to []string, body []byte) error {
var t *tls.Config
if ec.TLSInsecure {
t = &tls.Config{InsecureSkipVerify: true}
}
c, err := smtp.Dial(addr)
if err != nil {
return err
}
defer c.Close()
if err = c.Hello("localhost"); err != nil {
return err
}
// Use TLS if available
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(t); err != nil {
return err
}
}
if auth != nil {
if ok, _ := c.Extension("AUTH"); ok {
if err = c.Auth(auth); err != nil {
return err
}
}
}
if err = c.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err = c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
_, err = w.Write(body)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return c.Quit()
}
================================================
FILE: internal/services/email/service.go
================================================
package email
import (
"golang.org/x/exp/slog"
"codeberg.org/pennersr/shove/internal/services"
)
const serviceID = "email"
type EmailConfig struct {
EmailHost string
EmailPort int
Log *slog.Logger
TLS bool
TLSInsecure bool
PlainAuth bool
EmailUsername string
EmailPassword string
}
type EmailService struct {
config EmailConfig
}
func NewEmailService(config EmailConfig) (es *EmailService, err error) {
es = &EmailService{
config: config,
}
return
}
func (es *EmailService) Logger() *slog.Logger {
return es.config.Log
}
func (es *EmailService) ID() string {
return serviceID
}
func (es *EmailService) String() string {
return "Email"
}
func (es *EmailService) NewClient() (services.PumpClient, error) {
return nil, nil
}
func (es *EmailService) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
emails := make([]email, len(smsgs))
for i, smsg := range smsgs {
emails[i] = smsg.(email)
}
body, err := encodeEmailDigest(emails)
if err != nil {
return services.PushStatusHardFail
}
return es.push(emails[0].From, emails[0].To, body, fc)
}
func (es *EmailService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
email := smsg.(email)
es.config.Log.Info("Sending email")
body, err := encodeEmail(email)
if err != nil {
return services.PushStatusHardFail
}
return es.push(email.From, email.To, body, fc)
}
func (es *EmailService) push(from string, to []string, body []byte, fc services.FeedbackCollector) services.PushStatus {
err := es.config.send(from, to, body, fc)
if err != nil {
es.config.Log.Error("Failed to send email", "error", err)
return services.PushStatusHardFail // TODO: smtp down is not a hard failure
}
return services.PushStatusSuccess
}
================================================
FILE: internal/services/fcm/fcm.go
================================================
package fcm
import (
"codeberg.org/pennersr/shove/internal/services"
"context"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"golang.org/x/exp/slog"
"google.golang.org/api/option"
"strings"
"time"
)
// FCM ...
type FCM struct {
credentialsFile string
log *slog.Logger
}
// NewFCM ...
func NewFCM(credentialsFile string, log *slog.Logger) (fcm *FCM, err error) {
fcm = &FCM{
credentialsFile: credentialsFile,
log: log,
}
return
}
func (fcm *FCM) Logger() *slog.Logger {
return fcm.log
}
// ID ...
func (fcm *FCM) ID() string {
return "fcm"
}
// String ...
func (fcm *FCM) String() string {
return "FCM"
}
func (fcm *FCM) NewClient() (services.PumpClient, error) {
opt := option.WithCredentialsFile(fcm.credentialsFile)
ctx := context.Background()
app, err := firebase.NewApp(ctx, nil, opt)
if err != nil {
return nil, err
}
client, err := app.Messaging(ctx)
if err != nil {
return nil, err
}
return client, nil
}
func (fcm *FCM) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {
panic("not implemented")
}
func (fcm *FCM) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
msg := smsg.(fcmMessage)
startedAt := time.Now()
var success bool
client := pclient.(*messaging.Client)
_, err := client.Send(context.Background(), msg.Message)
duration := time.Now().Sub(startedAt)
defer func() {
fc.CountPush(fcm.ID(), success, duration)
}()
fcm.log.Info("Pushed", "duration", duration)
if err != nil {
// TODO: Isn't there a better way?
if strings.Contains(err.Error(), "registration-token-not-registered") {
fc.TokenInvalid(fcm.ID(), msg.Message.Token)
} else {
fcm.log.Error("Posting failed", "error", err)
}
return services.PushStatusHardFail
}
success = true
return services.PushStatusSuccess
}
================================================
FILE: internal/services/fcm/message.go
================================================
package fcm
import (
"codeberg.org/pennersr/shove/internal/services"
"encoding/json"
"errors"
"firebase.google.com/go/messaging"
)
type fcmMessage struct {
Message *messaging.Message `json:"message"`
}
func (fcmMessage) GetSquashKey() string {
panic("not implemented")
}
func (fcm *FCM) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
var msg fcmMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil, err
}
if msg.Message == nil {
return nil, errors.New("message key missing")
}
if msg.Message.Token == "" {
return nil, errors.New("no token specified")
}
return msg, nil
}
// Validate ...
func (fcm *FCM) Validate(data []byte) error {
_, err := fcm.ConvertMessage(data)
return err
}
================================================
FILE: internal/services/pump.go
================================================
package services
import (
"context"
"golang.org/x/exp/slog"
"math"
"sync"
"time"
"codeberg.org/pennersr/shove/internal/queue"
)
type Pump struct {
wg sync.WaitGroup
adapter PumpAdapter
workers int
squasher *squasher
}
type ServiceMessage interface {
GetSquashKey() string
}
type PushStatus int
const (
// PushStatusSuccess ...
PushStatusSuccess PushStatus = iota
// PushStatusTempFail signals a failure that may be resolved by retrying
PushStatusTempFail
// PushStatusHardFail signals a failure for which a retry would not hekp
PushStatusHardFail
)
type PumpClient interface {
}
type PumpAdapter interface {
ConvertMessage([]byte) (ServiceMessage, error)
NewClient() (PumpClient, error)
PushMessage(client PumpClient, smsg ServiceMessage, fc FeedbackCollector) PushStatus
SquashAndPushMessage(client PumpClient, smsgs []ServiceMessage, fc FeedbackCollector) PushStatus
Logger() *slog.Logger
}
// NewPump
func NewPump(workers int, squash SquashConfig, adapter PumpAdapter) (p *Pump) {
p = &Pump{
workers: workers,
adapter: adapter,
}
if squash.RateMax > 0 {
p.squasher = newSquasher(squash, adapter)
}
return p
}
func (p *Pump) push(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage, fc FeedbackCollector) (status PushStatus, squashed bool) {
if p.squasher != nil {
squashed = p.squasher.prepareToPush(q, qm, client, smsg)
if squashed {
return
}
}
status = p.adapter.PushMessage(client, smsg, fc)
return
}
func (p *Pump) serveClient(ctx context.Context, q queue.Queue, client PumpClient, fc FeedbackCollector) {
defer func() {
p.wg.Done()
}()
failureCount := 0
log := p.adapter.Logger()
for ctx.Err() == nil {
qm, err := q.Get(ctx)
if err != nil {
slog.Error("Unable to read from queue", "error", err)
return
}
msg := qm.Message()
smsg, err := p.adapter.ConvertMessage(msg)
if err != nil {
slog.Error("Bad message", "error", err)
removeFromQueue(q, qm, log)
continue
}
status, squashed := p.push(q, qm, client, smsg, fc)
if squashed {
// Message should remain in pending queue
continue
}
if status == PushStatusSuccess || status == PushStatusHardFail {
removeFromQueue(q, qm, log)
} else {
if err = q.Requeue(qm); err != nil {
slog.Error("Unable to requeue", "error", err)
}
}
if status == PushStatusTempFail {
p.backoff(ctx, failureCount)
failureCount++
} else {
failureCount = 0
}
}
}
func removeFromQueue(q queue.Queue, qm queue.QueuedMessage, log *slog.Logger) {
if err := q.Remove(qm); err != nil {
slog.Error("Unable to remove from the queue", "error", err)
}
}
func (p *Pump) backoff(ctx context.Context, failureCount int) {
sleep := time.Duration(float64(time.Second) * math.Min(30, math.Pow(2., float64(failureCount))))
p.adapter.Logger().Info("Backing off", "duration", sleep)
ctx, cancel := context.WithTimeout(ctx, sleep)
defer cancel()
<-ctx.Done()
}
func (p *Pump) Serve(ctx context.Context, q queue.Queue, fc FeedbackCollector) (err error) {
log := p.adapter.Logger()
if p.squasher != nil {
p.wg.Add(1)
go func() {
log.Info("Squasher started")
p.squasher.serve(fc)
log.Info("Squasher stopped")
p.wg.Add(-1)
}()
}
clients := make([]PumpClient, p.workers)
for i := 0; i < p.workers; i++ {
clients[i], err = p.adapter.NewClient()
if err != nil {
return
}
}
for i := 0; i < p.workers; i++ {
go func(client PumpClient) {
p.serveClient(ctx, q, client, fc)
if p.squasher != nil {
p.squasher.requestShutdown()
}
}(clients[i])
p.wg.Add(1)
}
slog.Info("Workers started", "worker_count", p.workers)
p.wg.Wait()
slog.Info("Workers stopped")
return
}
================================================
FILE: internal/services/services.go
================================================
package services
import (
"fmt"
"time"
)
// FeedbackCollector ...
type FeedbackCollector interface {
TokenInvalid(serviceID, token string)
ReplaceToken(serviceID, token, replacement string)
CountPush(serviceID string, success bool, duration time.Duration)
}
// PushService ...
type PushService interface {
PumpAdapter
fmt.Stringer
ID() string
Validate([]byte) error
}
================================================
FILE: internal/services/squasher.go
================================================
package services
import (
"sync"
"time"
"codeberg.org/pennersr/shove/internal/queue"
)
type batch struct {
key string
serviceMsgs []ServiceMessage
due time.Time
queuedMsgs []queue.QueuedMessage
q queue.Queue
client PumpClient
}
type SquashConfig struct {
RateMax int
RatePer time.Duration
}
type squasher struct {
pushedAt map[string][]time.Time
batches map[string]batch
config SquashConfig
cond *sync.Cond
lock sync.Mutex
shuttingDown bool
adapter PumpAdapter
}
func newSquasher(config SquashConfig, adapter PumpAdapter) (d *squasher) {
d = new(squasher)
d.adapter = adapter
d.config = config
d.pushedAt = make(map[string][]time.Time)
d.batches = make(map[string]batch)
d.cond = sync.NewCond(&d.lock)
return d
}
func (d *squasher) flushAndGetRate(key string) (sendCount int, sentAt time.Time) {
var flushedTimes []time.Time
times := d.pushedAt[key]
var didFlush = false
for _, t := range times {
if time.Since(t) > d.config.RatePer {
didFlush = true
continue
}
flushedTimes = append(flushedTimes, t)
sendCount++
}
if didFlush {
d.pushedAt[key] = flushedTimes
}
if len(flushedTimes) > 0 {
sentAt = flushedTimes[0]
}
return
}
func (d *squasher) recordPush(key string) {
times := d.pushedAt[key]
times = append(times, time.Now())
d.pushedAt[key] = times
}
func (d *squasher) prepareToPush(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage) (squashed bool) {
d.cond.L.Lock()
defer d.cond.L.Unlock()
key := smsg.GetSquashKey()
sendCount, firstSendAt := d.flushAndGetRate(key)
if sendCount < d.config.RateMax {
d.recordPush(key)
return false
}
d.adapter.Logger().Info("Rate exceeded, squashed", "destination", key)
batch, ok := d.batches[key]
if ok {
if batch.q != q {
panic("squasher cannot handle mixed queues")
}
} else {
batch.q = q
batch.client = client
}
batch.key = key
batch.serviceMsgs = append(batch.serviceMsgs, smsg)
batch.queuedMsgs = append(batch.queuedMsgs, qm)
batch.due = firstSendAt.Add(d.config.RatePer)
d.batches[key] = batch
d.cond.Signal()
return true
}
func (d *squasher) getNextBatch() (b batch, stopped bool) {
for {
d.cond.L.Lock()
if len(d.batches) == 0 {
d.cond.Wait()
}
if d.shuttingDown {
d.cond.L.Unlock()
stopped = true
return
}
var minDueBatch batch
var minDueBatchKey string
for key, batch := range d.batches {
if minDueBatch.due.IsZero() || minDueBatch.due.After(batch.due) {
minDueBatch = batch
minDueBatchKey = key
}
}
now := time.Now()
if now.After(minDueBatch.due) {
delete(d.batches, minDueBatchKey)
d.cond.L.Unlock()
return minDueBatch, false
}
d.cond.L.Unlock()
zzz := minDueBatch.due.Sub(now)
maxZzz := time.Millisecond * 500
if zzz > maxZzz {
zzz = maxZzz
}
time.Sleep(zzz)
}
}
func (d *squasher) requestShutdown() {
d.cond.L.Lock()
d.shuttingDown = true
d.cond.Signal()
d.cond.L.Unlock()
}
func (d *squasher) shutdown() {
d.cond.L.Lock()
defer d.cond.L.Unlock()
d.adapter.Logger().Info("Shutting down squasher", "unsent_batch_count", len(d.batches))
}
func (d *squasher) serve(fc FeedbackCollector) {
for {
batch, stopped := d.getNextBatch()
if stopped {
d.shutdown()
return
}
d.sendBatch(batch, fc)
}
}
func (d *squasher) sendBatch(b batch, fc FeedbackCollector) {
d.adapter.Logger().Info("Sending batch", "batch_size", len(b.serviceMsgs))
d.cond.L.Lock()
d.recordPush(b.key)
d.cond.L.Unlock()
status := d.adapter.SquashAndPushMessage(b.client, b.serviceMsgs, fc)
switch status {
case PushStatusTempFail:
// TODO: We should actually attempt to retry this with a backoff
fallthrough
case PushStatusHardFail:
d.adapter.Logger().Error("Failed to send batch")
fallthrough
case PushStatusSuccess:
for _, qm := range b.queuedMsgs {
removeFromQueue(b.q, qm, d.adapter.Logger())
}
}
}
================================================
FILE: internal/services/telegram/message.go
================================================
package telegram
import (
"encoding/json"
"errors"
"fmt"
"strings"
"codeberg.org/pennersr/shove/internal/services"
)
type telegramMessage struct {
Method string `json:"method"`
// This is intentionally kept as a raw message so that this can be fed 1:1
// to the API.
Payload json.RawMessage `json:"payload"`
parsedPayload telegramPayload
}
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text,omitempty"`
Caption string `json:"caption,omitempty"`
Photo string `json:"photo,omitempty"`
}
func (msg telegramMessage) GetSquashKey() string {
// TODO: This should include method (`sendMessage`)
return msg.parsedPayload.ChatID
}
func (tg *TelegramService) ConvertMessage(data []byte) (services.ServiceMessage, error) {
var msg telegramMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil, err
}
if !strings.HasPrefix(msg.Method, "send") {
return nil, fmt.Errorf("invalid method: %s", msg.Method)
}
// Telegram documents the chat_id as: "Integer or String", we're assuming string.
if err := json.Unmarshal(msg.Payload, &msg.parsedPayload); err != nil {
return nil, err
}
if msg.parsedPayload.ChatID == "" {
return nil, errors.New("missing `chat_id`")
}
return msg, nil
}
// Validate ...
func (tg *TelegramService) Validate(data []byte) error {
_, err := tg.ConvertMessage(data)
return err
}
func concatText(builder *strings.Builder, text string) {
text = strings.TrimSpace(text)
if len(text) == 0 {
return
}
texts := builder.String()
if len(texts) == 0 || strings.HasSuffix(texts, "\n\n") {
// No newlines needed
} else if strings.HasSuffix(texts, "\n") {
builder.WriteString("\n")
} else {
builder.WriteString("\n\n")
}
builder.WriteString(text)
}
func trimString(input string, maxLength int) string {
if len(input) <= maxLength {
return input
}
trimLength := maxLength - 3
if trimLength < 0 {
trimLength = 0
}
return input[:trimLength] + "..."
}
func squashMessages(msgs []telegramMessage) (dmsg telegramMessage, err error) {
if len(msgs) == 0 {
err = errors.New("need at least one message to digest")
return
}
dmsg = msgs[0]
var texts strings.Builder
var captions strings.Builder
for _, msg := range msgs {
if msg.Method != dmsg.Method {
err = errors.New("cannot digest mix of methods")
return
}
if msg.parsedPayload.ChatID != dmsg.parsedPayload.ChatID {
err = errors.New("different `chat_id` seen while digesting")
return
}
concatText(&texts, msg.parsedPayload.Text)
concatText(&captions, msg.parsedPayload.Caption)
}
dmsg.parsedPayload.Text = trimString(texts.String(), 4095)
dmsg.parsedPayload.Caption = trimString(captions.String(), 1023)
dmsg.Payload, err = json.Marshal(&dmsg.parsedPayload)
return
}
================================================
FILE: internal/services/telegram/telegram.go
================================================
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"golang.org/x/exp/slog"
"net/http"
"strings"
"time"
"codeberg.org/pennersr/shove/internal/services"
)
// TelegramService ...
type TelegramService struct {
botToken string
log *slog.Logger
}
// NewTelegramService ...
func NewTelegramService(botToken string, log *slog.Logger) (tg *TelegramService, err error) {
tg = &TelegramService{
botToken: botToken,
log: log,
}
return
}
func (tg *TelegramService) Logger() *slog.Logger {
return tg.log
}
// ID ...
func (tg *TelegramService) ID() string {
return "telegram"
}
// String ...
func (tg *TelegramService) String() string {
return "Telegram"
}
func (tg *TelegramService) NewClient() (services.PumpClient, error) {
client := &http.Client{
Timeout: time.Duration(15 * time.Second),
}
return client, nil
}
func (tg *TelegramService) SquashAndPushMessage(pclient services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
client := pclient.(*http.Client)
msgs := make([]telegramMessage, len(smsgs))
for i, smsg := range smsgs {
msgs[i] = smsg.(telegramMessage)
}
dmsg, err := squashMessages(msgs)
if err != nil {
tg.log.Error("Squashing failed", "error", err)
return services.PushStatusHardFail
}
return tg.pushMessage(client, dmsg.Method, dmsg.parsedPayload.ChatID, dmsg.Payload, fc)
}
func (tg *TelegramService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {
client := pclient.(*http.Client)
msg := smsg.(telegramMessage)
return tg.pushMessage(client, msg.Method, msg.parsedPayload.ChatID, msg.Payload, fc)
}
func (tg *TelegramService) pushMessage(client *http.Client, method string, chatID string, payload json.RawMessage, fc services.FeedbackCollector) (status services.PushStatus) {
startedAt := time.Now()
var success bool
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", tg.botToken, method)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
if err != nil {
tg.log.Error("Failure creating request", "error", err)
return services.PushStatusHardFail
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
tg.log.Error("Posting failed", "error", err)
return services.PushStatusTempFail
}
duration := time.Now().Sub(startedAt)
defer func() {
fc.CountPush(tg.ID(), success, duration)
}()
defer resp.Body.Close()
if resp.StatusCode == 429 {
tg.log.Error("Throttled, too many requests", "status", 429)
return services.PushStatusTempFail
}
var respData struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
err = json.NewDecoder(resp.Body).Decode(&respData)
if err != nil {
tg.log.Error("Unable to decode response", "error", err)
return services.PushStatusTempFail
}
// It's a bit odd that an invalid chat ID results in a 400 instead of a
// special response code {"ok":false,"error_code":400,"description":"Bad
// Request: chat not found"}
if respData.ErrorCode == 400 && strings.Contains(respData.Description, "chat not found") {
fc.TokenInvalid(tg.ID(), chatID)
}
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
tg.log.Error("Rejected", "description", respData.Description, "error_code", respData.ErrorCode, "status", resp.StatusCode)
return services.PushStatusHardFail
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
tg.log.Error("Upstream failure", "status", resp.StatusCode)
return services.PushStatusTempFail
}
tg.log.Info("Pushed", "duration", duration)
return services.PushStatusSuccess
}
================================================
FILE: internal/services/webhook/message.go
================================================
package webhook
import (
"encoding/json"
"errors"
"net/url"
"codeberg.org/pennersr/shove/internal/services"
)
type webhookMessage struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
Data json.RawMessage `json:"data"`
postData []byte
rawData []byte
}
func (webhookMessage) GetSquashKey() string {
panic("not implemented")
}
func (wh *Webhook) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {
var msg webhookMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil, err
}
if _, err := url.ParseRequestURI(msg.URL); err != nil {
return nil, err
}
if len(msg.Body) > 0 && len(msg.Data) > 0 {
return nil, errors.New("either body or data expected")
}
if len(msg.Data) > 0 {
msg.postData = []byte(msg.Data)
if msg.Headers == nil {
msg.Headers = make(map[string]string)
}
msg.Headers["content-type"] = "application/json"
} else if len(msg.Body) > 0 {
msg.postData = []byte(msg.Body)
}
msg.rawData = data
return msg, nil
}
// Validate ...
func (wh *Webhook) Validate(data []byte) error {
_, err := wh.ConvertMessage(data)
return err
}
================================================
FILE: internal/services/webhook/webhook.go
================================================
package webhook
import (
"bytes"
"io/ioutil"
"net/http"
"time"
"codeberg.org/pennersr/shove/internal/services"
"golang.org/x/exp/slog"
)
type Webhook struct {
log *slog.Logger
}
func NewWebhook(log *slog.Logger) (fcm *Webhook, err error) {
fcm = &Webhook{
log: log,
}
return
}
func (fcm *Webhook) Logger() *slog.Logger {
return fcm.log
}
// ID ...
func (fcm *Webhook) ID() string {
return "webhook"
}
// String ...
func (fcm *Webhook) String() string {
return "Webhook"
}
func (fcm *Webhook) NewClient() (services.PumpClient, error) {
client := &http.Client{
Timeout: time.Duration(5 * time.Second),
Transport: &http.Transport{
MaxIdleConns: 5,
IdleConnTimeout: 30 * time.Second,
},
}
return client, nil
}
func (wh *Webhook) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {
panic("not implemented")
}
func (wh *Webhook) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
msg := smsg.(webhookMessage)
startedAt := time.Now()
var success bool
wh.log.Debug("POST", "url", msg.URL, "data", string(msg.postData))
req, err := http.NewRequest("POST", msg.URL, bytes.NewBuffer(msg.postData))
if err != nil {
wh.log.Error("Failed to create request", "error", err)
return services.PushStatusHardFail
}
for k, v := range msg.Headers {
req.Header.Set(k, v)
}
client := pclient.(*http.Client)
resp, err := client.Do(req)
if err != nil {
wh.log.Error("Failed to post", "error", err)
return services.PushStatusHardFail
}
duration := time.Now().Sub(startedAt)
defer func() {
fc.CountPush(wh.ID(), success, duration)
}()
body, error := ioutil.ReadAll(resp.Body)
if error != nil {
wh.log.Error("Failed to read POST response", "error", error)
} else {
wh.log.Debug("POST response", "response", string(body))
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
wh.log.Error("Rejected", "status", resp.StatusCode)
return services.PushStatusHardFail
}
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
wh.log.Error("Upstream failure", "status", resp.StatusCode)
// A retry might help, but currently retries are not limitted to
// a certain number of attempts, meaning, we would keep trying
// indefinitely.
return services.PushStatusHardFail
}
success = true
return services.PushStatusSuccess
}
================================================
FILE: internal/services/webpush/message.go
================================================
package webpush
import (
"codeberg.org/pennersr/shove/internal/services"
"encoding/json"
wpg "github.com/SherClockHolmes/webpush-go"
)
type webPushMessage struct {
Subscription json.RawMessage `json:"subscription"`
Payload json.RawMessage `json:"payload"`
Token string `json:"token"`
Headers struct {
TTL int `json:"ttl"`
Topic string `json:"topic"`
Urgency string `json:"urgency"`
} `json:"headers"`
options wpg.Options
subscription wpg.Subscription
}
func (msg webPushMessage) GetSquashKey() string {
panic("not implemented")
}
func (wp *WebPush) ConvertMessage(data []byte) (services.ServiceMessage, error) {
var msg webPushMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil, err
}
if err := json.Unmarshal(msg.Subscription, &msg.subscription); err != nil {
return nil, err
}
if msg.Token == "" {
msg.Token = string(msg.Subscription)
}
msg.options = wpg.Options{
VAPIDPublicKey: wp.vapidPublicKey,
VAPIDPrivateKey: wp.vapidPrivateKey,
}
msg.options.Topic = msg.Headers.Topic
if msg.Headers.Urgency != "" {
msg.options.Urgency = wpg.Urgency(msg.Headers.Urgency)
}
if msg.Headers.TTL > 0 {
msg.options.TTL = msg.Headers.TTL
}
return msg, nil
}
// Validate ...
func (wp *WebPush) Validate(data []byte) error {
_, err := wp.ConvertMessage(data)
return err
}
================================================
FILE: internal/services/webpush/message_test.go
================================================
package webpush
import (
"fmt"
"golang.org/x/exp/slog"
"os"
"testing"
)
const subscription = `{
"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAA",
"keys": {
"auth":"bHmp2U5UKnWaL-31nal7ew",
"p256dh":"BKedT"
}
}`
func TestConvert(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
wp, err := NewWebPush("pub", "pvt", logger)
if err != nil {
t.Fatal(err)
}
smsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`
{
"subscription": %s,
"headers": {
"ttl": 10,
"urgency": "low"
},
"payload": {"xxx":"z"}
}
`, subscription)))
if err != nil {
t.Fatal(err)
}
msg := smsg.(webPushMessage)
if msg.options.TTL != 10 {
t.Fatal("TTL wrong")
}
if msg.Token != subscription {
t.Fatal("Token not derived from subscription")
}
}
func TestConvertWithToken(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
wp, err := NewWebPush("pub", "pvt", logger)
if err != nil {
t.Fatal(err)
}
smsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`
{
"subscription": %s,
"token": "my-token",
"headers": {
"ttl": 10,
"urgency": "low"
},
"payload": {"xxx":"z"}
}
`, subscription)))
if err != nil {
t.Fatal(err)
}
msg := smsg.(webPushMessage)
if msg.Token != "my-token" {
t.Fatal(msg.Token)
}
}
================================================
FILE: internal/services/webpush/webpush.go
================================================
package webpush
import (
"codeberg.org/pennersr/shove/internal/queue"
"codeberg.org/pennersr/shove/internal/services"
wpg "github.com/SherClockHolmes/webpush-go"
"golang.org/x/exp/slog"
"net/http"
"time"
)
// WebPush ...
type WebPush struct {
vapidPublicKey string
vapidPrivateKey string
log *slog.Logger
}
// NewWebPush ...
func NewWebPush(vapidPub, vapidPvt string, log *slog.Logger) (wp *WebPush, err error) {
wp = &WebPush{
vapidPrivateKey: vapidPvt,
vapidPublicKey: vapidPub,
log: log,
}
return
}
func (wp *WebPush) Logger() *slog.Logger {
return wp.log
}
func (wp *WebPush) NewClient() (services.PumpClient, error) {
client := &http.Client{
Timeout: time.Duration(15 * time.Second),
Transport: &http.Transport{
MaxIdleConns: 5,
IdleConnTimeout: 30 * time.Second,
},
}
return client, nil
}
// ID ...
func (wp *WebPush) ID() string {
return "webpush"
}
// String ...
func (wp *WebPush) String() string {
return "WebPush"
}
func (wp *WebPush) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
panic("not implemented")
}
func (wp *WebPush) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {
success := false
msg := smsg.(webPushMessage)
msg.options.HTTPClient = pclient.(*http.Client)
startedAt := time.Now()
// Send Notification
resp, err := wpg.SendNotification(msg.Payload, &msg.subscription, &msg.options)
if err != nil {
wp.log.Error("Failed to send", "error", err)
return services.PushStatusHardFail
}
defer resp.Body.Close()
duration := time.Now().Sub(startedAt)
wp.log.Info("Pushed", "status", resp.StatusCode, "duration", duration)
defer func() {
fc.CountPush(wp.ID(), success, duration)
}()
switch resp.StatusCode {
case 201:
// 201 Created. The request to send a push message was received and accepted.
success = true
return services.PushStatusSuccess
case 429:
// 429 Too many requests. Meaning your application server has
// reached a rate limit with a push service. The push service
// should include a 'Retry-After' header to indicate how long
// before another request can be made.
return services.PushStatusTempFail
case 400:
// 400 Invalid request. This generally means one of your headers is invalid or improperly formatted.
return services.PushStatusHardFail
case 404:
// 404 Not Found. This is an indication that the subscription is
// expired and can't be used. In this case you should delete the
// `PushSubscription` and wait for the client to resubscribe the
// user.
fallthrough
case 410:
// 410 Gone. The subscription is no longer valid and should be
// removed from application server. This can be reproduced by
// calling `unsubscribe()` on a `PushSubscription`.
fc.TokenInvalid(wp.ID(), msg.Token)
return services.PushStatusHardFail
default:
// 413 Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).
return services.PushStatusHardFail
}
}
func (wp *WebPush) remove(q queue.Queue, qm queue.QueuedMessage) {
if err := q.Remove(qm); err != nil {
wp.log.Error("Failed to remove from the queue", "error", err)
}
}
================================================
FILE: pkg/shove/client.go
================================================
package shove
import (
shvredis "codeberg.org/pennersr/shove/internal/queue/redis"
"github.com/gomodule/redigo/redis"
"time"
)
// Client ...
type Client interface {
PushRaw(serviceID string, data []byte) (err error)
}
type redisClient struct {
pool *redis.Pool
}
// NewRedisClient ...
func NewRedisClient(redisURL string) Client {
rc := &redisClient{
pool: &redis.Pool{
MaxIdle: 3,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) {
return redis.DialURL(redisURL)
},
},
}
return rc
}
// PushRaw ...
func (rc *redisClient) PushRaw(id string, data []byte) (err error) {
waitingList := shvredis.ListName(id)
conn := rc.pool.Get()
defer conn.Close()
_, err = conn.Do("RPUSH", waitingList, data)
return
}
================================================
FILE: scripts/email.json
================================================
{
"digest": {
"subject": "Hello Digest"
},
"subject": "Hello world!",
"from": "jane@doe.org",
"to": ["john@doe.org"],
"text": "Hello World!\n\nGreetings.\n",
"html": "<!DOCTYPE html>\n<html=\"en\">\n<head>\n<title>Hello World</title>\n</head>\n<body>\n<p>Hello <b>world</b>!\n</body>",
"attachments": [
{
"filename": "document.png",
"content-type": "image/png",
"content": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAA+LaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczppcHRjRXh0PSJodHRwOi8vaXB0Yy5vcmcvc3RkL0lwdGM0eG1wRXh0LzIwMDgtMDItMjkvIgogICAgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iCiAgICB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIgogICAgeG1sbnM6cGx1cz0iaHR0cDovL25zLnVzZXBsdXMub3JnL2xkZi94bXAvMS4wLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6YTYxMWNhYzYtZWUzNy00MWM5LTljMTItZDJiMWVhMjIyZWMwIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmVlY2NlNzk4LWE5MzctNDBmYS04NzliLWNkMjgyODg5MDA4ZCIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOmM0YTcwMDY0LTkxMjMtNDdiYS1hMDJkLTg3MmYzZWViYjY1ZCIKICAgR0lNUDpBUEk9IjIuMCIKICAgR0lNUDpQbGF0Zm9ybT0iTGludXgiCiAgIEdJTVA6VGltZVN0YW1wPSIxNjE2NDA4NjE2MzAxMDQ0IgogICBHSU1QOlZlcnNpb249IjIuMTAuMjIiCiAgIGRjOkZvcm1hdD0iaW1hZ2UvcG5nIgogICB0aWZmOk9yaWVudGF0aW9uPSIxIgogICB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCI+CiAgIDxpcHRjRXh0OkxvY2F0aW9uQ3JlYXRlZD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkxvY2F0aW9uQ3JlYXRlZD4KICAgPGlwdGNFeHQ6TG9jYXRpb25TaG93bj4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkxvY2F0aW9uU2hvd24+CiAgIDxpcHRjRXh0OkFydHdvcmtPck9iamVjdD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OkFydHdvcmtPck9iamVjdD4KICAgPGlwdGNFeHQ6UmVnaXN0cnlJZD4KICAgIDxyZGY6QmFnLz4KICAgPC9pcHRjRXh0OlJlZ2lzdHJ5SWQ+CiAgIDx4bXBNTTpIaXN0b3J5PgogICAgPHJkZjpTZXE+CiAgICAgPHJkZjpsaQogICAgICBzdEV2dDphY3Rpb249InNhdmVkIgogICAgICBzdEV2dDpjaGFuZ2VkPSIvIgogICAgICBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmRjN2Y5Zjg4LTU2MzUtNGFiMC1hMzE2LTQ4ZDNjNTA0ZjkzYSIKICAgICAgc3RFdnQ6c29mdHdhcmVBZ2VudD0iR2ltcCAyLjEwIChMaW51eCkiCiAgICAgIHN0RXZ0OndoZW49IiswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgIDxwbHVzOkltYWdlU3VwcGxpZXI+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpJbWFnZVN1cHBsaWVyPgogICA8cGx1czpJbWFnZUNyZWF0b3I+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpJbWFnZUNyZWF0b3I+CiAgIDxwbHVzOkNvcHlyaWdodE93bmVyPgogICAgPHJkZjpTZXEvPgogICA8L3BsdXM6Q29weXJpZ2h0T3duZXI+CiAgIDxwbHVzOkxpY2Vuc29yPgogICAgPHJkZjpTZXEvPgogICA8L3BsdXM6TGljZW5zb3I+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz76UHWrAAADFHpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHja7ZVJshshDIb3nCJHQANIOg4NUJUb5PgRNN1+dobKtMjCUA0gwy+hD8qhf/k8wicvGBkDJ9FsOUcvbGxYfKDxLMdqIfJq9yRegyd7uH9AN5H3dE4Nt7273cew57adwLX+Ero9FR+lxw+lbPvxbD+2IOqr0I6A4PQc296whQh3RHzO644om8rT0Vrdnnmb9PExCeaUQdhbxiiSzceKkYWN2wyUZLmPAbany3DNr6UYCbETUPQWCc8oaX5AxXvy1u3BF8Iy4TIR5ZX46CgxigvbKTxKvJP5MTePHP2g/Mqx9jVZ1yA8LsYevdyPe/RyPaRsO532h1C++yeslx3Si51uN/gUkT4848eIhG4X8ZXqGE3H6Gtx4MKZjfM+1HWUNfKFx8zW2pa9in/Jx7KqedVYYnXSLcQaj3j4xACd8QCGBgUG9NVXqBiRsaN4j1iRYNqUBA0rTeY8a4CBQkaNlJAqdiI34x0LLL+23FXQ2GIDJURwMSAkvGr4OPmb+o3QGPPNAES9c0URcN5oBJjkZuurHAiMnVMnGlaK4U70xzLBkhNMK80aLZZ4nBJHgsfdosl5vv7kleP5xkDaFoiR3XcCRCBgiBkoQYYoiALAhAoGBaIiMR4QKkBK2DxKZKKMgorRffsegbUWE55mw0JMiTIJKRkVJmZOnFlYAxuXRIlTSjlJ0mSpZMrzheUsWbPlIiQsSbKIqJgUJWVNmlVU1bQYGgVjS5ZNTM2sFHdaXLn47uIrSjnwoIOPdORDDj3sKBUrVa6p5ipVq9XSsFFo3FLLTZo2a6VDx06de+q5S9duvQwYOGjwSCMPGTpslJvapvpM7ZXcz6nBpoYLFAVv5KbmZpFLAiByTJOZE0MGJy6TABDiZBYVmHGSO6BCiIZElNCjTBNOg0kMmbgDpgE3uwe573ILrH/EDV/JhYnuX5ALE90LuW+5fYdaKzHGuoitVxhWUiONGH1B14JaYkT97T786ca30FvoLfQWegu9hd5C/6/QGKNZjOEr2O6+4EF2ypYAAAAGUExURUdwTAAAAJ8qhFEAAAABdFJOUwBA5thmAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAB5JREFUCNdjsH/AoGDAoKDBoKDCoGDDoMCCD9n/AQB+kwT702H/GwAAAABJRU5ErkJggg=="
}
]
}
gitextract_y77ellyt/
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .gitlab-ci.yml
├── .woodpecker.yaml
├── LICENSE
├── README.md
├── cmd/
│ └── shove/
│ └── main.go
├── go.mod
├── go.sum
├── internal/
│ ├── queue/
│ │ ├── memory/
│ │ │ ├── message.go
│ │ │ └── queue.go
│ │ ├── queue.go
│ │ └── redis/
│ │ └── queue.go
│ ├── server/
│ │ ├── feedback.go
│ │ ├── metrics.go
│ │ ├── push.go
│ │ ├── server.go
│ │ └── worker.go
│ └── services/
│ ├── apns/
│ │ ├── apns.go
│ │ └── message.go
│ ├── email/
│ │ ├── encode.go
│ │ ├── encode_test.go
│ │ ├── message.go
│ │ ├── send.go
│ │ └── service.go
│ ├── fcm/
│ │ ├── fcm.go
│ │ └── message.go
│ ├── pump.go
│ ├── services.go
│ ├── squasher.go
│ ├── telegram/
│ │ ├── message.go
│ │ └── telegram.go
│ ├── webhook/
│ │ ├── message.go
│ │ └── webhook.go
│ └── webpush/
│ ├── message.go
│ ├── message_test.go
│ └── webpush.go
├── pkg/
│ └── shove/
│ └── client.go
└── scripts/
└── email.json
SYMBOL INDEX (176 symbols across 30 files)
FILE: cmd/shove/main.go
function newLogger (line 58) | func newLogger() *slog.Logger {
function newServiceLogger (line 69) | func newServiceLogger(service string) *slog.Logger {
function main (line 76) | func main() {
FILE: internal/queue/memory/message.go
type memoryQueuedMessage (line 3) | type memoryQueuedMessage struct
method Message (line 9) | func (qm *memoryQueuedMessage) Message() []byte {
FILE: internal/queue/memory/queue.go
type MemoryQueueFactory (line 11) | type MemoryQueueFactory struct
method NewQueue (line 96) | func (mqf MemoryQueueFactory) NewQueue(id string) (q queue.Queue, err ...
type memoryQueue (line 13) | type memoryQueue struct
method Queue (line 20) | func (mq *memoryQueue) Queue(msg []byte) (err error) {
method Shutdown (line 42) | func (mq *memoryQueue) Shutdown() (err error) {
method Remove (line 50) | func (mq *memoryQueue) Remove(qm queue.QueuedMessage) (err error) {
method Requeue (line 58) | func (mq *memoryQueue) Requeue(qm queue.QueuedMessage) (err error) {
method getNextMessage (line 67) | func (mq *memoryQueue) getNextMessage() *memoryQueuedMessage {
method Get (line 78) | func (mq *memoryQueue) Get(ctx context.Context) (queue.QueuedMessage, ...
FILE: internal/queue/queue.go
type Queue (line 8) | type Queue interface
type QueuedMessage (line 17) | type QueuedMessage interface
type QueueFactory (line 22) | type QueueFactory interface
FILE: internal/queue/redis/queue.go
type redisQueueFactory (line 12) | type redisQueueFactory struct
method NewQueue (line 55) | func (rqf *redisQueueFactory) NewQueue(id string) (q queue.Queue, err ...
type redisQueue (line 16) | type redisQueue struct
method Queue (line 34) | func (rq redisQueue) Queue(msg []byte) (err error) {
method Get (line 38) | func (rq redisQueue) Get(ctx context.Context) (qm queue.QueuedMessage,...
method Remove (line 43) | func (rq redisQueue) Remove(qm queue.QueuedMessage) (err error) {
method Requeue (line 47) | func (rq redisQueue) Requeue(qm queue.QueuedMessage) (err error) {
method Shutdown (line 51) | func (rq redisQueue) Shutdown() (err error) {
function NewQueueFactory (line 21) | func NewQueueFactory(url string) queue.QueueFactory {
function ListName (line 66) | func ListName(serviceID string) string {
FILE: internal/server/feedback.go
type tokenFeedback (line 9) | type tokenFeedback struct
method handleFeedback (line 16) | func (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {
method TokenInvalid (line 37) | func (s *Server) TokenInvalid(serviceID, token string) {
method ReplaceToken (line 45) | func (s *Server) ReplaceToken(serviceID, token, replacement string) {
FILE: internal/server/metrics.go
method CountPush (line 26) | func (s *Server) CountPush(serviceID string, success bool, duration time...
FILE: internal/server/push.go
method handlePush (line 10) | func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
FILE: internal/server/server.go
type Server (line 14) | type Server struct
method Serve (line 44) | func (s *Server) Serve() (err error) {
method Shutdown (line 54) | func (s *Server) Shutdown(ctx context.Context) (err error) {
method AddService (line 72) | func (s *Server) AddService(pp services.PushService, workers int, squa...
function NewServer (line 24) | func NewServer(addr string, qf queue.QueueFactory) (s *Server) {
FILE: internal/server/worker.go
type worker (line 10) | type worker struct
method push (line 28) | func (w *worker) push(msg []byte) (err error) {
method serve (line 36) | func (w *worker) serve(workers int, squash services.SquashConfig, fc s...
method shutdown (line 45) | func (w *worker) shutdown() (err error) {
function newWorker (line 18) | func newWorker(pp services.PushService, queue queue.Queue) (w *worker, e...
FILE: internal/services/apns/apns.go
type APNS (line 13) | type APNS struct
method Logger (line 33) | func (apns *APNS) Logger() *slog.Logger {
method NewClient (line 37) | func (apns *APNS) NewClient() (pclient services.PumpClient, err error) {
method ID (line 49) | func (apns *APNS) ID() string {
method String (line 58) | func (apns *APNS) String() string {
method SquashAndPushMessage (line 65) | func (apns *APNS) SquashAndPushMessage(client services.PumpClient, sms...
method PushMessage (line 69) | func (apns *APNS) PushMessage(pclient services.PumpClient, smsg servic...
function NewAPNS (line 20) | func NewAPNS(pemFile string, production bool, log *slog.Logger) (apns *A...
FILE: internal/services/apns/message.go
type apnsMessage (line 11) | type apnsMessage struct
type apnsNotification (line 17) | type apnsNotification struct
method GetSquashKey (line 21) | func (notif apnsNotification) GetSquashKey() string {
method ConvertMessage (line 25) | func (apns *APNS) ConvertMessage(data []byte) (smsg services.ServiceMess...
method Validate (line 75) | func (apns *APNS) Validate(data []byte) (err error) {
FILE: internal/services/email/encode.go
function encodeSMTPAddress (line 17) | func encodeSMTPAddress(s string) (string, error) {
function encodeSMTPAddresses (line 25) | func encodeSMTPAddresses(from string, to []string) (encFrom string, encT...
function encodeAddress (line 41) | func encodeAddress(s string) (string, error) {
function encodeRFC2047 (line 49) | func encodeRFC2047(s string) string {
function encodeEmail (line 53) | func encodeEmail(em email) ([]byte, error) {
function encodeEmailDigest (line 71) | func encodeEmailDigest(emails []email) ([]byte, error) {
function encodeHeaders (line 121) | func encodeHeaders(out *bytes.Buffer, headers map[string]string) error {
function nestedMultipart (line 142) | func nestedMultipart(enclosingWriter *multipart.Writer, contentType stri...
function randomBoundary (line 158) | func randomBoundary() (string, error) {
FILE: internal/services/email/encode_test.go
function TestEncodeEmailDigest (line 15) | func TestEncodeEmailDigest(t *testing.T) {
function TestEncodeEmail (line 54) | func TestEncodeEmail(t *testing.T) {
function TestEncodeSMTPAddresses (line 77) | func TestEncodeSMTPAddresses(t *testing.T) {
function TestEncodeSMTPAddressesErrors (line 93) | func TestEncodeSMTPAddressesErrors(t *testing.T) {
FILE: internal/services/email/message.go
type attachment (line 9) | type attachment struct
type email (line 14) | type email struct
method GetSquashKey (line 26) | func (em email) GetSquashKey() string {
method ConvertMessage (line 30) | func (es *EmailService) ConvertMessage(data []byte) (services.ServiceMes...
method Validate (line 50) | func (es *EmailService) Validate(data []byte) error {
FILE: internal/services/email/send.go
method send (line 12) | func (ec EmailConfig) send(from string, to []string, body []byte, fc ser...
method sendMailTLS (line 41) | func (ec EmailConfig) sendMailTLS(addr string, auth smtp.Auth, from stri...
FILE: internal/services/email/service.go
constant serviceID (line 9) | serviceID = "email"
type EmailConfig (line 11) | type EmailConfig struct
type EmailService (line 22) | type EmailService struct
method Logger (line 33) | func (es *EmailService) Logger() *slog.Logger {
method ID (line 37) | func (es *EmailService) ID() string {
method String (line 41) | func (es *EmailService) String() string {
method NewClient (line 45) | func (es *EmailService) NewClient() (services.PumpClient, error) {
method SquashAndPushMessage (line 49) | func (es *EmailService) SquashAndPushMessage(client services.PumpClien...
method PushMessage (line 61) | func (es *EmailService) PushMessage(pclient services.PumpClient, smsg ...
method push (line 70) | func (es *EmailService) push(from string, to []string, body []byte, fc...
function NewEmailService (line 26) | func NewEmailService(config EmailConfig) (es *EmailService, err error) {
FILE: internal/services/fcm/fcm.go
type FCM (line 15) | type FCM struct
method Logger (line 29) | func (fcm *FCM) Logger() *slog.Logger {
method ID (line 34) | func (fcm *FCM) ID() string {
method String (line 39) | func (fcm *FCM) String() string {
method NewClient (line 43) | func (fcm *FCM) NewClient() (services.PumpClient, error) {
method SquashAndPushMessage (line 57) | func (fcm *FCM) SquashAndPushMessage(services.PumpClient, []services.S...
method PushMessage (line 61) | func (fcm *FCM) PushMessage(pclient services.PumpClient, smsg services...
function NewFCM (line 21) | func NewFCM(credentialsFile string, log *slog.Logger) (fcm *FCM, err err...
FILE: internal/services/fcm/message.go
type fcmMessage (line 10) | type fcmMessage struct
method GetSquashKey (line 14) | func (fcmMessage) GetSquashKey() string {
method ConvertMessage (line 18) | func (fcm *FCM) ConvertMessage(data []byte) (smsg services.ServiceMessag...
method Validate (line 33) | func (fcm *FCM) Validate(data []byte) error {
FILE: internal/services/pump.go
type Pump (line 13) | type Pump struct
method push (line 58) | func (p *Pump) push(q queue.Queue, qm queue.QueuedMessage, client Pump...
method serveClient (line 69) | func (p *Pump) serveClient(ctx context.Context, q queue.Queue, client ...
method backoff (line 116) | func (p *Pump) backoff(ctx context.Context, failureCount int) {
method Serve (line 124) | func (p *Pump) Serve(ctx context.Context, q queue.Queue, fc FeedbackCo...
type ServiceMessage (line 20) | type ServiceMessage interface
type PushStatus (line 24) | type PushStatus
constant PushStatusSuccess (line 28) | PushStatusSuccess PushStatus = iota
constant PushStatusTempFail (line 30) | PushStatusTempFail
constant PushStatusHardFail (line 32) | PushStatusHardFail
type PumpClient (line 35) | type PumpClient interface
type PumpAdapter (line 38) | type PumpAdapter interface
function NewPump (line 47) | func NewPump(workers int, squash SquashConfig, adapter PumpAdapter) (p *...
function removeFromQueue (line 110) | func removeFromQueue(q queue.Queue, qm queue.QueuedMessage, log *slog.Lo...
FILE: internal/services/services.go
type FeedbackCollector (line 9) | type FeedbackCollector interface
type PushService (line 16) | type PushService interface
FILE: internal/services/squasher.go
type batch (line 10) | type batch struct
type SquashConfig (line 19) | type SquashConfig struct
type squasher (line 24) | type squasher struct
method flushAndGetRate (line 44) | func (d *squasher) flushAndGetRate(key string) (sendCount int, sentAt ...
method recordPush (line 65) | func (d *squasher) recordPush(key string) {
method prepareToPush (line 71) | func (d *squasher) prepareToPush(q queue.Queue, qm queue.QueuedMessage...
method getNextBatch (line 101) | func (d *squasher) getNextBatch() (b batch, stopped bool) {
method requestShutdown (line 137) | func (d *squasher) requestShutdown() {
method shutdown (line 144) | func (d *squasher) shutdown() {
method serve (line 151) | func (d *squasher) serve(fc FeedbackCollector) {
method sendBatch (line 163) | func (d *squasher) sendBatch(b batch, fc FeedbackCollector) {
function newSquasher (line 34) | func newSquasher(config SquashConfig, adapter PumpAdapter) (d *squasher) {
FILE: internal/services/telegram/message.go
type telegramMessage (line 12) | type telegramMessage struct
method GetSquashKey (line 27) | func (msg telegramMessage) GetSquashKey() string {
type telegramPayload (line 20) | type telegramPayload struct
method ConvertMessage (line 32) | func (tg *TelegramService) ConvertMessage(data []byte) (services.Service...
method Validate (line 51) | func (tg *TelegramService) Validate(data []byte) error {
function concatText (line 56) | func concatText(builder *strings.Builder, text string) {
function trimString (line 72) | func trimString(input string, maxLength int) string {
function squashMessages (line 83) | func squashMessages(msgs []telegramMessage) (dmsg telegramMessage, err e...
FILE: internal/services/telegram/telegram.go
type TelegramService (line 16) | type TelegramService struct
method Logger (line 30) | func (tg *TelegramService) Logger() *slog.Logger {
method ID (line 35) | func (tg *TelegramService) ID() string {
method String (line 41) | func (tg *TelegramService) String() string {
method NewClient (line 45) | func (tg *TelegramService) NewClient() (services.PumpClient, error) {
method SquashAndPushMessage (line 52) | func (tg *TelegramService) SquashAndPushMessage(pclient services.PumpC...
method PushMessage (line 66) | func (tg *TelegramService) PushMessage(pclient services.PumpClient, sm...
method pushMessage (line 72) | func (tg *TelegramService) pushMessage(client *http.Client, method str...
function NewTelegramService (line 22) | func NewTelegramService(botToken string, log *slog.Logger) (tg *Telegram...
FILE: internal/services/webhook/message.go
type webhookMessage (line 11) | type webhookMessage struct
method GetSquashKey (line 20) | func (webhookMessage) GetSquashKey() string {
method ConvertMessage (line 24) | func (wh *Webhook) ConvertMessage(data []byte) (smsg services.ServiceMes...
method Validate (line 49) | func (wh *Webhook) Validate(data []byte) error {
FILE: internal/services/webhook/webhook.go
type Webhook (line 13) | type Webhook struct
method Logger (line 24) | func (fcm *Webhook) Logger() *slog.Logger {
method ID (line 29) | func (fcm *Webhook) ID() string {
method String (line 34) | func (fcm *Webhook) String() string {
method NewClient (line 38) | func (fcm *Webhook) NewClient() (services.PumpClient, error) {
method SquashAndPushMessage (line 49) | func (wh *Webhook) SquashAndPushMessage(services.PumpClient, []service...
method PushMessage (line 53) | func (wh *Webhook) PushMessage(pclient services.PumpClient, smsg servi...
function NewWebhook (line 17) | func NewWebhook(log *slog.Logger) (fcm *Webhook, err error) {
FILE: internal/services/webpush/message.go
type webPushMessage (line 9) | type webPushMessage struct
method GetSquashKey (line 23) | func (msg webPushMessage) GetSquashKey() string {
method ConvertMessage (line 27) | func (wp *WebPush) ConvertMessage(data []byte) (services.ServiceMessage,...
method Validate (line 53) | func (wp *WebPush) Validate(data []byte) error {
FILE: internal/services/webpush/message_test.go
constant subscription (line 10) | subscription = `{
function TestConvert (line 18) | func TestConvert(t *testing.T) {
function TestConvertWithToken (line 46) | func TestConvertWithToken(t *testing.T) {
FILE: internal/services/webpush/webpush.go
type WebPush (line 13) | type WebPush struct
method Logger (line 29) | func (wp *WebPush) Logger() *slog.Logger {
method NewClient (line 33) | func (wp *WebPush) NewClient() (services.PumpClient, error) {
method ID (line 45) | func (wp *WebPush) ID() string {
method String (line 50) | func (wp *WebPush) String() string {
method SquashAndPushMessage (line 54) | func (wp *WebPush) SquashAndPushMessage(client services.PumpClient, sm...
method PushMessage (line 58) | func (wp *WebPush) PushMessage(pclient services.PumpClient, smsg servi...
method remove (line 111) | func (wp *WebPush) remove(q queue.Queue, qm queue.QueuedMessage) {
function NewWebPush (line 20) | func NewWebPush(vapidPub, vapidPvt string, log *slog.Logger) (wp *WebPus...
FILE: pkg/shove/client.go
type Client (line 10) | type Client interface
type redisClient (line 14) | type redisClient struct
method PushRaw (line 33) | func (rc *redisClient) PushRaw(id string, data []byte) (err error) {
function NewRedisClient (line 19) | func NewRedisClient(redisURL string) Client {
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (151K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 642,
"preview": "# These are supported funding model platforms\n\ngithub: pennersr\npatreon: # Replace with a single Patreon username\nopen_c"
},
{
"path": ".gitignore",
"chars": 27,
"preview": "*~\n/cmd/shove/shove\n/shove\n"
},
{
"path": ".gitlab-ci.yml",
"chars": 976,
"preview": "image: golang:1.20-bullseye\n\nvariables:\n REPO_NAME: gitlab.com/pennersr/shove\n\n# The problem is that to be able to use "
},
{
"path": ".woodpecker.yaml",
"chars": 504,
"preview": "when:\n - event: pull_request\n - event: [push, tag, manual]\n branch: main\n\nsteps:\n build:\n image: golang:1.21-bo"
},
{
"path": "LICENSE",
"chars": 1098,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2019 Raymond Penners and contributors\n\nPermission is hereby granted, free of charge"
},
{
"path": "README.md",
"chars": 9216,
"preview": "# When push comes to shove...\n\n[](https://g"
},
{
"path": "cmd/shove/main.go",
"chars": 7018,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/"
},
{
"path": "go.mod",
"chars": 2871,
"preview": "module codeberg.org/pennersr/shove\n\ngo 1.21\n\ntoolchain go1.22.6\n\nrequire (\n\tcodeberg.org/pennersr/redq v0.0.0-2024090818"
},
{
"path": "go.sum",
"chars": 59929,
"preview": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1"
},
{
"path": "internal/queue/memory/message.go",
"chars": 163,
"preview": "package memory\n\ntype memoryQueuedMessage struct {\n\tmsg []byte\n\tpending bool\n\tidx int\n}\n\nfunc (qm *memoryQueuedMe"
},
{
"path": "internal/queue/memory/queue.go",
"chars": 1855,
"preview": "package memory\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n)\n\n// MemoryQueueFac"
},
{
"path": "internal/queue/queue.go",
"chars": 385,
"preview": "package queue\n\nimport (\n\t\"context\"\n)\n\n// Queue ...\ntype Queue interface {\n\tQueue([]byte) error\n\tGet(ctx context.Context)"
},
{
"path": "internal/queue/redis/queue.go",
"chars": 1384,
"preview": "package redis\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/redq\"\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n"
},
{
"path": "internal/server/feedback.go",
"chars": 1341,
"preview": "package server\n\nimport (\n\t\"encoding/json\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n)\n\ntype tokenFeedback struct {\n\tService "
},
{
"path": "internal/server/metrics.go",
"chars": 785,
"preview": "package server\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometh"
},
{
"path": "internal/server/push.go",
"chars": 623,
"preview": "package server\n\nimport (\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc (s *Server) handlePush(w http.ResponseWriter"
},
{
"path": "internal/server/server.go",
"chars": 1815,
"preview": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n"
},
{
"path": "internal/server/worker.go",
"chars": 1105,
"preview": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n"
},
{
"path": "internal/services/apns/apns.go",
"chars": 2279,
"preview": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"crypto/tls\"\n\t\"github.com/sideshow/apns2\"\n\t\"git"
},
{
"path": "internal/services/apns/message.go",
"chars": 1646,
"preview": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"github.com/sideshow"
},
{
"path": "internal/services/email/encode.go",
"chars": 3759,
"preview": "package email\n\nimport (\n\t\"bytes\"\n\t\"crypto/rand\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"mime/multipart\"\n\t\"net/mail\"\n\t\"net/textp"
},
{
"path": "internal/services/email/encode_test.go",
"chars": 2441,
"preview": "package email\n\nimport (\n\t\"testing\"\n)\n\nvar png1Pixel = []byte{\n\t0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x0"
},
{
"path": "internal/services/email/message.go",
"chars": 1238,
"preview": "package email\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n)\n\ntype attachment s"
},
{
"path": "internal/services/email/send.go",
"chars": 1822,
"preview": "package email\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/smtp\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\nfu"
},
{
"path": "internal/services/email/service.go",
"chars": 1915,
"preview": "package email\n\nimport (\n\t\"golang.org/x/exp/slog\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\nconst serviceID = "
},
{
"path": "internal/services/fcm/fcm.go",
"chars": 1954,
"preview": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"context\"\n\tfirebase \"firebase.google.com/go\"\n\t\"f"
},
{
"path": "internal/services/fcm/message.go",
"chars": 750,
"preview": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"firebase.google.com/"
},
{
"path": "internal/services/pump.go",
"chars": 3701,
"preview": "package services\n\nimport (\n\t\"context\"\n\t\"golang.org/x/exp/slog\"\n\t\"math\"\n\t\"sync\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/in"
},
{
"path": "internal/services/services.go",
"chars": 380,
"preview": "package services\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\n// FeedbackCollector ...\ntype FeedbackCollector interface {\n\tTokenInvalid(s"
},
{
"path": "internal/services/squasher.go",
"chars": 3947,
"preview": "package services\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n)\n\ntype batch struct {\n\tkey "
},
{
"path": "internal/services/telegram/message.go",
"chars": 2777,
"preview": "package telegram\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"codeberg.org/pennersr/shove/internal/services"
},
{
"path": "internal/services/telegram/telegram.go",
"chars": 3721,
"preview": "package telegram\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"c"
},
{
"path": "internal/services/webhook/message.go",
"chars": 1201,
"preview": "package webhook\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/url\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\ntyp"
},
{
"path": "internal/services/webhook/webhook.go",
"chars": 2452,
"preview": "package webhook\n\nimport (\n\t\"bytes\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\""
},
{
"path": "internal/services/webpush/message.go",
"chars": 1371,
"preview": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\twpg \"github.com/SherClockHo"
},
{
"path": "internal/services/webpush/message_test.go",
"chars": 1287,
"preview": "package webpush\n\nimport (\n\t\"fmt\"\n\t\"golang.org/x/exp/slog\"\n\t\"os\"\n\t\"testing\"\n)\n\nconst subscription = `{\n\t\"endpoint\":\"https"
},
{
"path": "internal/services/webpush/webpush.go",
"chars": 3305,
"preview": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\""
},
{
"path": "pkg/shove/client.go",
"chars": 758,
"preview": "package shove\n\nimport (\n\tshvredis \"codeberg.org/pennersr/shove/internal/queue/redis\"\n\t\"github.com/gomodule/redigo/redis\""
},
{
"path": "scripts/email.json",
"chars": 7142,
"preview": "{\n \"digest\": {\n \"subject\": \"Hello Digest\"\n },\n \"subject\": \"Hello world!\",\n \"from\": \"jane@doe.org\",\n "
}
]
About this extraction
This page contains the full source code of the pennersr/shove GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (138.3 KB), approximately 58.1k tokens, and a symbol index with 176 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.