[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: pennersr\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".gitignore",
    "content": "*~\n/cmd/shove/shove\n/shove\n"
  },
  {
    "path": ".gitlab-ci.yml",
    "content": "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 go get, one needs to put\n# the repository in the $GOPATH. So for example if your gitlab domain\n# is gitlab.com, and that your repository is namespace/project, and\n# the default GOPATH being /go, then you'd need to have your\n# repository in /go/src/gitlab.com/namespace/project\n# Thus, making a symbolic link corrects this.\nbefore_script:\n  - mkdir -p $GOPATH/src/$(dirname $REPO_NAME)\n  - ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME\n  - cd $GOPATH/src/$REPO_NAME\n\nstages:\n  - test\n  - build\n  - deploy\n\nformat:\n  stage: test\n  script:\n    - go fmt $(go list ./... | grep -v /vendor/)\n    - go vet $(go list ./... | grep -v /vendor/)\n    - go test -race $(go list ./... | grep -v /vendor/)\n\ncompile:\n  stage: build\n  script:\n    - go build -race -ldflags \"-extldflags '-static'\" -o $CI_PROJECT_DIR/shove ./cmd/shove\n  artifacts:\n    paths:\n      - shove\n"
  },
  {
    "path": ".woodpecker.yaml",
    "content": "when:\n  - event: pull_request\n  - event: [push, tag, manual]\n    branch: main\n\nsteps:\n  build:\n    image: golang:1.21-bookworm\n    commands:\n      - go build -race -ldflags \"-extldflags '-static'\" -o $CI_PROJECT_DIR/shove ./cmd/shove\n\n  lint:\n    image: golang:1.21-bookworm\n    commands:\n      - go fmt $(go list ./... | grep -v /vendor/)\n      - go vet $(go list ./... | grep -v /vendor/)\n\n  test:\n    image: golang:1.21-bookworm\n    commands:\n      - go test -race $(go list ./... | grep -v /vendor/)\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2019 Raymond Penners and contributors\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# When push comes to shove...\n\n[![Go Report Card](https://goreportcard.com/badge/codeberg.org/pennersr/shove)](https://goreportcard.com/report/codeberg.org/pennersr/shove) [![Written in Emacs](https://pennersr.github.io/img/emacs-badge.svg)](https://www.gnu.org/software/emacs/) [![Pipeline Status](https://ci.codeberg.org/api/badges/13727/status.svg)](https://ci.codeberg.org/repos/13727)\n\n## Background\n\nThis 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.\n\n## Overview\n\nDesign:\n- Asynchronous: a push client can just fire & forget.\n- Multiple workers per push service.\n- 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.\n\nSupported push services:\n- APNS\n- Email: supports automatic creation of email digests in case the rate limit\n  is exceeded\n- FCM\n- Telegram: supports squashing multiple messages into one in case the rate limit\n  is exceeded\n- Webhook: issue arbitrary webhook posts\n- Web Push\n\nFeatures:\n- Feedback: asynchronously receive information on invalid device tokens.\n- Queueing: both in-memory and persistent via Redis.\n- Exponential back-off in case of failure.\n- Prometheus support.\n- Squashing of messages in case rate limits are exceeded.\n\n\n## Why?\n\n- https://github.com/appleboy/gorush/issues/386#issuecomment-479191179\n\n- https://github.com/mercari/gaurun/issues/115\n\n\n## Usage\n\n### Running\n\nUsage:\n\n    $ shove -h\n    Usage of ./shove:\n      -api-addr string\n            API address to listen to (default \":8322\")\n      -apns-certificate-path string\n            APNS certificate path\n      -apns-sandbox-certificate-path string\n            APNS sandbox certificate path\n      -apns-workers int\n            The number of workers pushing APNS messages (default 4)\n      -email-host string\n            Email host\n      -email-port int\n            Email port (default 25)\n      -email-rate-amount int\n            Email max. rate (amount)\n      -email-rate-per int\n            Email max. rate (per seconds)\n      -email-tls\n            Use TLS\n      -email-tls-insecure\n            Skip TLS verification\n      -fcm-credentials-file string\n            Path to FCM service account JSON file\n      -fcm-workers int\n            The number of workers pushing FCM messages (default 4)\n      -queue-redis string\n            Use Redis queue (Redis URL)\n      -telegram-bot-token string\n            Telegram bot token\n      -telegram-rate-amount int\n            Telegram max. rate (amount)\n      -telegram-rate-per int\n            Telegram max. rate (per seconds)\n      -telegram-workers int\n            The number of workers pushing Telegram messages (default 2)\n      -webhook-workers int\n            The number of workers pushing Webhook messages\n      -webpush-vapid-private-key string\n            VAPID public key\n      -webpush-vapid-public-key string\n            VAPID public key\n      -webpush-workers int\n            The number of workers pushing Web messages (default 8)\n\n\nStart the server:\n\n    $ shove \\\n        -api-addr localhost:8322 \\\n        -queue-redis redis://redis:6379 \\\n        -fcm-credentials-file /etc/shove/fcm/credentials.json \\\n        -apns-certificate-path /etc/shove/apns/production/bundle.pem -apns-sandbox-certificate-path /etc/shove/apns/sandbox/bundle.pem \\\n        -webpush-vapid-public-key=$VAPID_PUBLIC_KEY -webpush-vapid-private-key=$VAPID_PRIVATE_KEY \\\n        -telegram-bot-token $TELEGRAM_BOT_TOKEN\n\n\n### APNS\n\nPush an APNS notification:\n\n    $ 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\n\n\nA successful push results in:\n\n    HTTP/1.1 202 Accepted\n    Date: Tue, 07 May 2019 19:00:15 GMT\n    Content-Length: 2\n    Content-Type: text/plain; charset=utf-8\n\n    OK\n\n\n### FCM\n\nPush an FCM notification:\n\n    $ curl  -i  --data '{\"message\": {\"notification\": {\"body\": \"Hello world!\", \"title\": \"Test\"}, \"token\": \"c7VmdNNHQaGTLkmi....15CmMs\"}}' http://localhost:8322/api/push/fcm\n\n### Webhook\n\nPush a Webhook call, containing arbitrary body content:\n\n    $ curl  -i  --data '{\"url\": \"http://localhost:8000/api/webhook\", \"headers\": {\"foo\": \"bar\"}, \"body\": \"Hello world!\"}' http://localhost:8322/api/push/webhook\n\nOr, post JSON:\n\n    $ curl  -i  --data '{\"url\": \"http://localhost:8000/api/webhook\", \"headers\": {\"foo\": \"bar\"}, \"data\": {\"hello\": \"world!\"}}' http://localhost:8322/api/push/webhook\n\n\n### WebPush\n\nPush a WebPush notification:\n\n    $ 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\n\nThe subscription (serialized as a JSON string) is used for receiving\nfeedback. Alternatively, you can specify an optional `token` parameter as done\nin the example above.\n\n\n### Telegram\n\nPush a Telegram notification:\n\n    $ curl  -i  --data '{\"method\": \"sendMessage\", \"payload\": {\"chat_id\": \"12345678\", \"text\": \"Hello!\"}}' http://localhost:8322/api/push/telegram\n\nNote that the Telegram Bot API documents `chat_id` as \"Integer or String\" --\nShove requires strings to be passed. For users that disconnected from your bot\nthe chat ID will be communicated back through the feedback mechanism. Here, the\ntoken will equal the unreachable chat ID.\n\n\n### Receive Feedback\n\nOutdated/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:\n\n\n    $ curl -X POST 'http://localhost:8322/api/feedback'\n\n    {\n      \"feedback\": [\n        {\"service\":\"apns-sandbox\",\n         \"token\":\"881becff86cbd221544044d3b9aeaaf6314dfbef2abb2fe313f3725f4505cb47\",\n         \"reason\":\"invalid\"}\n      ]\n    }\n\n\n### Email\n\nIn order to keep your SMTP server safe from being blacklisted, the email service\nsupports rate limitting. When the rate is exceeded, multiple mails are\nautomatically digested.\n\n    $ shove \\\n        -email-host localhost \\\n        -email-port 1025 \\\n        -api-addr localhost:8322 \\\n        -email-rate-amount 3 \\\n        -email-rate-per 10 \\\n        -queue-redis redis://localhost:6379\n\nPush an email:\n\n\t$ curl -i -X POST --data @./scripts/email.json http://localhost:8322/api/push/email\n\nIf you send too many emails, you'll notice that they are digested, and at a\nlater time, one digest mail is being sent:\n\n    2021/03/23 21:15:57 Using Redis queue at redis://localhost:6379\n    2021/03/23 21:15:57 Initializing Email service\n    2021/03/23 21:15:57 Serving on localhost:8322\n    2021/03/23 21:15:57 Shove server started\n    2021/03/23 21:15:57 email: Worker started\n    2021/03/23 21:15:57 email: Digester started\n    2021/03/23 21:15:58 email: Sending email\n    2021/03/23 21:15:59 email: Sending email\n    2021/03/23 21:15:59 email: Sending email\n    2021/03/23 21:16:00 email: Rate to john@doe.org exceeded, email digested\n    2021/03/23 21:16:12 email: Rate to john@doe.org exceeded, email digested\n    2021/03/23 21:16:18 email: Sending digest email\n\n\n### Redis Queues\n\nShove is being used to push a high volume of notifications in a production\nenvironment, consisting of various microservices interacting together. In such a\nscenario, it is important that the various services are not too tightly coupled\nto one another.  For that purpose, Shove offers the ability to post\nnotifications directly to a Redis queue.\n\nPosting directly to the Redis queue, instead of using the HTTP service\nendpoints, has the advantage that you can take Shove offline without disturbing\nthe operation of the clients pushing the notifications.\n\nShove intentionally tries to make as little assumptions on the notification\npayloads being pushed, as they are mostly handed over as is to the upstream\nservices. So, when using Shove this way, the client is responsible for handing\nover a raw payload. Here's an example:\n\n\n    package main\n\n    import (\n    \t\"encoding/json\"\n    \t\"codeberg.org/pennersr/shove/pkg/shove\"\n    \t\"log\"\n    \t\"os\"\n    )\n\n    type FCMNotification struct {\n    \tTo       string            `json:\"to\"`\n    \tData     map[string]string `json:\"data,omitempty\"`\n    }\n\n    func main() {\n    \tredisURL := os.Getenv(\"REDIS_URL\")\n    \tif redisURL == \"\" {\n    \t\tredis_URL = \"redis://localhost:6379\"\n    \t}\n    \tclient := shove.NewRedisClient(redisURL)\n\n    \tnotification := FCMNotification{\n    \t\tTo:   \"token....\",\n    \t\tData: map[string]string{},\n    \t}\n\n    \traw, err := json.Marshal(notification)\n    \tif err != nil {\n    \t\tlog.Fatal(err)\n    \t}\n    \terr = client.PushRaw(\"fcm\", raw)\n    \tif err != nil {\n    \t\tlog.Fatal(err)\n    \t}\n    }\n\n\n## Status\n\nUsed in production, over at:\n\n- [Drakdoo: Indicator based signals & alerts](https://www.drakdoo.com): 365.251.428 alerts fired and counting.\n"
  },
  {
    "path": "cmd/shove/main.go",
    "content": "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/queue\"\n\t\"codeberg.org/pennersr/shove/internal/queue/memory\"\n\t\"codeberg.org/pennersr/shove/internal/queue/redis\"\n\t\"codeberg.org/pennersr/shove/internal/server\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"codeberg.org/pennersr/shove/internal/services/apns\"\n\t\"codeberg.org/pennersr/shove/internal/services/email\"\n\t\"codeberg.org/pennersr/shove/internal/services/fcm\"\n\t\"codeberg.org/pennersr/shove/internal/services/telegram\"\n\t\"codeberg.org/pennersr/shove/internal/services/webhook\"\n\t\"codeberg.org/pennersr/shove/internal/services/webpush\"\n\t\"golang.org/x/exp/slog\"\n)\n\nvar debug = flag.Bool(\"debug\", false, \"Enable debug logging\")\nvar apiAddr = flag.String(\"api-addr\", \":8322\", \"API address to listen to\")\n\nvar apnsCertificate = flag.String(\"apns-certificate-path\", \"\", \"APNS certificate path\")\nvar apnsSandboxCertificate = flag.String(\"apns-sandbox-certificate-path\", \"\", \"APNS sandbox certificate path\")\nvar apnsWorkers = flag.Int(\"apns-workers\", 4, \"The number of workers pushing APNS messages\")\n\nvar fcmCredentialsFile = flag.String(\"fcm-credentials-file\", \"\", \"FCM credentials file\")\nvar fcmWorkers = flag.Int(\"fcm-workers\", 4, \"The number of workers pushing FCM messages\")\n\nvar redisURL = flag.String(\"queue-redis\", \"\", \"Use Redis queue (Redis URL)\")\n\nvar webhookWorkers = flag.Int(\"webhook-workers\", 0, \"The number of workers pushing Webhook messages\")\n\nvar webPushVAPIDPublicKey = flag.String(\"webpush-vapid-public-key\", \"\", \"VAPID public key\")\nvar webPushVAPIDPrivateKey = flag.String(\"webpush-vapid-private-key\", \"\", \"VAPID public key\")\nvar webPushWorkers = flag.Int(\"webpush-workers\", 8, \"The number of workers pushing Web messages\")\n\nvar telegramBotToken = flag.String(\"telegram-bot-token\", \"\", \"Telegram bot token\")\nvar telegramWorkers = flag.Int(\"telegram-workers\", 2, \"The number of workers pushing Telegram messages\")\nvar telegramRateAmount = flag.Int(\"telegram-rate-amount\", 0, \"Telegram max. rate (amount)\")\nvar telegramRatePer = flag.Int(\"telegram-rate-per\", 0, \"Telegram max. rate (per seconds)\")\n\nvar emailHost = flag.String(\"email-host\", \"\", \"Email host\")\nvar emailPort = flag.Int(\"email-port\", 25, \"Email port\")\nvar emailPlainAuth = flag.Bool(\"email-plain-auth\", false, \"Email plain auth(username and password)\")\nvar emailUsername = flag.String(\"email-username\", \"\", \"Email username\")\nvar emailPassword = flag.String(\"email-password\", \"\", \"Email password\")\nvar emailTLS = flag.Bool(\"email-tls\", false, \"Use TLS\")\nvar emailTLSInsecure = flag.Bool(\"email-tls-insecure\", false, \"Skip TLS verification\")\nvar emailRateAmount = flag.Int(\"email-rate-amount\", 0, \"Email max. rate (amount)\")\nvar emailRatePer = flag.Int(\"email-rate-per\", 0, \"Email max. rate (per seconds)\")\n\nfunc newLogger() *slog.Logger {\n\tvar opts *slog.HandlerOptions\n\tif *debug {\n\t\topts = &slog.HandlerOptions{\n\t\t\tLevel: slog.LevelDebug,\n\t\t}\n\t}\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, opts))\n\treturn logger\n}\n\nfunc newServiceLogger(service string) *slog.Logger {\n\tlogger := newLogger()\n\treturn logger.With(\n\t\tslog.String(\"service\", service),\n\t)\n}\n\nfunc main() {\n\tflag.Parse()\n\n\tlogger := newLogger()\n\tslog.SetDefault(logger)\n\n\tstop := make(chan os.Signal, 1)\n\tsignal.Notify(stop, os.Interrupt, syscall.SIGTERM)\n\n\tvar qf queue.QueueFactory\n\tif *redisURL == \"\" {\n\t\tslog.Info(\"Using non-persistent in-memory queue\")\n\t\tqf = memory.MemoryQueueFactory{}\n\t} else {\n\t\tslog.Info(\"Using Redis queue at\", \"address\", *redisURL)\n\t\tqf = redis.NewQueueFactory(*redisURL)\n\t}\n\ts := server.NewServer(*apiAddr, qf)\n\n\tif *apnsCertificate != \"\" {\n\t\tapns, err := apns.NewAPNS(*apnsCertificate, true, newServiceLogger(\"apns\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup APNS service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {\n\t\t\tslog.Error(\"Failed to add APNS service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *apnsSandboxCertificate != \"\" {\n\t\tapns, err := apns.NewAPNS(*apnsSandboxCertificate, false, newServiceLogger(\"apns-sandbox\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup APNS sandbox service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(apns, *apnsWorkers, services.SquashConfig{}); err != nil {\n\t\t\tslog.Error(\"Failed to add APNS sandbox service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *fcmCredentialsFile != \"\" {\n\t\tfcm, err := fcm.NewFCM(*fcmCredentialsFile, newServiceLogger(\"fcm\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup FCM service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(fcm, *fcmWorkers, services.SquashConfig{}); err != nil {\n\t\t\tslog.Error(\"Failed to add FCM service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *webhookWorkers > 0 {\n\t\twh, err := webhook.NewWebhook(newServiceLogger(\"webhook\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup Webhook service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(wh, *webhookWorkers, services.SquashConfig{}); err != nil {\n\t\t\tslog.Error(\"Failed to add Webhook service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *webPushVAPIDPrivateKey != \"\" {\n\t\tweb, err := webpush.NewWebPush(*webPushVAPIDPublicKey, *webPushVAPIDPrivateKey, newServiceLogger(\"webpush\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup WebPush service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(web, *webPushWorkers, services.SquashConfig{}); err != nil {\n\t\t\tslog.Error(\"Failed to add WebPush service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *telegramBotToken != \"\" {\n\t\ttg, err := telegram.NewTelegramService(*telegramBotToken, newServiceLogger(\"telegram\"))\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup Telegram service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(tg, *telegramWorkers, services.SquashConfig{\n\t\t\tRateMax: *telegramRateAmount,\n\t\t\tRatePer: time.Second * time.Duration(*telegramRatePer),\n\t\t}); err != nil {\n\t\t\tslog.Error(\"Failed to add Telegram service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif *emailHost != \"\" {\n\t\tconfig := email.EmailConfig{\n\t\t\tEmailHost:     *emailHost,\n\t\t\tEmailPort:     *emailPort,\n\t\t\tTLS:           *emailTLS,\n\t\t\tTLSInsecure:   *emailTLSInsecure,\n\t\t\tLog:           newServiceLogger(\"email\"),\n\t\t\tPlainAuth:     *emailPlainAuth,\n\t\t\tEmailUsername: *emailUsername,\n\t\t\tEmailPassword: *emailPassword,\n\t\t}\n\t\temail, err := email.NewEmailService(config)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Failed to setup email service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.AddService(email, 1, services.SquashConfig{\n\t\t\tRateMax: *emailRateAmount,\n\t\t\tRatePer: time.Second * time.Duration(*emailRatePer),\n\t\t}); err != nil {\n\t\t\tslog.Error(\"Failed to add email service\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tgo func() {\n\t\tslog.Info(\"Serving\", \"address\", *apiAddr)\n\t\terr := s.Serve()\n\t\tif err != nil {\n\t\t\tslog.Error(\"Serve failed\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\t}()\n\t<-stop\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\ts.Shutdown(ctx)\n\tslog.Info(\"Exiting\")\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module codeberg.org/pennersr/shove\n\ngo 1.21\n\ntoolchain go1.22.6\n\nrequire (\n\tcodeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d\n\tfirebase.google.com/go v3.13.0+incompatible\n\tgithub.com/SherClockHolmes/webpush-go v1.2.0\n\tgithub.com/gomodule/redigo v2.0.0+incompatible\n\tgithub.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible\n\tgithub.com/prometheus/client_golang v1.14.0\n\tgithub.com/sideshow/apns2 v0.23.0\n\tgolang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa\n\tgoogle.golang.org/api v0.189.0\n)\n\nrequire (\n\tcloud.google.com/go v0.115.0 // indirect\n\tcloud.google.com/go/auth v0.7.2 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect\n\tcloud.google.com/go/compute/metadata v0.5.0 // indirect\n\tcloud.google.com/go/firestore v1.16.0 // indirect\n\tcloud.google.com/go/iam v1.1.10 // indirect\n\tcloud.google.com/go/longrunning v0.5.9 // indirect\n\tcloud.google.com/go/storage v1.41.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.2.0 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/golang-jwt/jwt v3.2.2+incompatible // indirect\n\tgithub.com/golang-jwt/jwt/v4 v4.4.1 // indirect\n\tgithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/google/s2a-go v0.1.7 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.13.0 // indirect\n\tgithub.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect\n\tgithub.com/prometheus/client_model v0.3.0 // indirect\n\tgithub.com/prometheus/common v0.37.0 // indirect\n\tgithub.com/prometheus/procfs v0.8.0 // indirect\n\tgo.opencensus.io v0.24.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect\n\tgo.opentelemetry.io/otel v1.24.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.24.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.24.0 // indirect\n\tgolang.org/x/crypto v0.25.0 // indirect\n\tgolang.org/x/net v0.27.0 // indirect\n\tgolang.org/x/oauth2 v0.21.0 // indirect\n\tgolang.org/x/sync v0.7.0 // indirect\n\tgolang.org/x/sys v0.22.0 // indirect\n\tgolang.org/x/text v0.16.0 // indirect\n\tgolang.org/x/time v0.5.0 // indirect\n\tgoogle.golang.org/appengine v1.6.8 // indirect\n\tgoogle.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade // indirect\n\tgoogle.golang.org/grpc v1.64.1 // indirect\n\tgoogle.golang.org/protobuf v1.34.2 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=\ncloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=\ncloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=\ncloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=\ncloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=\ncloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=\ncloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=\ncloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=\ncloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=\ncloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=\ncloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=\ncloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=\ncloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=\ncloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=\ncloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=\ncloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=\ncloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=\ncloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=\ncloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=\ncloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=\ncloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=\ncloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=\ncloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=\ncloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=\ncloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=\ncloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=\ncloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=\ncloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=\ncloud.google.com/go/firestore v1.16.0 h1:YwmDHcyrxVRErWcgxunzEaZxtNbc8QoFYA/JOEwDPgc=\ncloud.google.com/go/firestore v1.16.0/go.mod h1:+22v/7p+WNBSQwdSwP57vz47aZiY+HrDkrOsJNhk7rg=\ncloud.google.com/go/iam v1.1.10 h1:ZSAr64oEhQSClwBL670MsJAW5/RLiC6kfw3Bqmd5ZDI=\ncloud.google.com/go/iam v1.1.10/go.mod h1:iEgMq62sg8zx446GCaijmA2Miwg5o3UbO+nI47WHJps=\ncloud.google.com/go/longrunning v0.5.9 h1:haH9pAuXdPAMqHvzX0zlWQigXT7B0+CL4/2nXXdBo5k=\ncloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c=\ncloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=\ncloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=\ncloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=\ncloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=\ncloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=\ncloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=\ncloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=\ncloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=\ncloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=\ncloud.google.com/go/storage v1.41.0 h1:RusiwatSu6lHeEXe3kglxakAmAbfV+rhtPqA6i8RBx0=\ncloud.google.com/go/storage v1.41.0/go.mod h1:J1WCa/Z2FcgdEDuPUY8DxT5I+d9mFKsCepp5vR6Sq80=\ncodeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d h1:ltVUgmkwSzpb/l/MvUOj2UgHRwMVtnxh6XKipVMglUo=\ncodeberg.org/pennersr/redq v0.0.0-20240908181154-b13bb619b69d/go.mod h1:hE2GlgcqKql1wcqp9wjN+2mCsECP8EQS/YbiCrWDxdQ=\ndmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=\nfirebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=\nfirebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=\ngithub.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=\ngithub.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=\ngithub.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=\ngithub.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=\ngithub.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=\ngithub.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=\ngithub.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=\ngithub.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=\ngithub.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=\ngithub.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=\ngithub.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=\ngithub.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=\ngithub.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=\ngithub.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=\ngithub.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=\ngithub.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=\ngithub.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=\ngithub.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=\ngithub.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=\ngithub.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=\ngithub.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=\ngithub.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=\ngithub.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=\ngithub.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=\ngithub.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=\ngithub.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=\ngithub.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=\ngithub.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=\ngithub.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=\ngithub.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=\ngithub.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=\ngithub.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=\ngithub.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=\ngithub.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=\ngithub.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=\ngithub.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=\ngithub.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=\ngithub.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=\ngithub.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=\ngithub.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=\ngithub.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=\ngithub.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=\ngithub.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=\ngithub.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=\ngithub.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=\ngithub.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=\ngithub.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=\ngithub.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=\ngithub.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=\ngithub.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=\ngithub.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/sideshow/apns2 v0.23.0 h1:lpkikaZ995GIcKk6AFsYzHyezCrsrfEDvUWcWkEGErY=\ngithub.com/sideshow/apns2 v0.23.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=\ngithub.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=\ngo.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=\ngo.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=\ngo.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=\ngo.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=\ngo.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=\ngo.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=\ngo.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=\ngo.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=\ngo.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=\ngo.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=\ngo.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=\ngolang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=\ngolang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=\ngolang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=\ngolang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=\ngolang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=\ngolang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=\ngolang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=\ngolang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=\ngolang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=\ngolang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=\ngolang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=\ngolang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=\ngolang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=\ngolang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=\ngolang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=\ngolang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=\ngolang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=\ngolang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=\ngolang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=\ngolang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=\ngolang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=\ngolang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=\ngolang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=\ngolang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=\ngolang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=\ngolang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=\ngoogle.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=\ngoogle.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=\ngoogle.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=\ngoogle.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=\ngoogle.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=\ngoogle.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=\ngoogle.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=\ngoogle.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=\ngoogle.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=\ngoogle.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=\ngoogle.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=\ngoogle.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=\ngoogle.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=\ngoogle.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=\ngoogle.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg=\ngoogle.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade h1:WxZOF2yayUHpHSbUE6NMzumUzBxYc3YGwo0YHnbzsJY=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20240722135656-d784300faade/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade h1:oCRSWfwGXQsqlVdErcyTt4A93Y8fo0/9D4b1gnI++qo=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=\ngoogle.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=\ngoogle.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=\ngoogle.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=\ngoogle.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=\ngoogle.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=\ngoogle.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=\ngoogle.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=\ngoogle.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=\ngopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nhonnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nhonnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\nrsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=\nrsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=\nrsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=\n"
  },
  {
    "path": "internal/queue/memory/message.go",
    "content": "package memory\n\ntype memoryQueuedMessage struct {\n\tmsg     []byte\n\tpending bool\n\tidx     int\n}\n\nfunc (qm *memoryQueuedMessage) Message() []byte {\n\treturn qm.msg\n}\n"
  },
  {
    "path": "internal/queue/memory/queue.go",
    "content": "package memory\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n)\n\n// MemoryQueueFactory ...\ntype MemoryQueueFactory struct{}\n\ntype memoryQueue struct {\n\tbuf          []*memoryQueuedMessage\n\tlock         sync.Mutex\n\tcond         *sync.Cond\n\tshuttingDown bool\n}\n\nfunc (mq *memoryQueue) Queue(msg []byte) (err error) {\n\tmq.lock.Lock()\n\tqm := &memoryQueuedMessage{\n\t\tmsg: msg,\n\t\tidx: -1,\n\t}\n\tfor i := 0; i < len(mq.buf); i++ {\n\t\tif mq.buf[i] == nil {\n\t\t\tqm.idx = i\n\t\t\tmq.buf[i] = qm\n\t\t\tbreak\n\t\t}\n\t}\n\tif qm.idx < 0 {\n\t\tqm.idx = len(mq.buf)\n\t\tmq.buf = append(mq.buf, qm)\n\t}\n\tmq.lock.Unlock()\n\tmq.cond.Signal()\n\treturn nil\n}\n\nfunc (mq *memoryQueue) Shutdown() (err error) {\n\tmq.lock.Lock()\n\tmq.shuttingDown = true\n\tmq.cond.Broadcast()\n\tmq.lock.Unlock()\n\treturn\n}\n\nfunc (mq *memoryQueue) Remove(qm queue.QueuedMessage) (err error) {\n\tmq.lock.Lock()\n\tmqm := qm.(*memoryQueuedMessage)\n\tmq.buf[mqm.idx] = nil\n\tmq.lock.Unlock()\n\treturn nil\n}\n\nfunc (mq *memoryQueue) Requeue(qm queue.QueuedMessage) (err error) {\n\tmq.lock.Lock()\n\tmqm := qm.(*memoryQueuedMessage)\n\tmqm.pending = false\n\tmq.lock.Unlock()\n\tmq.cond.Signal()\n\treturn\n}\n\nfunc (mq *memoryQueue) getNextMessage() *memoryQueuedMessage {\n\tfor i := 0; i < len(mq.buf); i++ {\n\t\tm := mq.buf[i]\n\t\tif m != nil && !m.pending {\n\t\t\tm.pending = true\n\t\t\treturn m\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (mq *memoryQueue) Get(ctx context.Context) (queue.QueuedMessage, error) {\n\tmq.cond.L.Lock()\n\tdefer mq.cond.L.Unlock()\n\tfor ctx.Err() == nil {\n\t\tif mq.shuttingDown {\n\t\t\tbreak\n\t\t}\n\t\tmsg := mq.getNextMessage()\n\t\tif msg == nil {\n\t\t\tmq.cond.Wait()\n\t\t\tcontinue\n\t\t}\n\t\treturn msg, nil\n\t}\n\treturn nil, errors.New(\"queue shut down\")\n}\n\n// NewQueue ...\nfunc (mqf MemoryQueueFactory) NewQueue(id string) (q queue.Queue, err error) {\n\tmq := &memoryQueue{}\n\tmq.cond = sync.NewCond(&mq.lock)\n\tq = mq\n\treturn\n}\n"
  },
  {
    "path": "internal/queue/queue.go",
    "content": "package queue\n\nimport (\n\t\"context\"\n)\n\n// Queue ...\ntype Queue interface {\n\tQueue([]byte) error\n\tGet(ctx context.Context) (QueuedMessage, error)\n\tRemove(QueuedMessage) error\n\tRequeue(QueuedMessage) error\n\tShutdown() error\n}\n\n// QueuedMessage ...\ntype QueuedMessage interface {\n\tMessage() []byte\n}\n\n// QueueFactory ...\ntype QueueFactory interface {\n\tNewQueue(id string) (Queue, error)\n}\n"
  },
  {
    "path": "internal/queue/redis/queue.go",
    "content": "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\t\"github.com/gomodule/redigo/redis\"\n)\n\ntype redisQueueFactory struct {\n\tpool *redis.Pool\n}\n\ntype redisQueue struct {\n\tq *redq.RedQueue\n}\n\n// NewQueueFactory ...\nfunc NewQueueFactory(url string) queue.QueueFactory {\n\tqf := &redisQueueFactory{\n\t\tpool: &redis.Pool{\n\t\t\tMaxIdle:     3,\n\t\t\tIdleTimeout: 240 * time.Second,\n\t\t\tDial: func() (redis.Conn, error) {\n\t\t\t\treturn redis.DialURL(url)\n\t\t\t},\n\t\t},\n\t}\n\treturn qf\n}\n\nfunc (rq redisQueue) Queue(msg []byte) (err error) {\n\treturn rq.q.Queue(msg)\n}\n\nfunc (rq redisQueue) Get(ctx context.Context) (qm queue.QueuedMessage, err error) {\n\tqm, err = rq.q.Get(ctx)\n\treturn\n}\n\nfunc (rq redisQueue) Remove(qm queue.QueuedMessage) (err error) {\n\treturn rq.q.Remove(qm.(redq.QueuedMessage))\n}\n\nfunc (rq redisQueue) Requeue(qm queue.QueuedMessage) (err error) {\n\treturn rq.q.Requeue(qm.(redq.QueuedMessage))\n}\n\nfunc (rq redisQueue) Shutdown() (err error) {\n\treturn rq.q.Close()\n}\n\nfunc (rqf *redisQueueFactory) NewQueue(id string) (q queue.Queue, err error) {\n\twaitingList := ListName(id)\n\trq, err := redq.NewQueue(rqf.pool, waitingList)\n\tif err != nil {\n\t\treturn\n\t}\n\tq = redisQueue{q: rq}\n\treturn\n}\n\n// ListName returns the Redis list name used for queueing.\nfunc ListName(serviceID string) string {\n\treturn \"shove:\" + serviceID\n}\n"
  },
  {
    "path": "internal/server/feedback.go",
    "content": "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     string `json:\"service\"`\n\tToken       string `json:\"token\"`\n\tReplacement string `json:\"replacement_token,omitempty\"`\n\tReason      string `json:\"reason\"`\n}\n\nfunc (s *Server) handleFeedback(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != \"POST\" {\n\t\thttp.Error(w, \"Invalid request method.\", 405)\n\t\treturn\n\t}\n\ts.feedbackLock.Lock()\n\tj, err := json.Marshal(struct {\n\t\tFeedback []tokenFeedback `json:\"feedback\"`\n\t}{Feedback: s.feedback})\n\tif err != nil {\n\t\ts.feedbackLock.Unlock()\n\t\thttp.Error(w, err.Error(), 500)\n\t\treturn\n\t}\n\ts.feedback = make([]tokenFeedback, 0)\n\ts.feedbackLock.Unlock()\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.Write(j)\n}\n\n// TokenInvalid ...\nfunc (s *Server) TokenInvalid(serviceID, token string) {\n\ts.feedbackLock.Lock()\n\ts.feedback = append(s.feedback, tokenFeedback{serviceID, token, \"\", \"invalid\"})\n\ts.feedbackLock.Unlock()\n\tslog.Info(\"Invalid token\", \"service\", serviceID, \"token\", token)\n}\n\n// ReplaceToken ...\nfunc (s *Server) ReplaceToken(serviceID, token, replacement string) {\n\ts.feedbackLock.Lock()\n\ts.feedback = append(s.feedback, tokenFeedback{serviceID, token, replacement, \"replaced\"})\n\ts.feedbackLock.Unlock()\n\tslog.Info(\"Token replaced\", \"service\", serviceID)\n}\n"
  },
  {
    "path": "internal/server/metrics.go",
    "content": "package server\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/client_golang/prometheus/promauto\"\n\t\"time\"\n)\n\nvar (\n\tpushSuccessCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"shove_push_success_total\",\n\t\tHelp: \"The total number of successful push notifications sent\",\n\t}, []string{\n\t\t\"service\",\n\t})\n\n\tpushErrorCounter = promauto.NewCounterVec(prometheus.CounterOpts{\n\t\tName: \"shove_push_error_total\",\n\t\tHelp: \"The total number of push notifications errored\",\n\t}, []string{\n\t\t\"service\",\n\t})\n)\n\n// CountPush ...\nfunc (s *Server) CountPush(serviceID string, success bool, duration time.Duration) {\n\tif success {\n\t\tpushSuccessCounter.WithLabelValues(serviceID).Inc()\n\t} else {\n\t\tpushErrorCounter.WithLabelValues(serviceID).Inc()\n\t}\n\n}\n"
  },
  {
    "path": "internal/server/push.go",
    "content": "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, r *http.Request) {\n\tservice := strings.TrimPrefix(r.URL.Path, \"/api/push/\")\n\twrk, ok := s.workers[service]\n\tif !ok {\n\t\thttp.NotFound(w, r)\n\t\treturn\n\t}\n\tif r.Method != \"POST\" {\n\t\thttp.Error(w, \"Invalid request method.\", 405)\n\t\treturn\n\t}\n\n\tbody, err := ioutil.ReadAll(r.Body)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\terr = wrk.push(body)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), 500)\n\t\treturn\n\t}\n\tw.WriteHeader(http.StatusAccepted)\n\tfmt.Fprintf(w, \"OK\")\n}\n"
  },
  {
    "path": "internal/server/server.go",
    "content": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"context\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n\t\"sync\"\n)\n\n// Server ...\ntype Server struct {\n\tserver       *http.Server\n\tshuttingDown bool\n\tqueueFactory queue.QueueFactory\n\tworkers      map[string]*worker\n\tfeedbackLock sync.Mutex\n\tfeedback     []tokenFeedback\n}\n\n// NewServer ...\nfunc NewServer(addr string, qf queue.QueueFactory) (s *Server) {\n\tmux := http.NewServeMux()\n\n\th := &http.Server{\n\t\tAddr:    addr,\n\t\tHandler: mux,\n\t}\n\ts = &Server{\n\t\tserver:       h,\n\t\tqueueFactory: qf,\n\t\tworkers:      make(map[string]*worker),\n\t\tfeedback:     make([]tokenFeedback, 0),\n\t}\n\tmux.HandleFunc(\"/api/push/\", s.handlePush)\n\tmux.HandleFunc(\"/api/feedback\", s.handleFeedback)\n\tmux.Handle(\"/metrics\", promhttp.Handler())\n\treturn s\n}\n\n// Serve ...\nfunc (s *Server) Serve() (err error) {\n\tslog.Info(\"Shove server started\")\n\terr = s.server.ListenAndServe()\n\tif s.shuttingDown {\n\t\terr = nil\n\t}\n\treturn\n}\n\n// Shutdown ...\nfunc (s *Server) Shutdown(ctx context.Context) (err error) {\n\ts.shuttingDown = true\n\ts.server.Shutdown(ctx)\n\tif err = s.server.Shutdown(ctx); err != nil {\n\t\tslog.Error(\"Shutting down Shove server\", \"error\", err)\n\t\treturn\n\t}\n\tslog.Info(\"Shove server stopped\")\n\tfor _, w := range s.workers {\n\t\terr = w.shutdown()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\treturn\n}\n\n// AddService ...\nfunc (s *Server) AddService(pp services.PushService, workers int, squash services.SquashConfig) (err error) {\n\tslog.Info(\"Initializing service\", \"service\", pp)\n\tq, err := s.queueFactory.NewQueue(pp.ID())\n\tif err != nil {\n\t\treturn\n\t}\n\tw, err := newWorker(pp, q)\n\tif err != nil {\n\t\treturn\n\t}\n\tgo w.serve(workers, squash, s)\n\ts.workers[pp.ID()] = w\n\treturn\n}\n"
  },
  {
    "path": "internal/server/worker.go",
    "content": "package server\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"context\"\n\t\"golang.org/x/exp/slog\"\n)\n\ntype worker struct {\n\tqueue    queue.Queue\n\tservice  services.PushService\n\tctx      context.Context\n\tcancel   context.CancelFunc\n\tfinished chan (bool)\n}\n\nfunc newWorker(pp services.PushService, queue queue.Queue) (w *worker, err error) {\n\tw = &worker{\n\t\tqueue:    queue,\n\t\tservice:  pp,\n\t\tfinished: make(chan bool),\n\t}\n\tw.ctx, w.cancel = context.WithCancel(context.Background())\n\treturn\n}\n\nfunc (w *worker) push(msg []byte) (err error) {\n\tif err = w.service.Validate(msg); err != nil {\n\t\treturn\n\t}\n\terr = w.queue.Queue(msg)\n\treturn\n}\n\nfunc (w *worker) serve(workers int, squash services.SquashConfig, fc services.FeedbackCollector) {\n\tpump := services.NewPump(workers, squash, w.service)\n\terr := pump.Serve(w.ctx, w.queue, fc)\n\tif err != nil {\n\t\tslog.Error(\"Serve failed\", \"error\", err)\n\t}\n\tw.finished <- true\n}\n\nfunc (w *worker) shutdown() (err error) {\n\tif err = w.queue.Shutdown(); err != nil {\n\t\treturn\n\t}\n\tw.cancel()\n\t<-w.finished\n\treturn\n}\n"
  },
  {
    "path": "internal/services/apns/apns.go",
    "content": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"crypto/tls\"\n\t\"github.com/sideshow/apns2\"\n\t\"github.com/sideshow/apns2/certificate\"\n\t\"golang.org/x/exp/slog\"\n\t\"time\"\n)\n\n// APNS ...\ntype APNS struct {\n\tproduction bool\n\tlog        *slog.Logger\n\tcert       tls.Certificate\n}\n\n// NewAPNS ...\nfunc NewAPNS(pemFile string, production bool, log *slog.Logger) (apns *APNS, err error) {\n\tcert, err := certificate.FromPemFile(pemFile, \"\")\n\tif err != nil {\n\t\treturn\n\t}\n\tapns = &APNS{\n\t\tcert:       cert,\n\t\tproduction: production,\n\t\tlog:        log,\n\t}\n\treturn\n}\n\nfunc (apns *APNS) Logger() *slog.Logger {\n\treturn apns.log\n}\n\nfunc (apns *APNS) NewClient() (pclient services.PumpClient, err error) {\n\tclient := apns2.NewClient(apns.cert)\n\tif apns.production {\n\t\tclient.Production()\n\t} else {\n\t\tclient.Development()\n\t}\n\tpclient = client\n\treturn\n}\n\n// ID ...\nfunc (apns *APNS) ID() string {\n\tif apns.production {\n\t\treturn \"apns\"\n\t}\n\treturn \"apns-sandbox\"\n\n}\n\n// String ...\nfunc (apns *APNS) String() string {\n\tif apns.production {\n\t\treturn \"APNS\"\n\t}\n\treturn \"APNS-sandbox\"\n}\n\nfunc (apns *APNS) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\tpanic(\"not implemented\")\n}\n\nfunc (apns *APNS) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {\n\tclient := pclient.(*apns2.Client)\n\tnotif := smsg.(apnsNotification)\n\tt := time.Now()\n\tresp, err := client.Push(notif.notification)\n\tduration := time.Now().Sub(t)\n\tsent := false\n\tif err != nil {\n\t\tapns.log.Error(\"Push message failed\", \"error\", err)\n\t\tstatus = services.PushStatusTempFail\n\t} else {\n\t\treason := resp.Reason\n\t\tif reason == \"\" {\n\t\t\treason = \"OK\"\n\t\t}\n\t\tapns.log.Info(\"Pushed\", \"reason\", reason, \"duration\", duration)\n\t\tsent = resp.Sent()\n\t\tif resp.Reason == apns2.ReasonBadDeviceToken || resp.Reason == apns2.ReasonUnregistered {\n\t\t\tfc.TokenInvalid(apns.ID(), notif.notification.DeviceToken)\n\t\t}\n\t\tretry := resp.StatusCode >= 500\n\t\tif sent {\n\t\t\tstatus = services.PushStatusSuccess\n\t\t} else if retry {\n\t\t\tstatus = services.PushStatusTempFail\n\t\t} else {\n\t\t\tstatus = services.PushStatusHardFail\n\t\t}\n\t}\n\tfc.CountPush(apns.ID(), sent, duration)\n\treturn\n}\n"
  },
  {
    "path": "internal/services/apns/message.go",
    "content": "package apns\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"github.com/sideshow/apns2\"\n\t\"time\"\n)\n\ntype apnsMessage struct {\n\tToken   string                     `json:\"token\"`\n\tHeaders map[string]json.RawMessage `json:\"headers,omitempty\"`\n\tPayload json.RawMessage            `json:\"payload,omitempty\"`\n}\n\ntype apnsNotification struct {\n\tnotification *apns2.Notification\n}\n\nfunc (notif apnsNotification) GetSquashKey() string {\n\tpanic(\"not implemented\")\n}\n\nfunc (apns *APNS) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {\n\tvar msg apnsMessage\n\tif err = json.Unmarshal(data, &msg); err != nil {\n\t\treturn\n\t}\n\tif msg.Token == \"\" {\n\t\terr = errors.New(\"token required\")\n\t\treturn\n\t}\n\n\tnotif := new(apns2.Notification)\n\tnotif.DeviceToken = msg.Token\n\ttopic, ok := msg.Headers[\"apns-topic\"]\n\tif !ok {\n\t\terr = errors.New(\"APNS requires a topic\")\n\t\treturn\n\t}\n\terr = json.Unmarshal(topic, &notif.Topic)\n\tif err != nil {\n\t\treturn\n\t}\n\tprio, ok := msg.Headers[\"apns-priority\"]\n\tif ok {\n\t\terr = json.Unmarshal(prio, &notif.Priority)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\tcollapse, ok := msg.Headers[\"apns-collapse-id\"]\n\tif ok {\n\t\terr = json.Unmarshal(collapse, &notif.CollapseID)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\texp, ok := msg.Headers[\"apns-expiration\"]\n\tif ok {\n\t\tvar epoch int64\n\t\terr = json.Unmarshal(exp, &epoch)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tnotif.Expiration = time.Unix(epoch, 0)\n\t}\n\tnotif.Payload = msg.Payload\n\tsmsg = apnsNotification{notification: notif}\n\treturn\n}\n\n// Validate ...\nfunc (apns *APNS) Validate(data []byte) (err error) {\n\t_, err = apns.ConvertMessage(data)\n\treturn\n}\n"
  },
  {
    "path": "internal/services/email/encode.go",
    "content": "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/textproto\"\n\n\tjwemail \"github.com/jordan-wright/email\"\n)\n\nfunc encodeSMTPAddress(s string) (string, error) {\n\taddr, err := mail.ParseAddress(s)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn addr.Address, nil\n}\n\nfunc encodeSMTPAddresses(from string, to []string) (encFrom string, encTo []string, err error) {\n\tencFrom, err = encodeSMTPAddress(from)\n\tif err != nil {\n\t\treturn\n\t}\n\tencTo = make([]string, 0, len(to))\n\tfor _, t := range to {\n\t\tt, err = encodeSMTPAddress(t)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tencTo = append(encTo, t)\n\t}\n\treturn\n}\n\nfunc encodeAddress(s string) (string, error) {\n\taddr, err := mail.ParseAddress(s)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn addr.String(), nil\n}\n\nfunc encodeRFC2047(s string) string {\n\treturn mime.QEncoding.Encode(\"UTF-8\", s)\n}\n\nfunc encodeEmail(em email) ([]byte, error) {\n\te := jwemail.NewEmail()\n\te.From = em.From\n\te.To = em.To\n\te.Subject = em.Subject\n\te.Text = []byte(em.Text)\n\te.HTML = []byte(em.HTML)\n\tfor _, atm := range em.Attachments {\n\t\te.Attachments = append(e.Attachments, &jwemail.Attachment{\n\t\t\tFilename:    atm.Filename,\n\t\t\tContent:     atm.Content,\n\t\t\tContentType: atm.ContentType,\n\t\t\tHeader:      textproto.MIMEHeader{},\n\t\t})\n\t}\n\treturn e.Bytes()\n}\n\nfunc encodeEmailDigest(emails []email) ([]byte, error) {\n\tif len(emails) == 0 {\n\t\treturn nil, errors.New(\"no emails specified\")\n\t}\n\tif len(emails) == 1 {\n\t\treturn encodeEmail(emails[0])\n\t}\n\tmixedContent := &bytes.Buffer{}\n\tmixedWriter := multipart.NewWriter(mixedContent)\n\n\tdigWriter, err := nestedMultipart(mixedWriter, \"multipart/digest\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Actual content alternatives (finally!)\n\tfor i, em := range emails {\n\t\tfilename := fmt.Sprintf(\"message-%d.eml\", i+1)\n\t\ts, _ := encodeEmail(em)\n\t\tvar childContent io.Writer\n\t\tchildContent, _ = digWriter.CreatePart(textproto.MIMEHeader{\n\t\t\t\"Content-Type\":        {fmt.Sprintf(\"message/rfc822; name=\\\"%s\\\"\", filename)},\n\t\t\t\"Content-Disposition\": {\"inline; filename=\" + filename},\n\t\t})\n\t\tchildContent.Write([]byte(s))\n\t}\n\tdigWriter.Close()\n\tmixedWriter.Close()\n\n\theaders := make(map[string]string)\n\theaders[\"To\"] = emails[0].To[0]\n\theaders[\"From\"] = emails[0].From\n\tsubject := emails[0].Digest.Subject\n\tif subject == \"\" {\n\t\tsubject = emails[0].Subject\n\t}\n\theaders[\"Subject\"] = subject\n\theaders[\"Content-Type\"] = \"multipart/mixed; boundary=\" + mixedWriter.Boundary()\n\theaders[\"MIME-Version\"] = \"1.0\"\n\n\tvar out bytes.Buffer\n\terr = encodeHeaders(&out, headers)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout.WriteString(mixedContent.String())\n\treturn out.Bytes(), nil\n\n}\n\nfunc encodeHeaders(out *bytes.Buffer, headers map[string]string) error {\n\tfor k, v := range headers {\n\t\tout.WriteString(k)\n\t\tout.WriteString(\": \")\n\t\tif k == \"To\" || k == \"From\" {\n\t\t\tvar err error\n\t\t\tv, err = encodeAddress(v)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t} else if k == \"Subject\" {\n\t\t\tv = encodeRFC2047(v)\n\t\t}\n\t\tout.WriteString(v)\n\t\tout.WriteString(\"\\r\\n\")\n\t}\n\tout.WriteString(\"\\r\\n\")\n\treturn nil\n}\n\nfunc nestedMultipart(enclosingWriter *multipart.Writer, contentType string) (nestedWriter *multipart.Writer, err error) {\n\tboundary, err := randomBoundary()\n\tif err != nil {\n\t\treturn\n\t}\n\tcontentWithBoundary := contentType + \"; boundary=\\\"\" + boundary + \"\\\"\"\n\tcontentBuffer, err := enclosingWriter.CreatePart(textproto.MIMEHeader{\"Content-Type\": {contentWithBoundary}})\n\tif err != nil {\n\t\treturn\n\t}\n\n\tnestedWriter = multipart.NewWriter(contentBuffer)\n\tnestedWriter.SetBoundary(boundary)\n\treturn\n}\n\nfunc randomBoundary() (string, error) {\n\tvar buf [30]byte\n\t_, err := io.ReadFull(rand.Reader, buf[:])\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn fmt.Sprintf(\"%x\", buf[:]), nil\n}\n"
  },
  {
    "path": "internal/services/email/encode_test.go",
    "content": "package email\n\nimport (\n\t\"testing\"\n)\n\nvar png1Pixel = []byte{\n\t0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,\n\t0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x37, 0x6e, 0xf9,\n\t0x24, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x60, 0x01, 0x00, 0x00,\n\t0x00, 0xff, 0xff, 0x03, 0x00, 0x00, 0x06, 0x00, 0x05, 0x57, 0xbf, 0xab, 0xd4, 0x00, 0x00, 0x00,\n\t0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,\n}\n\nfunc TestEncodeEmailDigest(t *testing.T) {\n\temails := []email{\n\t\t{\n\t\t\tSubject: \"Hello Jane!\",\n\t\t\tFrom:    \"john@doe.org\",\n\t\t\tTo:      []string{\"jane@doe.org\"},\n\t\t\tText:    \"Hello Jane!\\n\\nBye\\n\",\n\t\t\tHTML:    \"<p>Hello <b>Jane!</b></p>\",\n\t\t\tAttachments: []attachment{\n\t\t\t\t{\n\t\t\t\t\tFilename:    \"px1.png\",\n\t\t\t\t\tContentType: \"image/png\",\n\t\t\t\t\tContent:     png1Pixel,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tSubject: \"One last thing...!\",\n\t\t\tFrom:    \"john@doe.org\",\n\t\t\tTo:      []string{\"jane@doe.org\"},\n\t\t\tText:    \"Don't forget the milk...\",\n\t\t\tHTML:    \"<p>Don't forget the <b>milk</b></p>\",\n\t\t\tAttachments: []attachment{\n\t\t\t\t{\n\t\t\t\t\tFilename:    \"px1.png\",\n\t\t\t\t\tContentType: \"image/png\",\n\t\t\t\t\tContent:     png1Pixel,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tout, err := encodeEmailDigest(emails)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Log(string(out))\n}\n\nfunc TestEncodeEmail(t *testing.T) {\n\te := email{\n\n\t\tSubject: \"Hello!\",\n\t\tFrom:    \"john@doe.org\",\n\t\tTo:      []string{\"jane@doe.org\"},\n\t\tText:    \"Hello\\n\\nBye\\n\",\n\t\tHTML:    \"<p>Hello <b>world</b></p>\",\n\t\tAttachments: []attachment{\n\t\t\t{\n\t\t\t\tFilename:    \"px1.png\",\n\t\t\t\tContentType: \"image/png\",\n\t\t\t\tContent:     png1Pixel,\n\t\t\t},\n\t\t},\n\t}\n\tout, err := encodeEmail(e)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Log(string(out))\n}\n\nfunc TestEncodeSMTPAddresses(t *testing.T) {\n\tfrom, to, err := encodeSMTPAddresses(\"John <john@doe.org>\", []string{\"jane@doe.org\", \"Nobody <noreply@mail.org>\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif from != \"john@doe.org\" {\n\t\tt.Fatal(from)\n\t}\n\tif to[0] != \"jane@doe.org\" {\n\t\tt.Fatal(to[0])\n\t}\n\tif to[1] != \"noreply@mail.org\" {\n\t\tt.Fatal(to[0])\n\t}\n}\n\nfunc TestEncodeSMTPAddressesErrors(t *testing.T) {\n\t_, _, err := encodeSMTPAddresses(\"John <john@doe.org\", []string{\"jane@doe.org\", \"Nobody <noreply@mail.org>\"})\n\tif err == nil {\n\t\tt.Fail()\n\t}\n\t_, _, err = encodeSMTPAddresses(\"John <john@doe.org>\", []string{\"<jane@doe.org\", \"Nobody <noreply@mail.org>\"})\n\tif err == nil {\n\t\tt.Fail()\n\t}\n}\n"
  },
  {
    "path": "internal/services/email/message.go",
    "content": "package email\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n)\n\ntype attachment struct {\n\tFilename    string `json:\"filename\"`\n\tContentType string `json:\"content-type\"`\n\tContent     []byte `json:\"content\"`\n}\ntype email struct {\n\tSubject     string       `json:\"subject\"`\n\tTo          []string     `json:\"to\"`\n\tFrom        string       `json:\"from\"`\n\tText        string       `json:\"text\"`\n\tHTML        string       `json:\"html\"`\n\tAttachments []attachment `json:\"attachments\"`\n\tDigest      struct {\n\t\tSubject string `json:\"subject\"`\n\t} `json:\"digest\"`\n}\n\nfunc (em email) GetSquashKey() string {\n\treturn em.To[0]\n}\n\nfunc (es *EmailService) ConvertMessage(data []byte) (services.ServiceMessage, error) {\n\tvar em email\n\tif err := json.Unmarshal(data, &em); err != nil {\n\t\treturn em, err\n\t}\n\tif len(em.To) == 0 {\n\t\treturn em, errors.New(\"missing: `to`\")\n\t}\n\tif len(em.To) != 1 {\n\t\treturn em, errors.New(\"only one `to` is supported\")\n\t}\n\tif len(em.From) == 0 {\n\t\treturn em, errors.New(\"missing: `from`\")\n\t}\n\tif len(em.Subject) == 0 {\n\t\treturn em, errors.New(\"missing: `subject`\")\n\t}\n\treturn em, nil\n}\n\nfunc (es *EmailService) Validate(data []byte) error {\n\t_, err := es.ConvertMessage(data)\n\treturn err\n}\n"
  },
  {
    "path": "internal/services/email/send.go",
    "content": "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\nfunc (ec EmailConfig) send(from string, to []string, body []byte, fc services.FeedbackCollector) error {\n\tt := time.Now()\n\taddr := fmt.Sprintf(\"%s:%d\", ec.EmailHost, ec.EmailPort)\n\tvar auth smtp.Auth\n\n\t// Only use it with TLS because net/smtp throws an error when tls is not enabled when using plain auth\n\tif ec.PlainAuth && ec.TLS {\n\t\tauth = smtp.PlainAuth(\"\", ec.EmailUsername, ec.EmailPassword, ec.EmailHost)\n\t}\n\n\tvar err error\n\tfrom, to, err = encodeSMTPAddresses(from, to)\n\tif err == nil {\n\t\tif !ec.TLS {\n\t\t\terr = smtp.SendMail(addr, auth, from, to, body)\n\t\t} else {\n\t\t\terr = ec.sendMailTLS(addr, auth, from, to, body)\n\t\t}\n\t}\n\tduration := time.Since(t)\n\tfc.CountPush(serviceID, err == nil, duration)\n\n\tif err != nil {\n\t\tec.Log.Error(\"Send failed\", \"error\", err)\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (ec EmailConfig) sendMailTLS(addr string, auth smtp.Auth, from string, to []string, body []byte) error {\n\tvar t *tls.Config\n\tif ec.TLSInsecure {\n\t\tt = &tls.Config{InsecureSkipVerify: true}\n\t}\n\tc, err := smtp.Dial(addr)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer c.Close()\n\tif err = c.Hello(\"localhost\"); err != nil {\n\t\treturn err\n\t}\n\t// Use TLS if available\n\tif ok, _ := c.Extension(\"STARTTLS\"); ok {\n\t\tif err = c.StartTLS(t); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif auth != nil {\n\t\tif ok, _ := c.Extension(\"AUTH\"); ok {\n\t\t\tif err = c.Auth(auth); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\tif err = c.Mail(from); err != nil {\n\t\treturn err\n\t}\n\tfor _, addr := range to {\n\t\tif err = c.Rcpt(addr); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tw, err := c.Data()\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = w.Write(body)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = w.Close()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn c.Quit()\n}\n"
  },
  {
    "path": "internal/services/email/service.go",
    "content": "package email\n\nimport (\n\t\"golang.org/x/exp/slog\"\n\n\t\"codeberg.org/pennersr/shove/internal/services\"\n)\n\nconst serviceID = \"email\"\n\ntype EmailConfig struct {\n\tEmailHost     string\n\tEmailPort     int\n\tLog           *slog.Logger\n\tTLS           bool\n\tTLSInsecure   bool\n\tPlainAuth     bool\n\tEmailUsername string\n\tEmailPassword string\n}\n\ntype EmailService struct {\n\tconfig EmailConfig\n}\n\nfunc NewEmailService(config EmailConfig) (es *EmailService, err error) {\n\tes = &EmailService{\n\t\tconfig: config,\n\t}\n\treturn\n}\n\nfunc (es *EmailService) Logger() *slog.Logger {\n\treturn es.config.Log\n}\n\nfunc (es *EmailService) ID() string {\n\treturn serviceID\n}\n\nfunc (es *EmailService) String() string {\n\treturn \"Email\"\n}\n\nfunc (es *EmailService) NewClient() (services.PumpClient, error) {\n\treturn nil, nil\n}\n\nfunc (es *EmailService) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\temails := make([]email, len(smsgs))\n\tfor i, smsg := range smsgs {\n\t\temails[i] = smsg.(email)\n\t}\n\tbody, err := encodeEmailDigest(emails)\n\tif err != nil {\n\t\treturn services.PushStatusHardFail\n\t}\n\treturn es.push(emails[0].From, emails[0].To, body, fc)\n}\n\nfunc (es *EmailService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {\n\temail := smsg.(email)\n\tes.config.Log.Info(\"Sending email\")\n\tbody, err := encodeEmail(email)\n\tif err != nil {\n\t\treturn services.PushStatusHardFail\n\t}\n\treturn es.push(email.From, email.To, body, fc)\n}\nfunc (es *EmailService) push(from string, to []string, body []byte, fc services.FeedbackCollector) services.PushStatus {\n\terr := es.config.send(from, to, body, fc)\n\tif err != nil {\n\t\tes.config.Log.Error(\"Failed to send email\", \"error\", err)\n\t\treturn services.PushStatusHardFail // TODO: smtp down is not a hard failure\n\t}\n\treturn services.PushStatusSuccess\n}\n"
  },
  {
    "path": "internal/services/fcm/fcm.go",
    "content": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"context\"\n\tfirebase \"firebase.google.com/go\"\n\t\"firebase.google.com/go/messaging\"\n\t\"golang.org/x/exp/slog\"\n\t\"google.golang.org/api/option\"\n\t\"strings\"\n\t\"time\"\n)\n\n// FCM ...\ntype FCM struct {\n\tcredentialsFile string\n\tlog             *slog.Logger\n}\n\n// NewFCM ...\nfunc NewFCM(credentialsFile string, log *slog.Logger) (fcm *FCM, err error) {\n\tfcm = &FCM{\n\t\tcredentialsFile: credentialsFile,\n\t\tlog:             log,\n\t}\n\treturn\n}\n\nfunc (fcm *FCM) Logger() *slog.Logger {\n\treturn fcm.log\n}\n\n// ID ...\nfunc (fcm *FCM) ID() string {\n\treturn \"fcm\"\n}\n\n// String ...\nfunc (fcm *FCM) String() string {\n\treturn \"FCM\"\n}\n\nfunc (fcm *FCM) NewClient() (services.PumpClient, error) {\n\topt := option.WithCredentialsFile(fcm.credentialsFile)\n\tctx := context.Background()\n\tapp, err := firebase.NewApp(ctx, nil, opt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclient, err := app.Messaging(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn client, nil\n}\n\nfunc (fcm *FCM) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {\n\tpanic(\"not implemented\")\n}\n\nfunc (fcm *FCM) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\tmsg := smsg.(fcmMessage)\n\tstartedAt := time.Now()\n\tvar success bool\n\n\tclient := pclient.(*messaging.Client)\n\t_, err := client.Send(context.Background(), msg.Message)\n\tduration := time.Now().Sub(startedAt)\n\tdefer func() {\n\t\tfc.CountPush(fcm.ID(), success, duration)\n\t}()\n\tfcm.log.Info(\"Pushed\", \"duration\", duration)\n\tif err != nil {\n\t\t// TODO: Isn't there a better way?\n\t\tif strings.Contains(err.Error(), \"registration-token-not-registered\") {\n\t\t\tfc.TokenInvalid(fcm.ID(), msg.Message.Token)\n\t\t} else {\n\t\t\tfcm.log.Error(\"Posting failed\", \"error\", err)\n\t\t}\n\t\treturn services.PushStatusHardFail\n\t}\n\tsuccess = true\n\treturn services.PushStatusSuccess\n}\n"
  },
  {
    "path": "internal/services/fcm/message.go",
    "content": "package fcm\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"firebase.google.com/go/messaging\"\n)\n\ntype fcmMessage struct {\n\tMessage *messaging.Message `json:\"message\"`\n}\n\nfunc (fcmMessage) GetSquashKey() string {\n\tpanic(\"not implemented\")\n}\n\nfunc (fcm *FCM) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {\n\tvar msg fcmMessage\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn nil, err\n\t}\n\tif msg.Message == nil {\n\t\treturn nil, errors.New(\"message key missing\")\n\t}\n\tif msg.Message.Token == \"\" {\n\t\treturn nil, errors.New(\"no token specified\")\n\t}\n\treturn msg, nil\n}\n\n// Validate ...\nfunc (fcm *FCM) Validate(data []byte) error {\n\t_, err := fcm.ConvertMessage(data)\n\treturn err\n}\n"
  },
  {
    "path": "internal/services/pump.go",
    "content": "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/internal/queue\"\n)\n\ntype Pump struct {\n\twg       sync.WaitGroup\n\tadapter  PumpAdapter\n\tworkers  int\n\tsquasher *squasher\n}\n\ntype ServiceMessage interface {\n\tGetSquashKey() string\n}\n\ntype PushStatus int\n\nconst (\n\t// PushStatusSuccess ...\n\tPushStatusSuccess PushStatus = iota\n\t// PushStatusTempFail signals a failure that may be resolved by retrying\n\tPushStatusTempFail\n\t// PushStatusHardFail signals a failure for which a retry would not hekp\n\tPushStatusHardFail\n)\n\ntype PumpClient interface {\n}\n\ntype PumpAdapter interface {\n\tConvertMessage([]byte) (ServiceMessage, error)\n\tNewClient() (PumpClient, error)\n\tPushMessage(client PumpClient, smsg ServiceMessage, fc FeedbackCollector) PushStatus\n\tSquashAndPushMessage(client PumpClient, smsgs []ServiceMessage, fc FeedbackCollector) PushStatus\n\tLogger() *slog.Logger\n}\n\n// NewPump\nfunc NewPump(workers int, squash SquashConfig, adapter PumpAdapter) (p *Pump) {\n\tp = &Pump{\n\t\tworkers: workers,\n\t\tadapter: adapter,\n\t}\n\tif squash.RateMax > 0 {\n\t\tp.squasher = newSquasher(squash, adapter)\n\t}\n\treturn p\n}\n\nfunc (p *Pump) push(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage, fc FeedbackCollector) (status PushStatus, squashed bool) {\n\tif p.squasher != nil {\n\t\tsquashed = p.squasher.prepareToPush(q, qm, client, smsg)\n\t\tif squashed {\n\t\t\treturn\n\t\t}\n\t}\n\tstatus = p.adapter.PushMessage(client, smsg, fc)\n\treturn\n}\n\nfunc (p *Pump) serveClient(ctx context.Context, q queue.Queue, client PumpClient, fc FeedbackCollector) {\n\tdefer func() {\n\t\tp.wg.Done()\n\t}()\n\tfailureCount := 0\n\tlog := p.adapter.Logger()\n\tfor ctx.Err() == nil {\n\t\tqm, err := q.Get(ctx)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Unable to read from queue\", \"error\", err)\n\t\t\treturn\n\t\t}\n\t\tmsg := qm.Message()\n\t\tsmsg, err := p.adapter.ConvertMessage(msg)\n\t\tif err != nil {\n\t\t\tslog.Error(\"Bad message\", \"error\", err)\n\t\t\tremoveFromQueue(q, qm, log)\n\t\t\tcontinue\n\t\t}\n\t\tstatus, squashed := p.push(q, qm, client, smsg, fc)\n\t\tif squashed {\n\t\t\t// Message should remain in pending queue\n\t\t\tcontinue\n\t\t}\n\t\tif status == PushStatusSuccess || status == PushStatusHardFail {\n\t\t\tremoveFromQueue(q, qm, log)\n\t\t} else {\n\t\t\tif err = q.Requeue(qm); err != nil {\n\t\t\t\tslog.Error(\"Unable to requeue\", \"error\", err)\n\t\t\t}\n\t\t}\n\t\tif status == PushStatusTempFail {\n\t\t\tp.backoff(ctx, failureCount)\n\t\t\tfailureCount++\n\n\t\t} else {\n\t\t\tfailureCount = 0\n\t\t}\n\t}\n}\n\nfunc removeFromQueue(q queue.Queue, qm queue.QueuedMessage, log *slog.Logger) {\n\tif err := q.Remove(qm); err != nil {\n\t\tslog.Error(\"Unable to remove from the queue\", \"error\", err)\n\t}\n}\n\nfunc (p *Pump) backoff(ctx context.Context, failureCount int) {\n\tsleep := time.Duration(float64(time.Second) * math.Min(30, math.Pow(2., float64(failureCount))))\n\tp.adapter.Logger().Info(\"Backing off\", \"duration\", sleep)\n\tctx, cancel := context.WithTimeout(ctx, sleep)\n\tdefer cancel()\n\t<-ctx.Done()\n}\n\nfunc (p *Pump) Serve(ctx context.Context, q queue.Queue, fc FeedbackCollector) (err error) {\n\tlog := p.adapter.Logger()\n\tif p.squasher != nil {\n\t\tp.wg.Add(1)\n\t\tgo func() {\n\t\t\tlog.Info(\"Squasher started\")\n\t\t\tp.squasher.serve(fc)\n\t\t\tlog.Info(\"Squasher stopped\")\n\t\t\tp.wg.Add(-1)\n\t\t}()\n\t}\n\tclients := make([]PumpClient, p.workers)\n\tfor i := 0; i < p.workers; i++ {\n\t\tclients[i], err = p.adapter.NewClient()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\tfor i := 0; i < p.workers; i++ {\n\t\tgo func(client PumpClient) {\n\t\t\tp.serveClient(ctx, q, client, fc)\n\t\t\tif p.squasher != nil {\n\t\t\t\tp.squasher.requestShutdown()\n\t\t\t}\n\t\t}(clients[i])\n\t\tp.wg.Add(1)\n\t}\n\tslog.Info(\"Workers started\", \"worker_count\", p.workers)\n\tp.wg.Wait()\n\tslog.Info(\"Workers stopped\")\n\n\treturn\n}\n"
  },
  {
    "path": "internal/services/services.go",
    "content": "package services\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\n// FeedbackCollector ...\ntype FeedbackCollector interface {\n\tTokenInvalid(serviceID, token string)\n\tReplaceToken(serviceID, token, replacement string)\n\tCountPush(serviceID string, success bool, duration time.Duration)\n}\n\n// PushService ...\ntype PushService interface {\n\tPumpAdapter\n\tfmt.Stringer\n\tID() string\n\tValidate([]byte) error\n}\n"
  },
  {
    "path": "internal/services/squasher.go",
    "content": "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         string\n\tserviceMsgs []ServiceMessage\n\tdue         time.Time\n\tqueuedMsgs  []queue.QueuedMessage\n\tq           queue.Queue\n\tclient      PumpClient\n}\n\ntype SquashConfig struct {\n\tRateMax int\n\tRatePer time.Duration\n}\n\ntype squasher struct {\n\tpushedAt     map[string][]time.Time\n\tbatches      map[string]batch\n\tconfig       SquashConfig\n\tcond         *sync.Cond\n\tlock         sync.Mutex\n\tshuttingDown bool\n\tadapter      PumpAdapter\n}\n\nfunc newSquasher(config SquashConfig, adapter PumpAdapter) (d *squasher) {\n\td = new(squasher)\n\td.adapter = adapter\n\td.config = config\n\td.pushedAt = make(map[string][]time.Time)\n\td.batches = make(map[string]batch)\n\td.cond = sync.NewCond(&d.lock)\n\treturn d\n}\n\nfunc (d *squasher) flushAndGetRate(key string) (sendCount int, sentAt time.Time) {\n\tvar flushedTimes []time.Time\n\ttimes := d.pushedAt[key]\n\tvar didFlush = false\n\tfor _, t := range times {\n\t\tif time.Since(t) > d.config.RatePer {\n\t\t\tdidFlush = true\n\t\t\tcontinue\n\t\t}\n\t\tflushedTimes = append(flushedTimes, t)\n\t\tsendCount++\n\t}\n\tif didFlush {\n\t\td.pushedAt[key] = flushedTimes\n\t}\n\tif len(flushedTimes) > 0 {\n\t\tsentAt = flushedTimes[0]\n\t}\n\treturn\n}\n\nfunc (d *squasher) recordPush(key string) {\n\ttimes := d.pushedAt[key]\n\ttimes = append(times, time.Now())\n\td.pushedAt[key] = times\n}\n\nfunc (d *squasher) prepareToPush(q queue.Queue, qm queue.QueuedMessage, client PumpClient, smsg ServiceMessage) (squashed bool) {\n\td.cond.L.Lock()\n\tdefer d.cond.L.Unlock()\n\n\tkey := smsg.GetSquashKey()\n\tsendCount, firstSendAt := d.flushAndGetRate(key)\n\tif sendCount < d.config.RateMax {\n\t\td.recordPush(key)\n\t\treturn false\n\t}\n\td.adapter.Logger().Info(\"Rate exceeded, squashed\", \"destination\", key)\n\n\tbatch, ok := d.batches[key]\n\tif ok {\n\t\tif batch.q != q {\n\t\t\tpanic(\"squasher cannot handle mixed queues\")\n\t\t}\n\t} else {\n\t\tbatch.q = q\n\t\tbatch.client = client\n\t}\n\tbatch.key = key\n\tbatch.serviceMsgs = append(batch.serviceMsgs, smsg)\n\tbatch.queuedMsgs = append(batch.queuedMsgs, qm)\n\tbatch.due = firstSendAt.Add(d.config.RatePer)\n\td.batches[key] = batch\n\td.cond.Signal()\n\treturn true\n}\n\nfunc (d *squasher) getNextBatch() (b batch, stopped bool) {\n\tfor {\n\t\td.cond.L.Lock()\n\t\tif len(d.batches) == 0 {\n\t\t\td.cond.Wait()\n\t\t}\n\t\tif d.shuttingDown {\n\t\t\td.cond.L.Unlock()\n\t\t\tstopped = true\n\t\t\treturn\n\t\t}\n\t\tvar minDueBatch batch\n\t\tvar minDueBatchKey string\n\t\tfor key, batch := range d.batches {\n\t\t\tif minDueBatch.due.IsZero() || minDueBatch.due.After(batch.due) {\n\t\t\t\tminDueBatch = batch\n\t\t\t\tminDueBatchKey = key\n\t\t\t}\n\t\t}\n\t\tnow := time.Now()\n\t\tif now.After(minDueBatch.due) {\n\t\t\tdelete(d.batches, minDueBatchKey)\n\t\t\td.cond.L.Unlock()\n\t\t\treturn minDueBatch, false\n\t\t}\n\t\td.cond.L.Unlock()\n\n\t\tzzz := minDueBatch.due.Sub(now)\n\t\tmaxZzz := time.Millisecond * 500\n\t\tif zzz > maxZzz {\n\t\t\tzzz = maxZzz\n\t\t}\n\t\ttime.Sleep(zzz)\n\t}\n}\n\nfunc (d *squasher) requestShutdown() {\n\td.cond.L.Lock()\n\td.shuttingDown = true\n\td.cond.Signal()\n\td.cond.L.Unlock()\n}\n\nfunc (d *squasher) shutdown() {\n\td.cond.L.Lock()\n\tdefer d.cond.L.Unlock()\n\n\td.adapter.Logger().Info(\"Shutting down squasher\", \"unsent_batch_count\", len(d.batches))\n}\n\nfunc (d *squasher) serve(fc FeedbackCollector) {\n\tfor {\n\t\tbatch, stopped := d.getNextBatch()\n\t\tif stopped {\n\t\t\td.shutdown()\n\t\t\treturn\n\t\t}\n\t\td.sendBatch(batch, fc)\n\n\t}\n}\n\nfunc (d *squasher) sendBatch(b batch, fc FeedbackCollector) {\n\td.adapter.Logger().Info(\"Sending batch\", \"batch_size\", len(b.serviceMsgs))\n\td.cond.L.Lock()\n\td.recordPush(b.key)\n\td.cond.L.Unlock()\n\n\tstatus := d.adapter.SquashAndPushMessage(b.client, b.serviceMsgs, fc)\n\tswitch status {\n\tcase PushStatusTempFail:\n\t\t// TODO: We should actually attempt to retry this with a backoff\n\t\tfallthrough\n\tcase PushStatusHardFail:\n\t\td.adapter.Logger().Error(\"Failed to send batch\")\n\t\tfallthrough\n\tcase PushStatusSuccess:\n\t\tfor _, qm := range b.queuedMsgs {\n\t\t\tremoveFromQueue(b.q, qm, d.adapter.Logger())\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/services/telegram/message.go",
    "content": "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\"\n)\n\ntype telegramMessage struct {\n\tMethod string `json:\"method\"`\n\t// This is intentionally kept as a raw message so that this can be fed 1:1\n\t// to the API.\n\tPayload       json.RawMessage `json:\"payload\"`\n\tparsedPayload telegramPayload\n}\n\ntype telegramPayload struct {\n\tChatID  string `json:\"chat_id\"`\n\tText    string `json:\"text,omitempty\"`\n\tCaption string `json:\"caption,omitempty\"`\n\tPhoto   string `json:\"photo,omitempty\"`\n}\n\nfunc (msg telegramMessage) GetSquashKey() string {\n\t// TODO: This should include method (`sendMessage`)\n\treturn msg.parsedPayload.ChatID\n}\n\nfunc (tg *TelegramService) ConvertMessage(data []byte) (services.ServiceMessage, error) {\n\tvar msg telegramMessage\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn nil, err\n\t}\n\tif !strings.HasPrefix(msg.Method, \"send\") {\n\t\treturn nil, fmt.Errorf(\"invalid method: %s\", msg.Method)\n\t}\n\t// Telegram documents the chat_id as: \"Integer or String\", we're assuming string.\n\tif err := json.Unmarshal(msg.Payload, &msg.parsedPayload); err != nil {\n\t\treturn nil, err\n\t}\n\tif msg.parsedPayload.ChatID == \"\" {\n\t\treturn nil, errors.New(\"missing `chat_id`\")\n\t}\n\treturn msg, nil\n}\n\n// Validate ...\nfunc (tg *TelegramService) Validate(data []byte) error {\n\t_, err := tg.ConvertMessage(data)\n\treturn err\n}\n\nfunc concatText(builder *strings.Builder, text string) {\n\ttext = strings.TrimSpace(text)\n\tif len(text) == 0 {\n\t\treturn\n\t}\n\ttexts := builder.String()\n\tif len(texts) == 0 || strings.HasSuffix(texts, \"\\n\\n\") {\n\t\t// No newlines needed\n\t} else if strings.HasSuffix(texts, \"\\n\") {\n\t\tbuilder.WriteString(\"\\n\")\n\t} else {\n\t\tbuilder.WriteString(\"\\n\\n\")\n\t}\n\tbuilder.WriteString(text)\n}\n\nfunc trimString(input string, maxLength int) string {\n\tif len(input) <= maxLength {\n\t\treturn input\n\t}\n\ttrimLength := maxLength - 3\n\tif trimLength < 0 {\n\t\ttrimLength = 0\n\t}\n\treturn input[:trimLength] + \"...\"\n}\n\nfunc squashMessages(msgs []telegramMessage) (dmsg telegramMessage, err error) {\n\tif len(msgs) == 0 {\n\t\terr = errors.New(\"need at least one message to digest\")\n\t\treturn\n\t}\n\tdmsg = msgs[0]\n\tvar texts strings.Builder\n\tvar captions strings.Builder\n\tfor _, msg := range msgs {\n\t\tif msg.Method != dmsg.Method {\n\t\t\terr = errors.New(\"cannot digest mix of methods\")\n\t\t\treturn\n\t\t}\n\t\tif msg.parsedPayload.ChatID != dmsg.parsedPayload.ChatID {\n\t\t\terr = errors.New(\"different `chat_id` seen while digesting\")\n\t\t\treturn\n\t\t}\n\t\tconcatText(&texts, msg.parsedPayload.Text)\n\t\tconcatText(&captions, msg.parsedPayload.Caption)\n\t}\n\tdmsg.parsedPayload.Text = trimString(texts.String(), 4095)\n\tdmsg.parsedPayload.Caption = trimString(captions.String(), 1023)\n\tdmsg.Payload, err = json.Marshal(&dmsg.parsedPayload)\n\treturn\n}\n"
  },
  {
    "path": "internal/services/telegram/telegram.go",
    "content": "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\"codeberg.org/pennersr/shove/internal/services\"\n)\n\n// TelegramService ...\ntype TelegramService struct {\n\tbotToken string\n\tlog      *slog.Logger\n}\n\n// NewTelegramService ...\nfunc NewTelegramService(botToken string, log *slog.Logger) (tg *TelegramService, err error) {\n\ttg = &TelegramService{\n\t\tbotToken: botToken,\n\t\tlog:      log,\n\t}\n\treturn\n}\n\nfunc (tg *TelegramService) Logger() *slog.Logger {\n\treturn tg.log\n}\n\n// ID ...\nfunc (tg *TelegramService) ID() string {\n\treturn \"telegram\"\n\n}\n\n// String ...\nfunc (tg *TelegramService) String() string {\n\treturn \"Telegram\"\n}\n\nfunc (tg *TelegramService) NewClient() (services.PumpClient, error) {\n\tclient := &http.Client{\n\t\tTimeout: time.Duration(15 * time.Second),\n\t}\n\treturn client, nil\n}\n\nfunc (tg *TelegramService) SquashAndPushMessage(pclient services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {\n\tclient := pclient.(*http.Client)\n\tmsgs := make([]telegramMessage, len(smsgs))\n\tfor i, smsg := range smsgs {\n\t\tmsgs[i] = smsg.(telegramMessage)\n\t}\n\tdmsg, err := squashMessages(msgs)\n\tif err != nil {\n\t\ttg.log.Error(\"Squashing failed\", \"error\", err)\n\t\treturn services.PushStatusHardFail\n\t}\n\treturn tg.pushMessage(client, dmsg.Method, dmsg.parsedPayload.ChatID, dmsg.Payload, fc)\n}\n\nfunc (tg *TelegramService) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) (status services.PushStatus) {\n\tclient := pclient.(*http.Client)\n\tmsg := smsg.(telegramMessage)\n\treturn tg.pushMessage(client, msg.Method, msg.parsedPayload.ChatID, msg.Payload, fc)\n}\n\nfunc (tg *TelegramService) pushMessage(client *http.Client, method string, chatID string, payload json.RawMessage, fc services.FeedbackCollector) (status services.PushStatus) {\n\tstartedAt := time.Now()\n\tvar success bool\n\n\turl := fmt.Sprintf(\"https://api.telegram.org/bot%s/%s\", tg.botToken, method)\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(payload))\n\tif err != nil {\n\t\ttg.log.Error(\"Failure creating request\", \"error\", err)\n\t\treturn services.PushStatusHardFail\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\ttg.log.Error(\"Posting failed\", \"error\", err)\n\t\treturn services.PushStatusTempFail\n\t}\n\tduration := time.Now().Sub(startedAt)\n\n\tdefer func() {\n\t\tfc.CountPush(tg.ID(), success, duration)\n\t}()\n\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode == 429 {\n\t\ttg.log.Error(\"Throttled, too many requests\", \"status\", 429)\n\t\treturn services.PushStatusTempFail\n\t}\n\n\tvar respData struct {\n\t\tOK          bool   `json:\"ok\"`\n\t\tErrorCode   int    `json:\"error_code\"`\n\t\tDescription string `json:\"description\"`\n\t}\n\n\terr = json.NewDecoder(resp.Body).Decode(&respData)\n\tif err != nil {\n\t\ttg.log.Error(\"Unable to decode response\", \"error\", err)\n\t\treturn services.PushStatusTempFail\n\t}\n\n\t// It's a bit odd that an invalid chat ID results in a 400 instead of a\n\t// special response code {\"ok\":false,\"error_code\":400,\"description\":\"Bad\n\t// Request: chat not found\"}\n\tif respData.ErrorCode == 400 && strings.Contains(respData.Description, \"chat not found\") {\n\t\tfc.TokenInvalid(tg.ID(), chatID)\n\t}\n\tif resp.StatusCode >= 400 && resp.StatusCode < 500 {\n\t\ttg.log.Error(\"Rejected\", \"description\", respData.Description, \"error_code\", respData.ErrorCode, \"status\", resp.StatusCode)\n\t\treturn services.PushStatusHardFail\n\t}\n\tif resp.StatusCode >= 500 && resp.StatusCode < 600 {\n\t\ttg.log.Error(\"Upstream failure\", \"status\", resp.StatusCode)\n\t\treturn services.PushStatusTempFail\n\t}\n\ttg.log.Info(\"Pushed\", \"duration\", duration)\n\treturn services.PushStatusSuccess\n}\n"
  },
  {
    "path": "internal/services/webhook/message.go",
    "content": "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\ntype webhookMessage struct {\n\tURL      string            `json:\"url\"`\n\tHeaders  map[string]string `json:\"headers\"`\n\tBody     string            `json:\"body\"`\n\tData     json.RawMessage   `json:\"data\"`\n\tpostData []byte\n\trawData  []byte\n}\n\nfunc (webhookMessage) GetSquashKey() string {\n\tpanic(\"not implemented\")\n}\n\nfunc (wh *Webhook) ConvertMessage(data []byte) (smsg services.ServiceMessage, err error) {\n\tvar msg webhookMessage\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn nil, err\n\t}\n\tif _, err := url.ParseRequestURI(msg.URL); err != nil {\n\t\treturn nil, err\n\t}\n\tif len(msg.Body) > 0 && len(msg.Data) > 0 {\n\t\treturn nil, errors.New(\"either body or data expected\")\n\t}\n\tif len(msg.Data) > 0 {\n\t\tmsg.postData = []byte(msg.Data)\n\t\tif msg.Headers == nil {\n\t\t\tmsg.Headers = make(map[string]string)\n\t\t}\n\t\tmsg.Headers[\"content-type\"] = \"application/json\"\n\t} else if len(msg.Body) > 0 {\n\t\tmsg.postData = []byte(msg.Body)\n\t}\n\tmsg.rawData = data\n\treturn msg, nil\n}\n\n// Validate ...\nfunc (wh *Webhook) Validate(data []byte) error {\n\t_, err := wh.ConvertMessage(data)\n\treturn err\n}\n"
  },
  {
    "path": "internal/services/webhook/webhook.go",
    "content": "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\"golang.org/x/exp/slog\"\n)\n\ntype Webhook struct {\n\tlog *slog.Logger\n}\n\nfunc NewWebhook(log *slog.Logger) (fcm *Webhook, err error) {\n\tfcm = &Webhook{\n\t\tlog: log,\n\t}\n\treturn\n}\n\nfunc (fcm *Webhook) Logger() *slog.Logger {\n\treturn fcm.log\n}\n\n// ID ...\nfunc (fcm *Webhook) ID() string {\n\treturn \"webhook\"\n}\n\n// String ...\nfunc (fcm *Webhook) String() string {\n\treturn \"Webhook\"\n}\n\nfunc (fcm *Webhook) NewClient() (services.PumpClient, error) {\n\tclient := &http.Client{\n\t\tTimeout: time.Duration(5 * time.Second),\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:    5,\n\t\t\tIdleConnTimeout: 30 * time.Second,\n\t\t},\n\t}\n\treturn client, nil\n}\n\nfunc (wh *Webhook) SquashAndPushMessage(services.PumpClient, []services.ServiceMessage, services.FeedbackCollector) services.PushStatus {\n\tpanic(\"not implemented\")\n}\n\nfunc (wh *Webhook) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\tmsg := smsg.(webhookMessage)\n\tstartedAt := time.Now()\n\tvar success bool\n\n\twh.log.Debug(\"POST\", \"url\", msg.URL, \"data\", string(msg.postData))\n\treq, err := http.NewRequest(\"POST\", msg.URL, bytes.NewBuffer(msg.postData))\n\tif err != nil {\n\t\twh.log.Error(\"Failed to create request\", \"error\", err)\n\t\treturn services.PushStatusHardFail\n\t}\n\tfor k, v := range msg.Headers {\n\t\treq.Header.Set(k, v)\n\t}\n\n\tclient := pclient.(*http.Client)\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\twh.log.Error(\"Failed to post\", \"error\", err)\n\t\treturn services.PushStatusHardFail\n\t}\n\tduration := time.Now().Sub(startedAt)\n\n\tdefer func() {\n\t\tfc.CountPush(wh.ID(), success, duration)\n\t}()\n\n\tbody, error := ioutil.ReadAll(resp.Body)\n\tif error != nil {\n\t\twh.log.Error(\"Failed to read POST response\", \"error\", error)\n\t} else {\n\t\twh.log.Debug(\"POST response\", \"response\", string(body))\n\t}\n\n\tdefer resp.Body.Close()\n\tif resp.StatusCode >= 400 && resp.StatusCode < 500 {\n\t\twh.log.Error(\"Rejected\", \"status\", resp.StatusCode)\n\t\treturn services.PushStatusHardFail\n\t}\n\tif resp.StatusCode >= 500 && resp.StatusCode < 600 {\n\t\twh.log.Error(\"Upstream failure\", \"status\", resp.StatusCode)\n\t\t// A retry might help, but currently retries are not limitted to\n\t\t// a certain number of attempts, meaning, we would keep trying\n\t\t// indefinitely.\n\t\treturn services.PushStatusHardFail\n\t}\n\tsuccess = true\n\treturn services.PushStatusSuccess\n}\n"
  },
  {
    "path": "internal/services/webpush/message.go",
    "content": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\t\"encoding/json\"\n\twpg \"github.com/SherClockHolmes/webpush-go\"\n)\n\ntype webPushMessage struct {\n\tSubscription json.RawMessage `json:\"subscription\"`\n\tPayload      json.RawMessage `json:\"payload\"`\n\tToken        string          `json:\"token\"`\n\tHeaders      struct {\n\t\tTTL     int    `json:\"ttl\"`\n\t\tTopic   string `json:\"topic\"`\n\t\tUrgency string `json:\"urgency\"`\n\t} `json:\"headers\"`\n\n\toptions      wpg.Options\n\tsubscription wpg.Subscription\n}\n\nfunc (msg webPushMessage) GetSquashKey() string {\n\tpanic(\"not implemented\")\n}\n\nfunc (wp *WebPush) ConvertMessage(data []byte) (services.ServiceMessage, error) {\n\tvar msg webPushMessage\n\tif err := json.Unmarshal(data, &msg); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := json.Unmarshal(msg.Subscription, &msg.subscription); err != nil {\n\t\treturn nil, err\n\t}\n\tif msg.Token == \"\" {\n\t\tmsg.Token = string(msg.Subscription)\n\t}\n\tmsg.options = wpg.Options{\n\t\tVAPIDPublicKey:  wp.vapidPublicKey,\n\t\tVAPIDPrivateKey: wp.vapidPrivateKey,\n\t}\n\tmsg.options.Topic = msg.Headers.Topic\n\tif msg.Headers.Urgency != \"\" {\n\t\tmsg.options.Urgency = wpg.Urgency(msg.Headers.Urgency)\n\t}\n\tif msg.Headers.TTL > 0 {\n\t\tmsg.options.TTL = msg.Headers.TTL\n\t}\n\treturn msg, nil\n}\n\n// Validate ...\nfunc (wp *WebPush) Validate(data []byte) error {\n\t_, err := wp.ConvertMessage(data)\n\treturn err\n}\n"
  },
  {
    "path": "internal/services/webpush/message_test.go",
    "content": "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://updates.push.services.mozilla.com/wpush/v2/gAAAAA\",\n\t\"keys\": {\n\t\t\"auth\":\"bHmp2U5UKnWaL-31nal7ew\",\n\t\t\"p256dh\":\"BKedT\"\n\t}\n}`\n\nfunc TestConvert(t *testing.T) {\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\twp, err := NewWebPush(\"pub\", \"pvt\", logger)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsmsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`\n{\n\t\"subscription\": %s,\n\t\"headers\": {\n\t\t\"ttl\": 10,\n\t\t\"urgency\": \"low\"\n\t},\n\t\"payload\": {\"xxx\":\"z\"}\n}\n`, subscription)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmsg := smsg.(webPushMessage)\n\tif msg.options.TTL != 10 {\n\t\tt.Fatal(\"TTL wrong\")\n\t}\n\tif msg.Token != subscription {\n\t\tt.Fatal(\"Token not derived from subscription\")\n\t}\n}\n\nfunc TestConvertWithToken(t *testing.T) {\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\twp, err := NewWebPush(\"pub\", \"pvt\", logger)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsmsg, err := wp.ConvertMessage([]byte(fmt.Sprintf(`\n{\n\t\"subscription\": %s,\n\t\"token\": \"my-token\",\n\t\"headers\": {\n\t\t\"ttl\": 10,\n\t\t\"urgency\": \"low\"\n\t},\n\t\"payload\": {\"xxx\":\"z\"}\n}\n`, subscription)))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tmsg := smsg.(webPushMessage)\n\tif msg.Token != \"my-token\" {\n\t\tt.Fatal(msg.Token)\n\t}\n}\n"
  },
  {
    "path": "internal/services/webpush/webpush.go",
    "content": "package webpush\n\nimport (\n\t\"codeberg.org/pennersr/shove/internal/queue\"\n\t\"codeberg.org/pennersr/shove/internal/services\"\n\twpg \"github.com/SherClockHolmes/webpush-go\"\n\t\"golang.org/x/exp/slog\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// WebPush ...\ntype WebPush struct {\n\tvapidPublicKey  string\n\tvapidPrivateKey string\n\tlog             *slog.Logger\n}\n\n// NewWebPush ...\nfunc NewWebPush(vapidPub, vapidPvt string, log *slog.Logger) (wp *WebPush, err error) {\n\twp = &WebPush{\n\t\tvapidPrivateKey: vapidPvt,\n\t\tvapidPublicKey:  vapidPub,\n\t\tlog:             log,\n\t}\n\treturn\n}\n\nfunc (wp *WebPush) Logger() *slog.Logger {\n\treturn wp.log\n}\n\nfunc (wp *WebPush) NewClient() (services.PumpClient, error) {\n\tclient := &http.Client{\n\t\tTimeout: time.Duration(15 * time.Second),\n\t\tTransport: &http.Transport{\n\t\t\tMaxIdleConns:    5,\n\t\t\tIdleConnTimeout: 30 * time.Second,\n\t\t},\n\t}\n\treturn client, nil\n}\n\n// ID ...\nfunc (wp *WebPush) ID() string {\n\treturn \"webpush\"\n}\n\n// String ...\nfunc (wp *WebPush) String() string {\n\treturn \"WebPush\"\n}\n\nfunc (wp *WebPush) SquashAndPushMessage(client services.PumpClient, smsgs []services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\tpanic(\"not implemented\")\n}\n\nfunc (wp *WebPush) PushMessage(pclient services.PumpClient, smsg services.ServiceMessage, fc services.FeedbackCollector) services.PushStatus {\n\tsuccess := false\n\tmsg := smsg.(webPushMessage)\n\tmsg.options.HTTPClient = pclient.(*http.Client)\n\tstartedAt := time.Now()\n\t// Send Notification\n\tresp, err := wpg.SendNotification(msg.Payload, &msg.subscription, &msg.options)\n\tif err != nil {\n\t\twp.log.Error(\"Failed to send\", \"error\", err)\n\t\treturn services.PushStatusHardFail\n\t}\n\tdefer resp.Body.Close()\n\tduration := time.Now().Sub(startedAt)\n\twp.log.Info(\"Pushed\", \"status\", resp.StatusCode, \"duration\", duration)\n\tdefer func() {\n\t\tfc.CountPush(wp.ID(), success, duration)\n\t}()\n\tswitch resp.StatusCode {\n\tcase 201:\n\t\t//  201 Created. The request to send a push message was received and accepted.\n\t\tsuccess = true\n\t\treturn services.PushStatusSuccess\n\n\tcase 429:\n\t\t// 429 Too many requests. Meaning your application server has\n\t\t// reached a rate limit with a push service. The push service\n\t\t// should include a 'Retry-After' header to indicate how long\n\t\t// before another request can be made.\n\t\treturn services.PushStatusTempFail\n\n\tcase 400:\n\t\t// 400 Invalid request. This generally means one of your headers is invalid or improperly formatted.\n\t\treturn services.PushStatusHardFail\n\n\tcase 404:\n\t\t// 404 Not Found. This is an indication that the subscription is\n\t\t// expired and can't be used. In this case you should delete the\n\t\t// `PushSubscription` and wait for the client to resubscribe the\n\t\t// user.\n\t\tfallthrough\n\tcase 410:\n\t\t// 410 Gone. The subscription is no longer valid and should be\n\t\t// removed from application server. This can be reproduced by\n\t\t// calling `unsubscribe()` on a `PushSubscription`.\n\t\tfc.TokenInvalid(wp.ID(), msg.Token)\n\t\treturn services.PushStatusHardFail\n\n\tdefault:\n\t\t// 413 Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb).\n\t\treturn services.PushStatusHardFail\n\t}\n}\n\nfunc (wp *WebPush) remove(q queue.Queue, qm queue.QueuedMessage) {\n\tif err := q.Remove(qm); err != nil {\n\t\twp.log.Error(\"Failed to remove from the queue\", \"error\", err)\n\t}\n}\n"
  },
  {
    "path": "pkg/shove/client.go",
    "content": "package shove\n\nimport (\n\tshvredis \"codeberg.org/pennersr/shove/internal/queue/redis\"\n\t\"github.com/gomodule/redigo/redis\"\n\t\"time\"\n)\n\n// Client ...\ntype Client interface {\n\tPushRaw(serviceID string, data []byte) (err error)\n}\n\ntype redisClient struct {\n\tpool *redis.Pool\n}\n\n// NewRedisClient ...\nfunc NewRedisClient(redisURL string) Client {\n\trc := &redisClient{\n\t\tpool: &redis.Pool{\n\t\t\tMaxIdle:     3,\n\t\t\tIdleTimeout: 240 * time.Second,\n\t\t\tDial: func() (redis.Conn, error) {\n\t\t\t\treturn redis.DialURL(redisURL)\n\t\t\t},\n\t\t},\n\t}\n\treturn rc\n}\n\n// PushRaw ...\nfunc (rc *redisClient) PushRaw(id string, data []byte) (err error) {\n\twaitingList := shvredis.ListName(id)\n\tconn := rc.pool.Get()\n\tdefer conn.Close()\n\t_, err = conn.Do(\"RPUSH\", waitingList, data)\n\treturn\n}\n"
  },
  {
    "path": "scripts/email.json",
    "content": "{\n    \"digest\": {\n        \"subject\": \"Hello Digest\"\n    },\n    \"subject\": \"Hello world!\",\n    \"from\": \"jane@doe.org\",\n    \"to\": [\"john@doe.org\"],\n    \"text\": \"Hello World!\\n\\nGreetings.\\n\",\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>\",\n    \"attachments\": [\n        {\n            \"filename\": \"document.png\",\n            \"content-type\": \"image/png\",\n            \"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==\"\n        }\n    ]\n}\n"
  }
]