aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Anshul Gupta <ansg191@anshulg.com> 2024-08-11 13:15:50 -0700
committerGravatar Anshul Gupta <ansg191@anshulg.com> 2024-08-11 13:15:50 -0700
commit6a3c21fb0b1c126849f2bbff494403bbe901448e (patch)
tree5d7805524357c2c8a9819c39d2051a4e3633a1d5
parent29c6040a51616e9e4cf6c70ee16391b2a3b238c9 (diff)
parentf34b92ded11b07f78575ac62c260a380c468e5ea (diff)
downloadibd-trader-6a3c21fb0b1c126849f2bbff494403bbe901448e.tar.gz
ibd-trader-6a3c21fb0b1c126849f2bbff494403bbe901448e.tar.zst
ibd-trader-6a3c21fb0b1c126849f2bbff494403bbe901448e.zip
Merge remote-tracking branch 'backend/main'
-rw-r--r--backend/.dockerignore32
-rw-r--r--backend/.github/workflows/datadog.yaml37
-rw-r--r--backend/.github/workflows/docker.yaml74
-rw-r--r--backend/.github/workflows/go.yaml63
-rw-r--r--backend/.gitignore118
-rw-r--r--backend/.gitmodules3
-rw-r--r--backend/.idea/.gitignore8
-rw-r--r--backend/.idea/dataSources.xml19
-rw-r--r--backend/.idea/ibd-trader.iml9
-rw-r--r--backend/.idea/modules.xml8
-rw-r--r--backend/.idea/sqldialects.xml6
-rw-r--r--backend/.idea/vcs.xml7
-rw-r--r--backend/Dockerfile79
m---------backend/api0
-rw-r--r--backend/cmd/main.go187
-rw-r--r--backend/db/embed.go6
-rw-r--r--backend/db/migrations/000001_create_user_tables.down.sql5
-rw-r--r--backend/db/migrations/000001_create_user_tables.up.sql47
-rw-r--r--backend/db/migrations/000002_create_data_tables.down.sql11
-rw-r--r--backend/db/migrations/000002_create_data_tables.up.sql89
-rw-r--r--backend/go.mod113
-rw-r--r--backend/go.sum1327
-rw-r--r--backend/internal/analyzer/analyzer.go32
-rw-r--r--backend/internal/analyzer/openai/openai.go126
-rw-r--r--backend/internal/analyzer/openai/openai_test.go1
-rw-r--r--backend/internal/analyzer/openai/options.go45
-rw-r--r--backend/internal/analyzer/openai/system.txt34
-rw-r--r--backend/internal/auth/auth.go55
-rw-r--r--backend/internal/config/config.go114
-rw-r--r--backend/internal/database/cookies.go189
-rw-r--r--backend/internal/database/database.go166
-rw-r--r--backend/internal/database/database_test.go79
-rw-r--r--backend/internal/database/stocks.go293
-rw-r--r--backend/internal/database/users.go151
-rw-r--r--backend/internal/ibd/auth.go333
-rw-r--r--backend/internal/ibd/auth_test.go215
-rw-r--r--backend/internal/ibd/check_ibd_username.go68
-rw-r--r--backend/internal/ibd/client.go97
-rw-r--r--backend/internal/ibd/client_test.go201
-rw-r--r--backend/internal/ibd/html_helpers.go99
-rw-r--r--backend/internal/ibd/html_helpers_test.go79
-rw-r--r--backend/internal/ibd/ibd50.go182
-rw-r--r--backend/internal/ibd/options.go26
-rw-r--r--backend/internal/ibd/search.go111
-rw-r--r--backend/internal/ibd/search_test.go205
-rw-r--r--backend/internal/ibd/stockinfo.go233
-rw-r--r--backend/internal/ibd/transport/scrapfly/options.go84
-rw-r--r--backend/internal/ibd/transport/scrapfly/scraper_types.go253
-rw-r--r--backend/internal/ibd/transport/scrapfly/scrapfly.go103
-rw-r--r--backend/internal/ibd/transport/standard.go41
-rw-r--r--backend/internal/ibd/transport/transport.go66
-rw-r--r--backend/internal/ibd/userinfo.go156
-rw-r--r--backend/internal/keys/gcp.go131
-rw-r--r--backend/internal/keys/keys.go150
-rw-r--r--backend/internal/keys/keys_test.go64
-rw-r--r--backend/internal/keys/mock_keys_test.go156
-rw-r--r--backend/internal/leader/election/election.go128
-rw-r--r--backend/internal/leader/manager/ibd/auth/auth.go111
-rw-r--r--backend/internal/leader/manager/ibd/ibd.go8
-rw-r--r--backend/internal/leader/manager/ibd/scrape/scrape.go140
-rw-r--r--backend/internal/leader/manager/manager.go90
-rw-r--r--backend/internal/leader/manager/monitor.go164
-rw-r--r--backend/internal/redis/taskqueue/options.go9
-rw-r--r--backend/internal/redis/taskqueue/queue.go545
-rw-r--r--backend/internal/redis/taskqueue/queue_test.go467
-rw-r--r--backend/internal/server/idb/stock/v1/stock.go64
-rw-r--r--backend/internal/server/idb/user/v1/user.go159
-rw-r--r--backend/internal/server/operations.go142
-rw-r--r--backend/internal/server/server.go77
-rw-r--r--backend/internal/utils/money.go99
-rw-r--r--backend/internal/utils/money_test.go106
-rw-r--r--backend/internal/worker/analyzer/analyzer.go142
-rw-r--r--backend/internal/worker/auth/auth.go239
-rw-r--r--backend/internal/worker/scraper/scraper.go198
-rw-r--r--backend/internal/worker/worker.go151
75 files changed, 9625 insertions, 0 deletions
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..9e03c48
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,32 @@
+# Include any files or directories that you don't want to be copied to your
+# container here (e.g., local build artifacts, temporary files, etc.).
+#
+# For more help, visit the .dockerignore file reference guide at
+# https://docs.docker.com/go/build-context-dockerignore/
+
+**/.DS_Store
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/bin
+**/charts
+**/docker-compose*
+**/compose.y*ml
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
diff --git a/backend/.github/workflows/datadog.yaml b/backend/.github/workflows/datadog.yaml
new file mode 100644
index 0000000..e7418f3
--- /dev/null
+++ b/backend/.github/workflows/datadog.yaml
@@ -0,0 +1,37 @@
+on: [push]
+
+name: Datadog Analysis
+
+jobs:
+ static-analysis:
+ runs-on: ubuntu-latest
+ name: Datadog Static Analyzer
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Check code meets quality and security standards
+ id: datadog-static-analysis
+ uses: DataDog/datadog-static-analyzer-github-action@v1
+ with:
+ dd_api_key: ${{ secrets.DD_API_KEY }}
+ dd_app_key: ${{ secrets.DD_APP_KEY }}
+ dd_service: ibd-trader-backend
+ dd_env: ci
+ dd_site: datadoghq.com
+ cpu_count: 2
+
+ software-composition-analysis:
+ runs-on: ubuntu-latest
+ name: Datadog SBOM Generation and Upload
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Check imported libraries are secure and compliant
+ id: datadog-software-composition-analysis
+ uses: DataDog/datadog-sca-github-action@main
+ with:
+ dd_api_key: ${{ secrets.DD_API_KEY }}
+ dd_app_key: ${{ secrets.DD_APP_KEY }}
+ dd_service: ibd-trader-backend
+ dd_env: ci
+ dd_site: datadoghq.com
diff --git a/backend/.github/workflows/docker.yaml b/backend/.github/workflows/docker.yaml
new file mode 100644
index 0000000..cf6aec9
--- /dev/null
+++ b/backend/.github/workflows/docker.yaml
@@ -0,0 +1,74 @@
+name: Docker
+
+on:
+ # schedule:
+ # - cron: '45 13 * * *'
+ push:
+ branches: [ "main" ]
+ # Publish semver tags as releases.
+ tags: [ 'v*.*.*' ]
+ pull_request:
+ branches: [ "main" ]
+
+env:
+ # Use docker.io for Docker Hub if empty
+ REGISTRY: ghcr.io
+ # github.repository as <account>/<repo>
+ IMAGE_NAME: ${{ github.repository }}
+
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ # Set up BuildKit Docker container builder to be able to build
+ # multi-platform images and export cache
+ # https://github.com/docker/setup-buildx-action
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Login against a Docker registry except on PR
+ # https://github.com/docker/login-action
+ - name: Log into registry ${{ env.REGISTRY }}
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Extract metadata (tags, labels) for Docker
+ # https://github.com/docker/metadata-action
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+ # Build and push Docker image with Buildx (don't push on PR)
+ # https://github.com/docker/build-push-action
+ - name: Build and push Docker image
+ id: build-and-push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/backend/.github/workflows/go.yaml b/backend/.github/workflows/go.yaml
new file mode 100644
index 0000000..2eb8dd2
--- /dev/null
+++ b/backend/.github/workflows/go.yaml
@@ -0,0 +1,63 @@
+name: Go
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ services:
+ dind:
+ image: docker:dind-rootless
+ ports:
+ - 2375:2375
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+ - uses: actions/setup-go@v5
+ with:
+ go-version: stable
+ - uses: bufbuild/buf-action@v1
+ with:
+ setup_only: true
+ - name: Install mockgen
+ run: go install go.uber.org/mock/mockgen@latest
+ - name: Generate
+ run: go generate -v ./...
+ - name: Build
+ run: go build -v ./...
+ - name: Test
+ run: go test -v ./...
+
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+ - uses: actions/setup-go@v5
+ with:
+ go-version: stable
+ - uses: bufbuild/buf-action@v1
+ with:
+ setup_only: true
+ - name: Install mockgen
+ run: go install go.uber.org/mock/mockgen@latest
+ - name: Generate
+ run: go generate -v ./...
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: v1.59
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..03cab26
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,118 @@
+config.yaml
+
+### VisualStudioCode template
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### Go template
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+
+### GoLand template
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
diff --git a/backend/.gitmodules b/backend/.gitmodules
new file mode 100644
index 0000000..83084c1
--- /dev/null
+++ b/backend/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "api"]
+ path = api
+ url = https://github.com/ansg191/ibd-trader-api.git
diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/backend/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/backend/.idea/dataSources.xml b/backend/.idea/dataSources.xml
new file mode 100644
index 0000000..66cd183
--- /dev/null
+++ b/backend/.idea/dataSources.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
+ <data-source source="LOCAL" name="postgres@localhost" uuid="5df21c3c-074c-4cb3-91b3-a87c24731550">
+ <driver-ref>postgresql</driver-ref>
+ <synchronize>true</synchronize>
+ <jdbc-driver>org.postgresql.Driver</jdbc-driver>
+ <jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
+ <working-dir>$ProjectFileDir$</working-dir>
+ </data-source>
+ <data-source source="LOCAL" name="0@localhost" uuid="5306b4f0-7d5f-4ef3-a260-54bd3d0c859c">
+ <driver-ref>redis</driver-ref>
+ <synchronize>true</synchronize>
+ <jdbc-driver>jdbc.RedisDriver</jdbc-driver>
+ <jdbc-url>jdbc:redis://localhost:6379/0</jdbc-url>
+ <working-dir>$ProjectFileDir$</working-dir>
+ </data-source>
+ </component>
+</project> \ No newline at end of file
diff --git a/backend/.idea/ibd-trader.iml b/backend/.idea/ibd-trader.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/backend/.idea/ibd-trader.iml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="WEB_MODULE" version="4">
+ <component name="Go" enabled="true" />
+ <component name="NewModuleRootManager">
+ <content url="file://$MODULE_DIR$" />
+ <orderEntry type="inheritedJdk" />
+ <orderEntry type="sourceFolder" forTests="false" />
+ </component>
+</module> \ No newline at end of file
diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml
new file mode 100644
index 0000000..fe04cd3
--- /dev/null
+++ b/backend/.idea/modules.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="ProjectModuleManager">
+ <modules>
+ <module fileurl="file://$PROJECT_DIR$/.idea/ibd-trader.iml" filepath="$PROJECT_DIR$/.idea/ibd-trader.iml" />
+ </modules>
+ </component>
+</project> \ No newline at end of file
diff --git a/backend/.idea/sqldialects.xml b/backend/.idea/sqldialects.xml
new file mode 100644
index 0000000..6df4889
--- /dev/null
+++ b/backend/.idea/sqldialects.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="SqlDialectMappings">
+ <file url="PROJECT" dialect="PostgreSQL" />
+ </component>
+</project> \ No newline at end of file
diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml
new file mode 100644
index 0000000..7036dcb
--- /dev/null
+++ b/backend/.idea/vcs.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="VcsDirectoryMappings">
+ <mapping directory="" vcs="Git" />
+ <mapping directory="$PROJECT_DIR$/api" vcs="Git" />
+ </component>
+</project> \ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..caef54a
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,79 @@
+# syntax=docker/dockerfile:1
+################################################################################
+# Create a stage for building the application.
+ARG GO_VERSION=1.22.5
+FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build
+WORKDIR /src
+
+# Download dependencies as a separate step to take advantage of Docker's caching.
+# Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds.
+# Leverage bind mounts to go.sum and go.mod to avoid having to copy them into
+# the container.
+RUN --mount=type=cache,target=/go/pkg/mod/ \
+ --mount=type=bind,source=go.sum,target=go.sum \
+ --mount=type=bind,source=go.mod,target=go.mod \
+ go mod download -x
+
+# This is the architecture you're building for, which is passed in by the builder.
+# Placing it here allows the previous steps to be cached across architectures.
+ARG TARGETARCH
+
+# Specify the versions of the tools to use.
+ARG BUF_VERSION=1.35.1
+ARG MOCKGEN_VERSION=0.4.0
+
+# Install required tools for build
+RUN go install "go.uber.org/mock/mockgen@v${MOCKGEN_VERSION}" && \
+ curl -sSL "https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-$(uname -s)-$(uname -m)" \
+ -o "/usr/local/bin/buf" && \
+ chmod +x /usr/local/bin/buf && \
+ buf --version
+
+# Bind mounts are read-only, so we copy the source code into the container.
+COPY . .
+
+# Build the application.
+# Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds.
+RUN --mount=type=cache,target=/go/pkg/mod/ \
+ go generate -v ./... && \
+ CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /bin/server ./cmd
+
+################################################################################
+# Create a new stage for running the application that contains the minimal
+# runtime dependencies for the application. This often uses a different base
+# image from the build stage where the necessary files are copied from the build
+# stage.
+FROM alpine:latest AS final
+
+# Install any runtime dependencies that are needed to run your application.
+# Leverage a cache mount to /var/cache/apk/ to speed up subsequent builds.
+RUN --mount=type=cache,target=/var/cache/apk \
+ apk --update add \
+ ca-certificates \
+ tzdata \
+ tini \
+ && \
+ update-ca-certificates
+
+# Create a non-privileged user that the app will run under.
+# See https://docs.docker.com/go/dockerfile-user-best-practices/
+ARG UID=10001
+RUN adduser \
+ --disabled-password \
+ --gecos "" \
+ --home "/nonexistent" \
+ --shell "/sbin/nologin" \
+ --no-create-home \
+ --uid "${UID}" \
+ appuser
+USER appuser
+
+# Copy the executable from the "build" stage.
+COPY --from=build /bin/server /bin/
+
+# Expose the port that the application listens on.
+EXPOSE 8000
+
+# What the container should run when it is started.
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["/bin/server"]
diff --git a/backend/api b/backend/api
new file mode 160000
+Subproject 250159ea00457090d9cc7b3cc270618d14ce9a9
diff --git a/backend/cmd/main.go b/backend/cmd/main.go
new file mode 100644
index 0000000..2a34780
--- /dev/null
+++ b/backend/cmd/main.go
@@ -0,0 +1,187 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/analyzer/openai"
+ auth2 "github.com/ansg191/ibd-trader-backend/internal/auth"
+ "github.com/ansg191/ibd-trader-backend/internal/config"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport/scrapfly"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/election"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager"
+ "github.com/ansg191/ibd-trader-backend/internal/server"
+ "github.com/ansg191/ibd-trader-backend/internal/worker"
+
+ "github.com/lmittmann/tint"
+ "github.com/redis/go-redis/v9"
+)
+
+func main() {
+ // Load the config
+ cfg, err := config.New()
+ if err != nil {
+ log.Fatal("Unable to load config: ", err)
+ }
+
+ // Setup slog
+ var level slog.Level
+ if err = level.UnmarshalText([]byte(cfg.Log.Level)); err != nil {
+ log.Fatal("Unable to parse log level: ", err)
+ }
+ var logger *slog.Logger
+ opts := &tint.Options{
+ AddSource: cfg.Log.AddSource,
+ Level: level,
+ NoColor: !cfg.Log.Color,
+ }
+ logger = slog.New(tint.NewHandler(os.Stdout, opts))
+ slog.SetDefault(logger)
+
+ logger.Info(
+ "Starting IBD Trader...",
+ "logger.level", level,
+ )
+
+ // Create kms
+ kms, err := createKMS()
+ if err != nil {
+ log.Fatal("Unable to create KMS: ", err)
+ }
+
+ // Connect to the database
+ db, err := connectDB(logger, cfg, kms)
+ defer func(db database.Database) {
+ _ = db.Close()
+ }(db)
+ if err != nil {
+ log.Fatal("Unable to connect to database: ", err)
+ }
+
+ // Connect to redis
+ redisClient := redis.NewClient(&redis.Options{
+ Addr: cfg.Redis.Addr,
+ Password: cfg.Redis.Password,
+ })
+ defer redisClient.Close()
+
+ // Setup auth
+ auth, err := auth2.New(cfg)
+ if err != nil {
+ log.Fatal("Unable to setup auth: ", err)
+ }
+ _ = auth
+
+ // Setup IBD client
+ client, err := setupIBDClient(cfg, db, kms)
+ if err != nil {
+ log.Fatal("Unable to setup IBD client: ", err)
+ }
+
+ // Setup analyzer
+ analyzer := openai.NewAnalyzer(openai.WithDefaultConfig(cfg.Analyzer.OpenAI.APIKey))
+ _ = analyzer
+
+ // Setup context w/ signal handling
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer cancel()
+
+ //// Start the server
+ //go func() {
+ // if err := server.StartServer(ctx, cfg, logger, db, auth, client, redisClient); err != nil {
+ // log.Fatal("Unable to start server: ", err)
+ // }
+ // // Cancel the context when the server stops
+ // cancel()
+ //}()
+
+ // Start the gRPC server
+ go func() {
+ s, err := server.New(ctx, cfg.Server.Port, db, redisClient, client, kms, cfg.KMS.GCP.String())
+ if err != nil {
+ log.Fatal("Unable to create gRPC server: ", err)
+ }
+ if err := s.Serve(ctx); err != nil {
+ slog.ErrorContext(ctx, "Unable to start gRPC server", "error", err)
+ }
+ // Cancel the context when the server stops
+ cancel()
+ }()
+
+ // Start the worker
+ go func() {
+ err := worker.StartWorker(
+ ctx,
+ client,
+ redisClient,
+ db,
+ kms,
+ analyzer,
+ )
+ if err != nil {
+ log.Fatal("Unable to start worker: ", err)
+ }
+ // Cancel the context when the worker stops
+ cancel()
+ }()
+
+ // Start leader election
+ election.RunOrDie(
+ ctx,
+ redisClient,
+ func(ctx context.Context) {
+ m, err := manager.New(ctx, cfg, redisClient, db, client)
+ if err != nil {
+ logger.Error("Unable to create manager", "error", err)
+ return
+ }
+ if err = m.Run(ctx); err != nil {
+ logger.Error("Manager exited with error", "error", err)
+ }
+ },
+ )
+}
+
+func setupIBDClient(cfg *config.Config, db database.Database, kms keys.KeyManagementService) (*ibd.Client, error) {
+ pUrl, err := url.Parse(cfg.IBD.ProxyURL)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse proxy URL: %w", err)
+ }
+ t := http.DefaultTransport.(*http.Transport).Clone()
+ t.Proxy = http.ProxyURL(pUrl)
+ transports := []transport.Transport{
+ transport.NewStandardTransport(&http.Client{Transport: t}), // Default proxied transport
+ scrapfly.New(http.DefaultClient, cfg.IBD.APIKey), // Scrapfly transport
+ }
+ client := ibd.NewClient(db, kms, transports...)
+ return client, nil
+}
+
+func createKMS() (keys.KeyManagementService, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ return keys.NewGoogleKMS(ctx)
+}
+
+func connectDB(logger *slog.Logger, cfg *config.Config, kms keys.KeyManagementService) (database.Database, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ db, err := database.New(ctx, logger, cfg.DB.URL, kms, cfg.KMS.GCP.String())
+ if err != nil {
+ return nil, err
+ }
+
+ return db, nil
+}
diff --git a/backend/db/embed.go b/backend/db/embed.go
new file mode 100644
index 0000000..e0a4e0b
--- /dev/null
+++ b/backend/db/embed.go
@@ -0,0 +1,6 @@
+package db
+
+import "embed"
+
+//go:embed migrations/*.sql
+var Migrations embed.FS
diff --git a/backend/db/migrations/000001_create_user_tables.down.sql b/backend/db/migrations/000001_create_user_tables.down.sql
new file mode 100644
index 0000000..f1ba1c0
--- /dev/null
+++ b/backend/db/migrations/000001_create_user_tables.down.sql
@@ -0,0 +1,5 @@
+DROP INDEX IF EXISTS idx_ibd_tokens_user_subject;
+
+DROP TABLE IF EXISTS ibd_tokens;
+DROP TABLE IF EXISTS users;
+DROP TABLE IF EXISTS keys;
diff --git a/backend/db/migrations/000001_create_user_tables.up.sql b/backend/db/migrations/000001_create_user_tables.up.sql
new file mode 100644
index 0000000..6681aa6
--- /dev/null
+++ b/backend/db/migrations/000001_create_user_tables.up.sql
@@ -0,0 +1,47 @@
+/**
+ * Keys Table
+ * The keys table stores the encryption keys used to encrypt user data.
+ * It holds encrypted AES keys and the KMS key used to encrypt them.
+ */
+CREATE TABLE IF NOT EXISTS keys
+(
+ id SERIAL PRIMARY KEY, -- Unique ID for the key
+ kms_key_name VARCHAR(255) NOT NULL, -- The name of the KMS key
+ encrypted_key BYTEA NOT NULL, -- The encrypted AES key (encrypted with the KMS key)
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- The datetime the key was created
+);
+
+/**
+ * Users Table
+ * The users table stores the user data for the app.
+ */
+CREATE TABLE IF NOT EXISTS users
+(
+ subject TEXT PRIMARY KEY, -- The unique subject of the user, retrieved from Auth0
+ ibd_username VARCHAR(255), -- The IBD username of the user
+ ibd_password BYTEA, -- The encrypted IBD password. Encrypted with `encryption_key`
+ encryption_key INTEGER REFERENCES keys (id) -- The encryption key used to encrypt the IBD password
+);
+
+/**
+ * IBD Tokens Table
+ * The IBD tokens table stores the tokens/cookies used to authenticate with the IBD website.
+ * These are scraped using chromedb and stored in the database for future use via APIs.
+ */
+CREATE TABLE IF NOT EXISTS ibd_tokens
+(
+ id SERIAL PRIMARY KEY, -- Unique ID for the token
+ user_subject TEXT NOT NULL REFERENCES users (subject), -- The user in the users table associated with the token
+ token BYTEA NOT NULL, -- The encrypted token/cookie
+ encryption_key INTEGER NOT NULL REFERENCES keys (id), -- The encryption key used to encrypt the token
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- The datetime the token was created
+ expires_at TIMESTAMP NOT NULL, -- The datetime the token expires
+ /**
+ * The `degraded` column is used to indicate that the token MAY be invalid.
+ * It is set to TRUE when a worker reports a failure using the token.
+ * This will cause the authentication cronjob to check the token and re-authenticate if necessary.
+ */
+ degraded BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE INDEX IF NOT EXISTS idx_ibd_tokens_user_subject ON ibd_tokens (user_subject);
diff --git a/backend/db/migrations/000002_create_data_tables.down.sql b/backend/db/migrations/000002_create_data_tables.down.sql
new file mode 100644
index 0000000..640fe94
--- /dev/null
+++ b/backend/db/migrations/000002_create_data_tables.down.sql
@@ -0,0 +1,11 @@
+DROP INDEX IF EXISTS ratings_symbol_idx;
+
+DROP TABLE IF EXISTS stock_data;
+DROP TABLE IF EXISTS ratings;
+DROP TABLE IF EXISTS chart_analysis;
+DROP TABLE IF EXISTS stock_rank;
+DROP TABLE IF EXISTS stocks;
+
+DROP TYPE IF EXISTS CHART_ACTION;
+DROP TYPE IF EXISTS BAR_INTERVAL;
+DROP TYPE IF EXISTS RANK_TYPE; \ No newline at end of file
diff --git a/backend/db/migrations/000002_create_data_tables.up.sql b/backend/db/migrations/000002_create_data_tables.up.sql
new file mode 100644
index 0000000..f279aa3
--- /dev/null
+++ b/backend/db/migrations/000002_create_data_tables.up.sql
@@ -0,0 +1,89 @@
+/**
+ * Stocks table
+ * The stocks table stores the basic information about each stock.
+ * This data is scraped periodically from the IBD website.
+ */
+CREATE TABLE IF NOT EXISTS stocks
+(
+ symbol VARCHAR(16) PRIMARY KEY, -- The stock symbol/ticker
+ name TEXT NOT NULL, -- The full name of the stock
+ ibd_url TEXT -- The URL to the IBD page for the stock
+);
+
+/**
+ * Stock Rank table
+ * The stock rank table stores the rank information for each stock.
+ * This data is scraped periodically from the IBD website.
+ */
+CREATE TYPE RANK_TYPE AS ENUM ('ibd50', 'cap20');
+CREATE TABLE IF NOT EXISTS stock_rank
+(
+ symbol VARCHAR(16) NOT NULL REFERENCES stocks (symbol), -- The stock symbol from the stocks table
+ rank_type RANK_TYPE NOT NULL, -- The type of rank (IBD 50 or CAP 20)
+ rank SMALLINT, -- The rank of the stock
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- The datetime the rank was scraped/created
+);
+
+/**
+ * Chart Analysis table
+ * The chart analysis table stores the chart analysis for each stock.
+ * This data is scraped periodically from the IBD website then analyzed by OpenAI's GPT-4o.
+ */
+CREATE TYPE CHART_ACTION AS ENUM ('buy', 'sell', 'hold', 'unknown');
+CREATE TABLE IF NOT EXISTS chart_analysis
+(
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Randomly generated UUID for the chart analysis
+ raw_analysis TEXT NOT NULL, -- The raw chart analysis text (e.g. "Flat base, 3 weeks tight")
+
+ processed BOOLEAN NOT NULL DEFAULT FALSE, -- Whether the chart analysis has been processed by GPT-4o
+ action CHART_ACTION, -- The action to take based on the chart analysis
+ price MONEY, -- The price to take the action at
+ reason TEXT, -- The reason for the action
+ confidence SMALLINT -- The confidence level of the action (0-100)
+);
+
+/**
+ * Ratings table
+ * The ratings table stores the ratings for each stock.
+ * This data is scraped periodically from the IBD website.
+ */
+CREATE TABLE IF NOT EXISTS ratings
+(
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Randomly generated UUID for the rating
+ symbol VARCHAR(16) NOT NULL REFERENCES stocks (symbol), -- The stock symbol from the stocks table
+
+ composite SMALLINT, -- The composite rating (0-99)
+ eps SMALLINT, -- The EPS rating (0-99)
+ rel_str SMALLINT, -- The relative strength rating (0-99)
+ group_rel_str CHAR(2), -- The group relative strength rating (E-A+) E=0, A+=13
+ smr CHAR(2), -- The SMR rating (E-A+)
+ acc_dis CHAR(2), -- The acc/dis rating (E-A+)
+ chart_analysis UUID REFERENCES chart_analysis (id), -- The ID of the chart analysis for the stock
+ price MONEY, -- The price of the stock at the time of the rating
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- The datetime the rating was scraped/created
+);
+
+CREATE INDEX IF NOT EXISTS ratings_symbol_idx ON ratings (symbol);
+
+/**
+ * Stock Data table
+ * The stock data table stores the historical price data for each stock.
+ * This data should be retrieved from a brokerage API or other data source.
+ * These should be in the format of minute and daily bars.
+ */
+CREATE TYPE BAR_INTERVAL AS ENUM ('1m', '1d');
+CREATE TABLE IF NOT EXISTS stock_data
+(
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Randomly generated UUID for the stock data
+ symbol VARCHAR(16) NOT NULL REFERENCES stocks (symbol), -- The stock symbol from the stocks table
+
+ bar_interval BAR_INTERVAL NOT NULL, -- The interval of the bar data (1m, 1d)
+ time TIMESTAMP NOT NULL, -- The timestamp of the bar
+
+ open MONEY NOT NULL, -- The opening price of the bar
+ high MONEY NOT NULL, -- The high price of the bar
+ low MONEY NOT NULL, -- The low price of the bar
+ close MONEY NOT NULL, -- The closing price of the bar
+ volume BIGINT NOT NULL -- The volume of the bar
+);
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..7b717b2
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,113 @@
+module github.com/ansg191/ibd-trader-backend
+
+go 1.22.0
+
+toolchain go1.22.5
+
+require (
+ cloud.google.com/go/kms v1.18.4
+ cloud.google.com/go/longrunning v0.5.11
+ github.com/EDDYCJY/fake-useragent v0.2.0
+ github.com/Rhymond/go-money v1.0.14
+ github.com/bsm/redislock v0.9.4
+ github.com/buraksezer/consistent v0.10.0
+ github.com/cespare/xxhash/v2 v2.3.0
+ github.com/coreos/go-oidc/v3 v3.11.0
+ github.com/golang-migrate/migrate/v4 v4.17.1
+ github.com/jarcoal/httpmock v1.3.1
+ github.com/lib/pq v1.10.9
+ github.com/lmittmann/tint v1.0.5
+ github.com/mennanov/fmutils v0.3.0
+ github.com/ory/dockertest/v3 v3.10.0
+ github.com/redis/go-redis/v9 v9.6.1
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/sashabaranov/go-openai v1.27.1
+ github.com/spf13/viper v1.19.0
+ github.com/stretchr/testify v1.9.0
+ go.uber.org/mock v0.4.0
+ golang.org/x/net v0.27.0
+ golang.org/x/oauth2 v0.21.0
+ golang.org/x/sync v0.7.0
+ google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf
+ google.golang.org/grpc v1.65.0
+ google.golang.org/protobuf v1.34.2
+)
+
+require (
+ cloud.google.com/go v0.115.0 // indirect
+ cloud.google.com/go/auth v0.7.3 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
+ cloud.google.com/go/compute/metadata v0.5.0 // indirect
+ cloud.google.com/go/iam v1.1.12 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+ github.com/Microsoft/go-winio v0.6.1 // indirect
+ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
+ github.com/PuerkitoBio/goquery v1.9.2 // indirect
+ github.com/andybalholm/cascadia v1.3.2 // indirect
+ github.com/cenkalti/backoff/v4 v4.1.3 // indirect
+ github.com/containerd/continuity v0.3.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/docker/cli v20.10.17+incompatible // indirect
+ github.com/docker/docker v24.0.9+incompatible // indirect
+ github.com/docker/go-connections v0.4.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.4 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/google/s2a-go v0.1.8 // indirect
+ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
+ github.com/googleapis/gax-go/v2 v2.13.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/imdario/mergo v0.3.12 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/moby/term v0.5.0 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.0.2 // indirect
+ github.com/opencontainers/runc v1.1.5 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/rogpeppe/go-internal v1.10.0 // indirect
+ github.com/sagikazarmark/locafero v0.6.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sirupsen/logrus v1.9.2 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/cast v1.6.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ github.com/xeipuuv/gojsonschema v1.2.0 // indirect
+ go.opencensus.io v0.24.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
+ go.opentelemetry.io/otel v1.28.0 // indirect
+ go.opentelemetry.io/otel/metric v1.28.0 // indirect
+ go.opentelemetry.io/otel/trace v1.28.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/crypto v0.25.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/mod v0.19.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
+ golang.org/x/time v0.5.0 // indirect
+ golang.org/x/tools v0.23.0 // indirect
+ google.golang.org/api v0.190.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v2 v2.3.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..a5a8ea5
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,1327 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
+cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
+cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
+cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
+cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
+cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
+cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
+cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
+cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
+cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
+cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
+cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
+cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
+cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
+cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
+cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
+cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA=
+cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM=
+cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
+cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
+cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4=
+cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw=
+cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o=
+cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE=
+cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw=
+cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY=
+cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg=
+cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI=
+cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4=
+cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk=
+cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc=
+cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc=
+cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04=
+cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno=
+cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak=
+cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4=
+cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0=
+cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ=
+cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk=
+cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0=
+cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc=
+cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o=
+cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s=
+cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0=
+cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ=
+cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY=
+cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY=
+cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw=
+cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI=
+cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
+cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
+cloud.google.com/go/auth v0.7.3 h1:98Vr+5jMaCZ5NZk6e/uBgf60phTk/XN84r8QEWB9yjY=
+cloud.google.com/go/auth v0.7.3/go.mod h1:HJtWUx1P5eqjy/f6Iq5KeytNpbAcGolPhOgyop2LlzA=
+cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
+cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
+cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
+cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8=
+cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8=
+cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM=
+cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc=
+cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI=
+cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE=
+cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE=
+cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4=
+cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA=
+cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw=
+cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc=
+cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY=
+cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s=
+cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI=
+cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y=
+cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM=
+cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI=
+cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0=
+cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk=
+cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg=
+cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590=
+cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk=
+cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk=
+cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U=
+cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA=
+cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM=
+cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk=
+cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY=
+cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI=
+cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4=
+cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI=
+cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
+cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
+cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
+cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
+cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
+cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
+cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU=
+cloud.google.com/go/compute v1.12.0/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU=
+cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU=
+cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE=
+cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU=
+cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
+cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
+cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
+cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY=
+cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck=
+cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg=
+cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo=
+cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I=
+cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4=
+cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0=
+cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs=
+cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc=
+cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H0/tc8f8ZbZIE=
+cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM=
+cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM=
+cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ=
+cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo=
+cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE=
+cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0=
+cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38=
+cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w=
+cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I=
+cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ=
+cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA=
+cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A=
+cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s=
+cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI=
+cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo=
+cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM=
+cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo=
+cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ=
+cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g=
+cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4=
+cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c=
+cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s=
+cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4=
+cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0=
+cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8=
+cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz0njv7sMx/iek=
+cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0=
+cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM=
+cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q=
+cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU=
+cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU=
+cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k=
+cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4=
+cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y=
+cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg=
+cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk=
+cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w=
+cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU=
+cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI=
+cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8=
+cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc=
+cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw=
+cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w=
+cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI=
+cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
+cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk=
+cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg=
+cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY=
+cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08=
+cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM=
+cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA=
+cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w=
+cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM=
+cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60=
+cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo=
+cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o=
+cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A=
+cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0=
+cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0=
+cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA=
+cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI=
+cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc=
+cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM=
+cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o=
+cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
+cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc=
+cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc=
+cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg=
+cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE=
+cloud.google.com/go/iam v1.1.12 h1:JixGLimRrNGcxvJEQ8+clfLxPlbeZA6MuRJ+qJNQ5Xw=
+cloud.google.com/go/iam v1.1.12/go.mod h1:9LDX8J7dN5YRyzVHxwQzrQs9opFFqn0Mxs9nAeB+Hhg=
+cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
+cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
+cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM=
+cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY=
+cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs=
+cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g=
+cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
+cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg=
+cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0=
+cloud.google.com/go/kms v1.18.4 h1:dYN3OCsQ6wJLLtOnI8DGUwQ5shMusXsWCCC+s09ATsk=
+cloud.google.com/go/kms v1.18.4/go.mod h1:SG1bgQ3UWW6/KdPo9uuJnzELXY5YTTMJtDYvajiQ22g=
+cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
+cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI=
+cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE=
+cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8=
+cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8=
+cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08=
+cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw=
+cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE=
+cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
+cloud.google.com/go/longrunning v0.5.11 h1:Havn1kGjz3whCfoD8dxMLP73Ph5w+ODyZB9RUsDxtGk=
+cloud.google.com/go/longrunning v0.5.11/go.mod h1:rDn7//lmlfWV1Dx6IB4RatCPenTwwmqXuiP0/RgoEO4=
+cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE=
+cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM=
+cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI=
+cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4=
+cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w=
+cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE=
+cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM=
+cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA=
+cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY=
+cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY=
+cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s=
+cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8=
+cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI=
+cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk=
+cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4=
+cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA=
+cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o=
+cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM=
+cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8=
+cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8=
+cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4=
+cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ=
+cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU=
+cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY=
+cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34=
+cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA=
+cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0=
+cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4=
+cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs=
+cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA=
+cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk=
+cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE=
+cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc=
+cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs=
+cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg=
+cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo=
+cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw=
+cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E=
+cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU=
+cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70=
+cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo=
+cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0=
+cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA=
+cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg=
+cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE=
+cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0=
+cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI=
+cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0=
+cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg=
+cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4=
+cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o=
+cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk=
+cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo=
+cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE=
+cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U=
+cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg=
+cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4=
+cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg=
+cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c=
+cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs=
+cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70=
+cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y=
+cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A=
+cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA=
+cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM=
+cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA=
+cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0=
+cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU=
+cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg=
+cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4=
+cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY=
+cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc=
+cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y=
+cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do=
+cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo=
+cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s=
+cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI=
+cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk=
+cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44=
+cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA=
+cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4=
+cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4=
+cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4=
+cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0=
+cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU=
+cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q=
+cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA=
+cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU=
+cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc=
+cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk=
+cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk=
+cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU=
+cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s=
+cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs=
+cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg=
+cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4=
+cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U=
+cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco=
+cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo=
+cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E=
+cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU=
+cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4=
+cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw=
+cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos=
+cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM=
+cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ=
+cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0=
+cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
+cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
+cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
+cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
+cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
+cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw=
+cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g=
+cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM=
+cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA=
+cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8=
+cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4=
+cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ=
+cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg=
+cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28=
+cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y=
+cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs=
+cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg=
+cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk=
+cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw=
+cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU=
+cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4=
+cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M=
+cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU=
+cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0=
+cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo=
+cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo=
+cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY=
+cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E=
+cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE=
+cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g=
+cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208=
+cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w=
+cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8=
+cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE=
+cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg=
+cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc=
+cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A=
+cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo=
+cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ=
+cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0=
+cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M=
+cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M=
+cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/EDDYCJY/fake-useragent v0.2.0 h1:Jcnkk2bgXmDpX0z+ELlUErTkoLb/mxFBNd2YdcpvJBs=
+github.com/EDDYCJY/fake-useragent v0.2.0/go.mod h1:5wn3zzlDxhKW6NYknushqinPcAqZcAPHy8lLczCdJdc=
+github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
+github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
+github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
+github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/Rhymond/go-money v1.0.14 h1:HtdIZ0mP4LrnpN3wdRhsik7pool7x22ILZdDe3moL6E=
+github.com/Rhymond/go-money v1.0.14/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
+github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
+github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw=
+github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk=
+github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW25rrZlU=
+github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw=
+github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
+github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg=
+github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM=
+github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
+github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg=
+github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA=
+github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M=
+github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
+github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
+github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
+github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
+github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
+github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
+github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
+github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
+github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
+github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
+github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
+github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
+github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
+github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
+github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
+github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
+github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
+github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
+github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
+github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
+github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
+github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
+github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
+github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
+github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
+github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
+github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
+github.com/mennanov/fmutils v0.3.0 h1:2YSyrO8oOLQQwB/iKe+xDDGO6xCUHiIAj3gYhY7D4Ao=
+github.com/mennanov/fmutils v0.3.0/go.mod h1:ph1jsu8gV1gUgMURCmfIVbXKG3O2/O5o/UbPbbqu8zs=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
+github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/GDEs=
+github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg=
+github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI=
+github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4=
+github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
+github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
+github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sashabaranov/go-openai v1.27.1 h1:7Nx6db5NXbcoutNmAUQulEQZEpHG/SkzfexP2X5RWMk=
+github.com/sashabaranov/go-openai v1.27.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
+github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
+github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
+github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
+github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
+go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
+go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
+go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
+go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
+go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
+go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
+go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
+go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
+go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
+golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
+golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
+golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
+golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
+google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
+google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
+google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
+google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
+google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
+google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
+google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
+google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
+google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
+google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
+google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
+google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
+google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
+google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
+google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
+google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
+google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
+google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
+google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
+google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
+google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
+google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
+google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
+google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
+google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
+google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
+google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
+google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
+google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91A08=
+google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70=
+google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo=
+google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
+google.golang.org/api v0.190.0 h1:ASM+IhLY1zljNdLu19W1jTmU6A+gMk6M46Wlur61s+Q=
+google.golang.org/api v0.190.0/go.mod h1:QIr6I9iedBLnfqoD6L6Vze1UvS5Hzj5r2aUBOaZnLHo=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
+google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
+google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
+google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
+google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE=
+google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
+google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
+google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
+google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
+google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
+google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
+google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
+google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw=
+google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
+google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
+google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U=
+google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM=
+google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM=
+google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s=
+google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s=
+google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo=
+google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
+google.golang.org/genproto v0.0.0-20221117204609-8f9c96812029/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
+google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
+google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
+google.golang.org/genproto v0.0.0-20230202175211-008b39050e57/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
+google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf h1:OqdXDEakZCVtDiZTjcxfwbHPCT11ycCEsTKesBVKvyY=
+google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:mCr1K1c8kX+1iSBREvU3Juo11CB+QOEWxbRS01wWl5M=
+google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf h1:GillM0Ef0pkZPIB+5iO6SDK+4T9pf6TpaYR6ICD5rVE=
+google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:OFMYQFHJ4TM3JRlWDZhJbZfra2uqc3WLBZiaaqP4DtU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf h1:liao9UHurZLtiEwBgT9LMOnKYsHze6eA6w1KQCMVN2Q=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
+google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
+google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
+google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
+google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
+google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
+google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
+gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/backend/internal/analyzer/analyzer.go b/backend/internal/analyzer/analyzer.go
new file mode 100644
index 0000000..c055647
--- /dev/null
+++ b/backend/internal/analyzer/analyzer.go
@@ -0,0 +1,32 @@
+package analyzer
+
+import (
+ "context"
+
+ "github.com/Rhymond/go-money"
+)
+
+type Analyzer interface {
+ Analyze(
+ ctx context.Context,
+ symbol string,
+ price *money.Money,
+ rawAnalysis string,
+ ) (*Analysis, error)
+}
+
+type ChartAction string
+
+const (
+ Buy ChartAction = "buy"
+ Sell ChartAction = "sell"
+ Hold ChartAction = "hold"
+ Unknown ChartAction = "unknown"
+)
+
+type Analysis struct {
+ Action ChartAction
+ Price *money.Money
+ Reason string
+ Confidence uint8
+}
diff --git a/backend/internal/analyzer/openai/openai.go b/backend/internal/analyzer/openai/openai.go
new file mode 100644
index 0000000..0419c57
--- /dev/null
+++ b/backend/internal/analyzer/openai/openai.go
@@ -0,0 +1,126 @@
+package openai
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/analyzer"
+ "github.com/ansg191/ibd-trader-backend/internal/utils"
+
+ "github.com/Rhymond/go-money"
+ "github.com/sashabaranov/go-openai"
+)
+
+type Client interface {
+ CreateChatCompletion(
+ ctx context.Context,
+ request openai.ChatCompletionRequest,
+ ) (response openai.ChatCompletionResponse, err error)
+}
+
+type Analyzer struct {
+ client Client
+ model string
+ systemMsg string
+ temperature float32
+}
+
+func NewAnalyzer(opts ...Option) *Analyzer {
+ a := &Analyzer{
+ client: nil,
+ model: defaultModel,
+ systemMsg: defaultSystemMsg,
+ temperature: defaultTemperature,
+ }
+ for _, option := range opts {
+ option(a)
+ }
+ if a.client == nil {
+ panic("client is required")
+ }
+
+ return a
+}
+
+func (a *Analyzer) Analyze(
+ ctx context.Context,
+ symbol string,
+ price *money.Money,
+ rawAnalysis string,
+) (*analyzer.Analysis, error) {
+ usrMsg := fmt.Sprintf(
+ "%s\n%s\n%s\n%s\n",
+ time.Now().Format(time.RFC3339),
+ symbol,
+ price.Display(),
+ rawAnalysis,
+ )
+ res, err := a.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
+ Model: a.model,
+ Messages: []openai.ChatCompletionMessage{
+ {
+ Role: openai.ChatMessageRoleSystem,
+ Content: a.systemMsg,
+ },
+ {
+ Role: openai.ChatMessageRoleUser,
+ Content: usrMsg,
+ },
+ },
+ MaxTokens: 0,
+ Temperature: a.temperature,
+ ResponseFormat: &openai.ChatCompletionResponseFormat{Type: openai.ChatCompletionResponseFormatTypeJSONObject},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var resp response
+ if err = json.Unmarshal([]byte(res.Choices[0].Message.Content), &resp); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal gpt response: %w", err)
+ }
+
+ var action analyzer.ChartAction
+ switch strings.ToLower(resp.Action) {
+ case "buy":
+ action = analyzer.Buy
+ case "sell":
+ action = analyzer.Sell
+ case "hold":
+ action = analyzer.Hold
+ default:
+ action = analyzer.Unknown
+ }
+
+ m, err := utils.ParseMoney(resp.Price)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse price: %w", err)
+ }
+
+ confidence, err := strconv.ParseFloat(resp.Confidence, 64)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse confidence: %w", err)
+ }
+ if confidence < 0 || confidence > 100 {
+ return nil, fmt.Errorf("confidence must be between 0 and 100, got %f", confidence)
+ }
+
+ return &analyzer.Analysis{
+ Action: action,
+ Price: m,
+ Reason: resp.Reason,
+ Confidence: uint8(math.Floor(confidence)),
+ }, nil
+}
+
+type response struct {
+ Action string `json:"action"`
+ Price string `json:"price"`
+ Reason string `json:"reason"`
+ Confidence string `json:"confidence"`
+}
diff --git a/backend/internal/analyzer/openai/openai_test.go b/backend/internal/analyzer/openai/openai_test.go
new file mode 100644
index 0000000..0aac709
--- /dev/null
+++ b/backend/internal/analyzer/openai/openai_test.go
@@ -0,0 +1 @@
+package openai
diff --git a/backend/internal/analyzer/openai/options.go b/backend/internal/analyzer/openai/options.go
new file mode 100644
index 0000000..11d691f
--- /dev/null
+++ b/backend/internal/analyzer/openai/options.go
@@ -0,0 +1,45 @@
+package openai
+
+import (
+ _ "embed"
+
+ "github.com/sashabaranov/go-openai"
+)
+
+//go:embed system.txt
+var defaultSystemMsg string
+
+const defaultModel = openai.GPT4o
+const defaultTemperature = 0.25
+
+type Option func(*Analyzer)
+
+func WithClientConfig(cfg openai.ClientConfig) Option {
+ return func(a *Analyzer) {
+ a.client = openai.NewClientWithConfig(cfg)
+ }
+}
+
+func WithDefaultConfig(apiKey string) Option {
+ return func(a *Analyzer) {
+ a.client = openai.NewClient(apiKey)
+ }
+}
+
+func WithModel(model string) Option {
+ return func(a *Analyzer) {
+ a.model = model
+ }
+}
+
+func WithSystemMsg(msg string) Option {
+ return func(a *Analyzer) {
+ a.systemMsg = msg
+ }
+}
+
+func WithTemperature(temp float32) Option {
+ return func(a *Analyzer) {
+ a.temperature = temp
+ }
+}
diff --git a/backend/internal/analyzer/openai/system.txt b/backend/internal/analyzer/openai/system.txt
new file mode 100644
index 0000000..82e3b2a
--- /dev/null
+++ b/backend/internal/analyzer/openai/system.txt
@@ -0,0 +1,34 @@
+You're a stock analyzer.
+You will be given a stock symbol, its current price, and its chart analysis.
+Your job is to determine the best course of action to do with the stock (buy, sell, hold, or unknown),
+the price at which the action should be taken, the reason for the action, and the confidence (0-100)
+level you have in the action.
+
+The reason should be a paragraph explaining why you chose the action.
+
+The date the chart analysis was done may be mentioned in the chart analysis.
+Make sure to take that into account when making your decision.
+If the chart analysis is older than 1 week, lower your confidence accordingly and mention that in the reason.
+If the chart analysis is too outdated, set the action to "unknown".
+
+The information will be given in the following format:
+```
+<current datetime>
+<stock symbol>
+<current price>
+<chart analysis>
+```
+
+Your response should be in the following JSON format:
+```
+{
+ "action": "<action>",
+ "price": "<price>",
+ "reason": "<reason>",
+ "confidence": "<confidence>"
+}
+```
+All fields are required and must be strings.
+`action` must be one of the following: "buy", "sell", "hold", or "unknown".
+`price` must contain the symbol for the currency and the price (e.g. "$100").
+The system WILL validate your response. \ No newline at end of file
diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go
new file mode 100644
index 0000000..edad914
--- /dev/null
+++ b/backend/internal/auth/auth.go
@@ -0,0 +1,55 @@
+package auth
+
+import (
+ "context"
+ "errors"
+
+ "github.com/ansg191/ibd-trader-backend/internal/config"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "golang.org/x/oauth2"
+)
+
+// Authenticator is used to authenticate our users.
+type Authenticator struct {
+ *oidc.Provider
+ oauth2.Config
+}
+
+// New instantiates the *Authenticator.
+func New(cfg *config.Config) (*Authenticator, error) {
+ provider, err := oidc.NewProvider(
+ context.Background(),
+ "https://"+cfg.Auth.Domain+"/",
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ conf := oauth2.Config{
+ ClientID: cfg.Auth.ClientID,
+ ClientSecret: cfg.Auth.ClientSecret,
+ RedirectURL: cfg.Auth.CallbackURL,
+ Endpoint: provider.Endpoint(),
+ Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ }
+
+ return &Authenticator{
+ Provider: provider,
+ Config: conf,
+ }, nil
+}
+
+// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
+func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ return nil, errors.New("no id_token field in oauth2 token")
+ }
+
+ oidcConfig := &oidc.Config{
+ ClientID: a.ClientID,
+ }
+
+ return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
new file mode 100644
index 0000000..c37588b
--- /dev/null
+++ b/backend/internal/config/config.go
@@ -0,0 +1,114 @@
+package config
+
+import (
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd"
+
+ "github.com/spf13/viper"
+)
+
+type Config struct {
+ // Logging configuration
+ Log struct {
+ // Log level
+ Level string
+ // Add source info to log messages
+ AddSource bool
+ // Enable colorized output
+ Color bool
+ }
+ // Database configuration
+ DB struct {
+ // Database URL
+ URL string
+ }
+ // Redis configuration
+ Redis struct {
+ // Redis address
+ Addr string
+ // Redis password
+ Password string
+ }
+ // KMS configuration
+ KMS struct {
+ // GCP KMS configuration
+ GCP *keys.GCPKeyName
+ }
+ // Server configuration
+ Server struct {
+ // Server port
+ Port uint16
+ }
+ // OAuth 2.0 configuration
+ Auth struct {
+ // OAuth 2.0 domain
+ Domain string
+ // OAuth 2.0 client ID
+ ClientID string
+ // OAuth 2.0 client secret
+ ClientSecret string
+ // OAuth 2.0 callback URL
+ CallbackURL string
+ }
+ // IBD configuration
+ IBD struct {
+ // Scraper API Key
+ APIKey string
+ // Proxy URL
+ ProxyURL string
+ // Scrape schedules. In cron format.
+ Schedules ibd.Schedules
+ }
+ // Analyzer configuration
+ Analyzer struct {
+ // Use OpenAI for analysis
+ OpenAI *struct {
+ // OpenAI API Key
+ APIKey string
+ }
+ }
+}
+
+func New() (*Config, error) {
+ v := viper.New()
+
+ v.SetDefault("log.level", "INFO")
+ v.SetDefault("log.addSource", false)
+ v.SetDefault("log.color", false)
+ v.SetDefault("server.port", 8000)
+
+ v.SetConfigName("config")
+ v.AddConfigPath("/etc/ibd-trader/")
+ v.AddConfigPath("$HOME/.ibd-trader")
+ v.AddConfigPath(".")
+ err := v.ReadInConfig()
+ if err != nil {
+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+ // Config file not found; ignore error
+ } else {
+ return nil, err
+ }
+ }
+
+ v.MustBindEnv("db.url", "DATABASE_URL")
+ v.MustBindEnv("redis.addr", "REDIS_ADDR")
+ v.MustBindEnv("redis.password", "REDIS_PASSWORD")
+ v.MustBindEnv("log.level", "LOG_LEVEL")
+ v.MustBindEnv("server.port", "SERVER_PORT")
+ v.MustBindEnv("auth.domain", "AUTH_DOMAIN")
+ v.MustBindEnv("auth.clientID", "AUTH_CLIENT_ID")
+ v.MustBindEnv("auth.clientSecret", "AUTH_CLIENT_SECRET")
+ v.MustBindEnv("auth.callbackURL", "AUTH_CALLBACK_URL")
+
+ config := new(Config)
+ err = v.Unmarshal(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return config, config.assert()
+}
+
+func (c *Config) assert() error {
+ return nil
+}
diff --git a/backend/internal/database/cookies.go b/backend/internal/database/cookies.go
new file mode 100644
index 0000000..3ea21d0
--- /dev/null
+++ b/backend/internal/database/cookies.go
@@ -0,0 +1,189 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+)
+
+func GetAnyCookie(ctx context.Context, exec Executor, kms keys.KeyManagementService) (*IBDCookie, error) {
+ row := exec.QueryRowContext(ctx, `
+SELECT ibd_tokens.id, token, encrypted_key, kms_key_name, expires_at
+FROM ibd_tokens
+ INNER JOIN keys ON encryption_key = keys.id
+WHERE expires_at > NOW()
+ AND degraded = FALSE
+ORDER BY random()
+LIMIT 1;`)
+
+ var id uint
+ var encryptedToken, encryptedKey []byte
+ var keyName string
+ var expiry time.Time
+ err := row.Scan(&id, &encryptedToken, &encryptedKey, &keyName, &expiry)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("unable to scan sql row into ibd cookie: %w", err)
+ }
+
+ // Set the expiry to UTC explicitly.
+ // For some reason, the expiry time is set to location="".
+ expiry = expiry.UTC()
+
+ token, err := keys.Decrypt(ctx, kms, keyName, encryptedToken, encryptedKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to decrypt token: %w", err)
+ }
+ return &IBDCookie{
+ Token: string(token),
+ Expiry: expiry,
+ }, nil
+}
+
+func GetCookies(
+ ctx context.Context,
+ exec Executor,
+ kms keys.KeyManagementService,
+ subject string,
+ degraded bool,
+) ([]IBDCookie, error) {
+ rows, err := exec.QueryContext(ctx, `
+SELECT ibd_tokens.id, token, encrypted_key, kms_key_name, expires_at
+FROM ibd_tokens
+ INNER JOIN keys ON encryption_key = keys.id
+WHERE user_subject = $1
+ AND expires_at > NOW()
+ AND degraded = $2
+ORDER BY expires_at DESC;`, subject, degraded)
+ if err != nil {
+ return nil, fmt.Errorf("unable to get ibd cookies: %w", err)
+ }
+
+ cookies := make([]IBDCookie, 0)
+ for rows.Next() {
+ var id uint
+ var encryptedToken, encryptedKey []byte
+ var keyName string
+ var expiry time.Time
+ err = rows.Scan(&id, &encryptedToken, &encryptedKey, &keyName, &expiry)
+ if err != nil {
+ return nil, fmt.Errorf("unable to scan sql row into ibd cookie: %w", err)
+ }
+
+ // Set the expiry to UTC explicitly.
+ // For some reason, the expiry time is set to location="".
+ expiry = expiry.UTC()
+
+ token, err := keys.Decrypt(ctx, kms, keyName, encryptedToken, encryptedKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to decrypt token: %w", err)
+ }
+ cookie := IBDCookie{
+ ID: id,
+ Token: string(token),
+ Expiry: expiry,
+ }
+ cookies = append(cookies, cookie)
+ }
+
+ return cookies, nil
+}
+
+func AddCookie(
+ ctx context.Context,
+ exec TransactionExecutor,
+ kms keys.KeyManagementService,
+ subject string,
+ cookie *http.Cookie,
+) error {
+ tx, err := exec.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ // Get the key ID for the user
+ user, err := GetUser(ctx, tx, subject)
+ if err != nil {
+ return fmt.Errorf("unable to get user: %w", err)
+ }
+ if user.EncryptionKeyID == nil {
+ return errors.New("user does not have an encryption key")
+ }
+
+ // Get the key
+ var keyName string
+ var key []byte
+ err = tx.QueryRowContext(ctx, `
+SELECT kms_key_name, encrypted_key
+FROM keys
+WHERE id = $1;`,
+ *user.EncryptionKeyID,
+ ).Scan(&keyName, &key)
+ if err != nil {
+ return fmt.Errorf("unable to get key: %w", err)
+ }
+
+ // Encrypt the token
+ encryptedToken, err := keys.EncryptWithKey(ctx, kms, keyName, key, []byte(cookie.Value))
+ if err != nil {
+ return fmt.Errorf("unable to encrypt token: %w", err)
+ }
+
+ // Add the cookie to the database
+ _, err = exec.ExecContext(ctx, `
+INSERT INTO ibd_tokens (token, expires_at, user_subject, encryption_key)
+VALUES ($1, $2, $3, $4)`, encryptedToken, cookie.Expires, subject, *user.EncryptionKeyID)
+ if err != nil {
+ return fmt.Errorf("unable to add cookie: %w", err)
+ }
+
+ return nil
+}
+
+func ReportCookieFailure(ctx context.Context, exec Executor, id uint) error {
+ _, err := exec.ExecContext(ctx, `
+UPDATE ibd_tokens
+SET degraded = TRUE
+WHERE id = $1;`, id)
+ if err != nil {
+ return fmt.Errorf("unable to report cookie failure: %w", err)
+ }
+ return nil
+}
+
+func RepairCookie(ctx context.Context, exec Executor, id uint) error {
+ _, err := exec.ExecContext(ctx, `
+UPDATE ibd_tokens
+SET degraded = FALSE
+WHERE id = $1;`, id)
+ if err != nil {
+ return fmt.Errorf("unable to report cookie failure: %w", err)
+ }
+ return nil
+}
+
+type IBDCookie struct {
+ ID uint
+ Token string
+ Expiry time.Time
+}
+
+func (c *IBDCookie) ToHTTPCookie() *http.Cookie {
+ return &http.Cookie{
+ Name: ".ASPXAUTH",
+ Value: c.Token,
+ Path: "/",
+ Domain: "investors.com",
+ Expires: c.Expiry,
+ Secure: true,
+ HttpOnly: false,
+ SameSite: http.SameSiteLaxMode,
+ }
+}
diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go
new file mode 100644
index 0000000..409dd3c
--- /dev/null
+++ b/backend/internal/database/database.go
@@ -0,0 +1,166 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "io"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/db"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+
+ "github.com/golang-migrate/migrate/v4"
+ _ "github.com/golang-migrate/migrate/v4/database/postgres"
+ "github.com/golang-migrate/migrate/v4/source/iofs"
+ _ "github.com/lib/pq"
+)
+
+type Database interface {
+ io.Closer
+ TransactionExecutor
+ driver.Pinger
+
+ Migrate(ctx context.Context) error
+ Maintenance(ctx context.Context)
+}
+
+type database struct {
+ logger *slog.Logger
+
+ db *sql.DB
+ url string
+
+ kms keys.KeyManagementService
+ keyName string
+}
+
+func New(ctx context.Context, logger *slog.Logger, url string, kms keys.KeyManagementService, keyName string) (Database, error) {
+ sqlDB, err := sql.Open("postgres", url)
+ if err != nil {
+ return nil, err
+ }
+
+ err = sqlDB.PingContext(ctx)
+ if err != nil {
+ // Ping failed. Don't error, but give a warning.
+ logger.WarnContext(ctx, "Unable to ping database", "error", err)
+ }
+
+ return &database{
+ logger: logger,
+ db: sqlDB,
+ url: url,
+ kms: kms,
+ keyName: keyName,
+ }, nil
+}
+
+func (d *database) Close() error {
+ return d.db.Close()
+}
+
+func (d *database) Migrate(ctx context.Context) error {
+ return Migrate(ctx, d.url)
+}
+
+func (d *database) Maintenance(ctx context.Context) {
+ ticker := time.NewTicker(15 * time.Minute)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ func() {
+ var wg sync.WaitGroup
+ wg.Add(1)
+
+ _, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+
+ wg.Wait()
+ }()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+func Migrate(ctx context.Context, url string) error {
+ fs, err := iofs.New(db.Migrations, "migrations")
+ if err != nil {
+ return err
+ }
+
+ m, err := migrate.NewWithSourceInstance("iofs", fs, url)
+ if err != nil {
+ return err
+ }
+
+ slog.InfoContext(ctx, "Running DB migration")
+ err = m.Up()
+ if err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ slog.ErrorContext(ctx, "DB migration failed", "error", err)
+ return err
+ }
+
+ return nil
+}
+
+func (d *database) Ping(ctx context.Context) error {
+ return d.db.PingContext(ctx)
+}
+
+type Executor interface {
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
+
+type TransactionExecutor interface {
+ Executor
+ BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
+}
+
+func (d *database) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
+ d.logger.DebugContext(ctx, "Executing query", "query", query)
+
+ now := time.Now()
+ ret, err := d.db.ExecContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+
+ d.logger.DebugContext(ctx, "Query executed successfully", "duration", time.Since(now))
+ return ret, nil
+}
+
+func (d *database) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
+ d.logger.DebugContext(ctx, "Executing query", "query", query)
+
+ now := time.Now()
+ ret, err := d.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+
+ d.logger.DebugContext(ctx, "Query executed successfully", "duration", time.Since(now))
+ return ret, nil
+}
+
+func (d *database) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
+ d.logger.DebugContext(ctx, "Executing query", "query", query)
+
+ now := time.Now()
+ ret := d.db.QueryRowContext(ctx, query, args...)
+
+ d.logger.DebugContext(ctx, "Query executed successfully", "duration", time.Since(now))
+ return ret
+}
+
+func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
+ return d.db.BeginTx(ctx, opts)
+}
diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go
new file mode 100644
index 0000000..407a09a
--- /dev/null
+++ b/backend/internal/database/database_test.go
@@ -0,0 +1,79 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "testing"
+ "time"
+
+ "github.com/ory/dockertest/v3"
+ "github.com/ory/dockertest/v3/docker"
+)
+
+var exec *sql.DB
+
+func TestMain(m *testing.M) {
+ pool, err := dockertest.NewPool("")
+ if err != nil {
+ log.Fatalf("Could not create pool: %s", err)
+ }
+
+ err = pool.Client.Ping()
+ if err != nil {
+ log.Fatalf("Could not connect to Docker: %s", err)
+ }
+
+ resource, err := pool.RunWithOptions(&dockertest.RunOptions{
+ Repository: "postgres",
+ Tag: "16",
+ Env: []string{
+ "POSTGRES_PASSWORD=secret",
+ "POSTGRES_USER=ibd-client-test",
+ "POSTGRES_DB=ibd-client-test",
+ "listen_addresses='*'",
+ },
+ Cmd: []string{
+ "postgres",
+ "-c",
+ "log_statement=all",
+ },
+ }, func(config *docker.HostConfig) {
+ config.AutoRemove = true
+ config.RestartPolicy = docker.RestartPolicy{Name: "no"}
+ })
+ if err != nil {
+ log.Fatalf("Could not start resource: %s", err)
+ }
+
+ hostAndPort := resource.GetHostPort("5432/tcp")
+ databaseUrl := fmt.Sprintf("postgres://ibd-client-test:secret@%s/ibd-client-test?sslmode=disable", hostAndPort)
+
+ // Kill container after 120 seconds
+ _ = resource.Expire(120)
+
+ pool.MaxWait = 120 * time.Second
+ if err = pool.Retry(func() error {
+ exec, err = sql.Open("postgres", databaseUrl)
+ if err != nil {
+ return err
+ }
+ return exec.Ping()
+ }); err != nil {
+ log.Fatalf("Could not connect to database: %s", err)
+ }
+
+ err = Migrate(context.Background(), databaseUrl)
+ if err != nil {
+ log.Fatalf("Could not migrate database: %s", err)
+ }
+
+ defer func() {
+ if err := pool.Purge(resource); err != nil {
+ log.Fatalf("Could not purge resource: %s", err)
+ }
+ }()
+
+ m.Run()
+}
diff --git a/backend/internal/database/stocks.go b/backend/internal/database/stocks.go
new file mode 100644
index 0000000..24f5fe7
--- /dev/null
+++ b/backend/internal/database/stocks.go
@@ -0,0 +1,293 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+
+ pb "github.com/ansg191/ibd-trader-backend/api/gen/idb/stock/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/analyzer"
+ "github.com/ansg191/ibd-trader-backend/internal/utils"
+
+ "github.com/Rhymond/go-money"
+)
+
+var ErrStockNotFound = errors.New("stock not found")
+
+func GetStock(ctx context.Context, exec Executor, symbol string) (Stock, error) {
+ row := exec.QueryRowContext(ctx, `
+SELECT symbol, name, ibd_url
+FROM stocks
+WHERE symbol = $1;
+`, symbol)
+
+ var stock Stock
+ if err := row.Scan(&stock.Symbol, &stock.Name, &stock.IBDUrl); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return Stock{}, ErrStockNotFound
+ }
+ return Stock{}, err
+ }
+
+ return stock, nil
+}
+
+func AddStock(ctx context.Context, exec Executor, stock Stock) error {
+ _, err := exec.ExecContext(ctx, `
+INSERT INTO stocks (symbol, name, ibd_url)
+VALUES ($1, $2, $3)
+ON CONFLICT (symbol)
+ DO UPDATE SET name = $2,
+ ibd_url = $3;`, stock.Symbol, stock.Name, stock.IBDUrl)
+ return err
+}
+
+func AddRanking(ctx context.Context, exec Executor, symbol string, ibd50, cap20 int) error {
+ if ibd50 > 0 {
+ _, err := exec.ExecContext(ctx, `
+INSERT INTO stock_rank (symbol, rank_type, rank)
+VALUES ($1, $2, $3)`, symbol, "ibd50", ibd50)
+ if err != nil {
+ return err
+ }
+ }
+ if cap20 > 0 {
+ _, err := exec.ExecContext(ctx, `
+INSERT INTO stock_rank (symbol, rank_type, rank)
+VALUES ($1, $2, $3)`, symbol, "cap20", cap20)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func AddStockInfo(ctx context.Context, exec TransactionExecutor, info *StockInfo) (string, error) {
+ tx, err := exec.BeginTx(ctx, nil)
+ if err != nil {
+ return "", err
+ }
+ defer func(tx *sql.Tx) {
+ _ = tx.Rollback()
+ }(tx)
+
+ // Add raw chart analysis
+ row := tx.QueryRowContext(ctx, `
+INSERT INTO chart_analysis (raw_analysis)
+VALUES ($1)
+RETURNING id;`, info.ChartAnalysis)
+
+ var chartAnalysisID string
+ if err = row.Scan(&chartAnalysisID); err != nil {
+ return "", err
+ }
+
+ // Add stock info
+ row = tx.QueryRowContext(ctx,
+ `
+INSERT INTO ratings (symbol, composite, eps, rel_str, group_rel_str, smr, acc_dis, chart_analysis, price)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+RETURNING id;`,
+ info.Symbol,
+ info.Ratings.Composite,
+ info.Ratings.EPS,
+ info.Ratings.RelStr,
+ info.Ratings.GroupRelStr,
+ info.Ratings.SMR,
+ info.Ratings.AccDis,
+ chartAnalysisID,
+ info.Price.Display(),
+ )
+
+ var ratingsID string
+ if err = row.Scan(&ratingsID); err != nil {
+ return "", err
+ }
+
+ return ratingsID, tx.Commit()
+}
+
+func GetStockInfo(ctx context.Context, exec Executor, id string) (*StockInfo, error) {
+ row := exec.QueryRowContext(ctx, `
+SELECT r.symbol,
+ s.name,
+ ca.raw_analysis,
+ r.composite,
+ r.eps,
+ r.rel_str,
+ r.group_rel_str,
+ r.smr,
+ r.acc_dis,
+ r.price
+FROM ratings r
+ INNER JOIN stocks s on r.symbol = s.symbol
+ INNER JOIN chart_analysis ca on r.chart_analysis = ca.id
+WHERE r.id = $1;`, id)
+
+ var info StockInfo
+ var priceStr string
+ err := row.Scan(
+ &info.Symbol,
+ &info.Name,
+ &info.ChartAnalysis,
+ &info.Ratings.Composite,
+ &info.Ratings.EPS,
+ &info.Ratings.RelStr,
+ &info.Ratings.GroupRelStr,
+ &info.Ratings.SMR,
+ &info.Ratings.AccDis,
+ &priceStr,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ info.Price, err = utils.ParseMoney(priceStr)
+ if err != nil {
+ return nil, err
+ }
+
+ return &info, nil
+}
+
+func AddAnalysis(
+ ctx context.Context,
+ exec Executor,
+ ratingId string,
+ analysis *analyzer.Analysis,
+) (id string, err error) {
+ err = exec.QueryRowContext(ctx, `
+UPDATE chart_analysis ca
+SET processed = true,
+ action = $2,
+ price = $3,
+ reason = $4,
+ confidence = $5
+FROM ratings r
+WHERE r.id = $1
+ AND r.chart_analysis = ca.id
+RETURNING ca.id;`,
+ ratingId,
+ analysis.Action,
+ analysis.Price.Display(),
+ analysis.Reason,
+ analysis.Confidence,
+ ).Scan(&id)
+ return id, err
+}
+
+type Stock struct {
+ Symbol string
+ Name string
+ IBDUrl string
+}
+
+type StockInfo struct {
+ Symbol string
+ Name string
+ ChartAnalysis string
+ Ratings Ratings
+ Price *money.Money
+}
+
+type Ratings struct {
+ Composite uint8
+ EPS uint8
+ RelStr uint8
+ GroupRelStr LetterRating
+ SMR LetterRating
+ AccDis LetterRating
+}
+
+type LetterRating pb.LetterGrade
+
+func (r LetterRating) String() string {
+ switch pb.LetterGrade(r) {
+ case pb.LetterGrade_LETTER_GRADE_E:
+ return "E"
+ case pb.LetterGrade_LETTER_GRADE_E_PLUS:
+ return "E+"
+ case pb.LetterGrade_LETTER_GRADE_D_MINUS:
+ return "D-"
+ case pb.LetterGrade_LETTER_GRADE_D:
+ return "D"
+ case pb.LetterGrade_LETTER_GRADE_D_PLUS:
+ return "D+"
+ case pb.LetterGrade_LETTER_GRADE_C_MINUS:
+ return "C-"
+ case pb.LetterGrade_LETTER_GRADE_C:
+ return "C"
+ case pb.LetterGrade_LETTER_GRADE_C_PLUS:
+ return "C+"
+ case pb.LetterGrade_LETTER_GRADE_B_MINUS:
+ return "B-"
+ case pb.LetterGrade_LETTER_GRADE_B:
+ return "B"
+ case pb.LetterGrade_LETTER_GRADE_B_PLUS:
+ return "B+"
+ case pb.LetterGrade_LETTER_GRADE_A_MINUS:
+ return "A-"
+ case pb.LetterGrade_LETTER_GRADE_A:
+ return "A"
+ case pb.LetterGrade_LETTER_GRADE_A_PLUS:
+ return "A+"
+ default:
+ return "NA"
+ }
+}
+
+func LetterRatingFromString(str string) LetterRating {
+ switch str {
+ case "E":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_E)
+ case "E+":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_E_PLUS)
+ case "D-":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_D_MINUS)
+ case "D":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_D)
+ case "D+":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_D_PLUS)
+ case "C-":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_C_MINUS)
+ case "C":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_C)
+ case "C+":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_C_PLUS)
+ case "B-":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_B_MINUS)
+ case "B":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_B)
+ case "B+":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_B_PLUS)
+ case "A-":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_A_MINUS)
+ case "A":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_A)
+ case "A+":
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_A_PLUS)
+ case "NA":
+ fallthrough
+ default:
+ return LetterRating(pb.LetterGrade_LETTER_GRADE_UNSPECIFIED)
+ }
+}
+
+func (r LetterRating) Value() (driver.Value, error) {
+ return r.String(), nil
+}
+
+func (r *LetterRating) Scan(src any) error {
+ var source string
+ switch v := src.(type) {
+ case string:
+ source = v
+ case []byte:
+ source = string(v)
+ default:
+ return errors.New("incompatible type for LetterRating")
+ }
+ *r = LetterRatingFromString(source)
+ return nil
+}
diff --git a/backend/internal/database/users.go b/backend/internal/database/users.go
new file mode 100644
index 0000000..f7998fb
--- /dev/null
+++ b/backend/internal/database/users.go
@@ -0,0 +1,151 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+)
+
+var ErrUserNotFound = fmt.Errorf("user not found")
+var ErrIBDCredsNotFound = fmt.Errorf("ibd creds not found")
+
+func AddUser(ctx context.Context, exec Executor, subject string) (err error) {
+ _, err = exec.ExecContext(ctx, `
+INSERT INTO users (subject)
+VALUES ($1)
+ON CONFLICT DO NOTHING;`, subject)
+ return
+}
+
+func GetUser(ctx context.Context, exec Executor, subject string) (*User, error) {
+ row := exec.QueryRowContext(ctx, `
+SELECT subject, ibd_username, ibd_password, encryption_key
+FROM users
+WHERE subject = $1;`, subject)
+
+ user := &User{}
+ err := row.Scan(&user.Subject, &user.IBDUsername, &user.EncryptedIBDPassword, &user.EncryptionKeyID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrUserNotFound
+ }
+ return nil, fmt.Errorf("unable to scan sql row into user: %w", err)
+ }
+
+ return user, nil
+}
+
+func ListUsers(ctx context.Context, exec Executor, hasIBDCreds bool) ([]User, error) {
+ rows, err := exec.QueryContext(ctx, `
+SELECT subject, ibd_username, ibd_password, encryption_key
+FROM users;
+`)
+ if err != nil {
+ return nil, fmt.Errorf("unable to list users: %w", err)
+ }
+
+ users := make([]User, 0)
+ for rows.Next() {
+ user := User{}
+ err = rows.Scan(&user.Subject, &user.IBDUsername, &user.EncryptedIBDPassword, &user.EncryptionKeyID)
+ if err != nil {
+ return nil, fmt.Errorf("unable to scan sql row into user: %w", err)
+ }
+
+ if hasIBDCreds && user.IBDUsername == nil {
+ continue
+ }
+ users = append(users, user)
+ }
+
+ return users, nil
+}
+
+func AddIBDCreds(
+ ctx context.Context,
+ exec TransactionExecutor,
+ kms keys.KeyManagementService,
+ keyName, subject, username, password string,
+) error {
+ encryptedPass, encryptedKey, err := keys.Encrypt(ctx, kms, keyName, []byte(password))
+ if err != nil {
+ return fmt.Errorf("unable to encrypt password: %w", err)
+ }
+
+ tx, err := exec.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func(tx *sql.Tx) {
+ _ = tx.Rollback()
+ }(tx)
+
+ var keyId int
+ err = tx.QueryRowContext(ctx, `
+INSERT INTO keys (kms_key_name, encrypted_key)
+VALUES ($1, $2)
+RETURNING id;`, keyName, encryptedKey).Scan(&keyId)
+ if err != nil {
+ return fmt.Errorf("unable to add ibd creds key: %w", err)
+ }
+
+ _, err = exec.ExecContext(ctx, `
+UPDATE users
+SET ibd_username = $2,
+ ibd_password = $3,
+ encryption_key = $4
+WHERE subject = $1;`, subject, username, encryptedPass, keyId)
+ if err != nil {
+ return fmt.Errorf("unable to add ibd creds to user: %w", err)
+ }
+
+ if err = tx.Commit(); err != nil {
+ return fmt.Errorf("unable to commit transaction: %w", err)
+ }
+
+ return nil
+}
+
+func GetIBDCreds(
+ ctx context.Context,
+ exec Executor,
+ kms keys.KeyManagementService,
+ subject string,
+) (
+ username string,
+ password string,
+ err error,
+) {
+ row := exec.QueryRowContext(ctx, `
+SELECT ibd_username, ibd_password, encrypted_key, kms_key_name
+FROM users
+INNER JOIN public.keys k on k.id = users.encryption_key
+WHERE subject = $1;`, subject)
+
+ var encryptedPass, encryptedKey []byte
+ var keyName string
+ err = row.Scan(&username, &encryptedPass, &encryptedKey, &keyName)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", "", ErrIBDCredsNotFound
+ }
+ return "", "", fmt.Errorf("unable to scan sql row into ibd creds: %w", err)
+ }
+
+ passwordBytes, err := keys.Decrypt(ctx, kms, keyName, encryptedPass, encryptedKey)
+ if err != nil {
+ return "", "", fmt.Errorf("unable to decrypt password: %w", err)
+ }
+
+ return username, string(passwordBytes), nil
+}
+
+type User struct {
+ Subject string
+ IBDUsername *string
+ EncryptedIBDPassword *string
+ EncryptionKeyID *int
+}
diff --git a/backend/internal/ibd/auth.go b/backend/internal/ibd/auth.go
new file mode 100644
index 0000000..7b82057
--- /dev/null
+++ b/backend/internal/ibd/auth.go
@@ -0,0 +1,333 @@
+package ibd
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+ "golang.org/x/net/html"
+)
+
+const (
+ signInUrl = "https://myibd.investors.com/secure/signin.aspx?eurl=https%3A%2F%2Fwww.investors.com"
+ authenticateUrl = "https://sso.accounts.dowjones.com/authenticate"
+ postAuthUrl = "https://sso.accounts.dowjones.com/postauth/handler"
+ cookieName = ".ASPXAUTH"
+)
+
+var ErrAuthCookieNotFound = errors.New("cookie not found")
+var ErrBadCredentials = errors.New("bad credentials")
+
+func (c *Client) Authenticate(
+ ctx context.Context,
+ username,
+ password string,
+) (*http.Cookie, error) {
+ cfg, err := c.getLoginPage(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ token, params, err := c.sendAuthRequest(ctx, cfg, username, password)
+ if err != nil {
+ return nil, err
+ }
+
+ return c.sendPostAuth(ctx, token, params)
+}
+
+func (c *Client) getLoginPage(ctx context.Context) (*authConfig, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, signInUrl, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.Do(req, withRequiredProps(transport.PropertiesReliable))
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return nil, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ node, err := html.Parse(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg, err := extractAuthConfig(node)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract auth config: %w", err)
+ }
+
+ return cfg, nil
+}
+
+func (c *Client) sendAuthRequest(ctx context.Context, cfg *authConfig, username, password string) (string, string, error) {
+ body := authRequestBody{
+ ClientId: cfg.ClientID,
+ RedirectUri: cfg.CallbackURL,
+ Tenant: "sso",
+ ResponseType: cfg.ExtraParams.ResponseType,
+ Username: username,
+ Password: password,
+ Scope: cfg.ExtraParams.Scope,
+ State: cfg.ExtraParams.State,
+ Headers: struct {
+ XRemoteUser string `json:"x-_remote-_user"`
+ }(struct{ XRemoteUser string }{
+ XRemoteUser: username,
+ }),
+ XOidcProvider: "localop",
+ Protocol: cfg.ExtraParams.Protocol,
+ Nonce: cfg.ExtraParams.Nonce,
+ UiLocales: cfg.ExtraParams.UiLocales,
+ Csrf: cfg.ExtraParams.Csrf,
+ Intstate: cfg.ExtraParams.Intstate,
+ Connection: "DJldap",
+ }
+ bodyJson, err := json.Marshal(body)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to marshal auth request body: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, authenticateUrl, bytes.NewReader(bodyJson))
+ if err != nil {
+ return "", "", err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Auth0-Client", "eyJuYW1lIjoiYXV0aDAuanMtdWxwIiwidmVyc2lvbiI6IjkuMjQuMSJ9")
+
+ resp, err := c.Do(req,
+ withRequiredProps(transport.PropertiesReliable),
+ withExpectedStatuses(http.StatusOK, http.StatusUnauthorized))
+ if err != nil {
+ return "", "", err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return "", "", ErrBadCredentials
+ } else if resp.StatusCode != http.StatusOK {
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to read response body: %w", err)
+ }
+ return "", "", fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ node, err := html.Parse(resp.Body)
+ if err != nil {
+ return "", "", err
+ }
+
+ return extractTokenParams(node)
+}
+
+func (c *Client) sendPostAuth(ctx context.Context, token, params string) (*http.Cookie, error) {
+ body := fmt.Sprintf("token=%s&params=%s", token, params)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, postAuthUrl, strings.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := c.Do(req, withRequiredProps(transport.PropertiesReliable))
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return nil, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ // Extract cookie
+ for _, cookie := range resp.Cookies() {
+ if cookie.Name == cookieName {
+ return cookie, nil
+ }
+ }
+
+ return nil, ErrAuthCookieNotFound
+}
+
+func extractAuthConfig(node *html.Node) (*authConfig, error) {
+ // Find `root` element
+ root := findId(node, "root")
+ if root == nil {
+ return nil, fmt.Errorf("root element not found")
+ }
+
+ // Get adjacent script element
+ var script *html.Node
+ for s := root.NextSibling; s != nil; s = s.NextSibling {
+ if s.Type == html.ElementNode && s.Data == "script" {
+ script = s
+ break
+ }
+ }
+
+ if script == nil {
+ return nil, fmt.Errorf("script element not found")
+ }
+
+ // Get script content
+ content := extractText(script)
+
+ // Find `AUTH_CONFIG` variable
+ const authConfigVar = "const AUTH_CONFIG = '"
+ i := strings.Index(content, authConfigVar)
+ if i == -1 {
+ return nil, fmt.Errorf("AUTH_CONFIG not found")
+ }
+
+ // Find end of `AUTH_CONFIG` variable
+ j := strings.Index(content[i+len(authConfigVar):], "'")
+
+ // Extract `AUTH_CONFIG` value
+ authConfigJSONB64 := content[i+len(authConfigVar) : i+len(authConfigVar)+j]
+
+ // Decode `AUTH_CONFIG` value
+ authConfigJSON, err := base64.StdEncoding.DecodeString(authConfigJSONB64)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode AUTH_CONFIG: %w", err)
+ }
+
+ // Unmarshal `AUTH_CONFIG` value
+ var cfg authConfig
+ if err = json.Unmarshal(authConfigJSON, &cfg); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal AUTH_CONFIG: %w", err)
+ }
+
+ return &cfg, nil
+}
+
+type authConfig struct {
+ Auth0Domain string `json:"auth0Domain"`
+ CallbackURL string `json:"callbackURL"`
+ ClientID string `json:"clientID"`
+ ExtraParams struct {
+ Protocol string `json:"protocol"`
+ Scope string `json:"scope"`
+ ResponseType string `json:"response_type"`
+ Nonce string `json:"nonce"`
+ UiLocales string `json:"ui_locales"`
+ Csrf string `json:"_csrf"`
+ Intstate string `json:"_intstate"`
+ State string `json:"state"`
+ } `json:"extraParams"`
+ InternalOptions struct {
+ ResponseType string `json:"response_type"`
+ ClientId string `json:"client_id"`
+ Scope string `json:"scope"`
+ RedirectUri string `json:"redirect_uri"`
+ UiLocales string `json:"ui_locales"`
+ Eurl string `json:"eurl"`
+ Nonce string `json:"nonce"`
+ State string `json:"state"`
+ Resource string `json:"resource"`
+ Protocol string `json:"protocol"`
+ Client string `json:"client"`
+ } `json:"internalOptions"`
+ IsThirdPartyClient bool `json:"isThirdPartyClient"`
+ AuthorizationServer struct {
+ Url string `json:"url"`
+ Issuer string `json:"issuer"`
+ } `json:"authorizationServer"`
+}
+
+func extractTokenParams(node *html.Node) (token string, params string, err error) {
+ inputs := findChildrenRecursive(node, func(node *html.Node) bool {
+ return node.Type == html.ElementNode && node.Data == "input"
+ })
+
+ var tokenNode, paramsNode *html.Node
+ for _, input := range inputs {
+ for _, attr := range input.Attr {
+ if attr.Key == "name" && attr.Val == "token" {
+ tokenNode = input
+ } else if attr.Key == "name" && attr.Val == "params" {
+ paramsNode = input
+ }
+ }
+ }
+
+ if tokenNode == nil {
+ return "", "", fmt.Errorf("token input not found")
+ }
+ if paramsNode == nil {
+ return "", "", fmt.Errorf("params input not found")
+ }
+
+ for _, attr := range tokenNode.Attr {
+ if attr.Key == "value" {
+ token = attr.Val
+ }
+ }
+ for _, attr := range paramsNode.Attr {
+ if attr.Key == "value" {
+ params = attr.Val
+ }
+ }
+
+ return
+}
+
+type authRequestBody struct {
+ ClientId string `json:"client_id"`
+ RedirectUri string `json:"redirect_uri"`
+ Tenant string `json:"tenant"`
+ ResponseType string `json:"response_type"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Scope string `json:"scope"`
+ State string `json:"state"`
+ Headers struct {
+ XRemoteUser string `json:"x-_remote-_user"`
+ } `json:"headers"`
+ XOidcProvider string `json:"x-_oidc-_provider"`
+ Protocol string `json:"protocol"`
+ Nonce string `json:"nonce"`
+ UiLocales string `json:"ui_locales"`
+ Csrf string `json:"_csrf"`
+ Intstate string `json:"_intstate"`
+ Connection string `json:"connection"`
+}
diff --git a/backend/internal/ibd/auth_test.go b/backend/internal/ibd/auth_test.go
new file mode 100644
index 0000000..157b507
--- /dev/null
+++ b/backend/internal/ibd/auth_test.go
@@ -0,0 +1,215 @@
+package ibd
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/net/html"
+)
+
+const extractAuthHtml = `
+<!doctype html>
+<html lang="en">
+ <head>
+ <title>Log in · Dow Jones</title>
+ <meta charset="UTF-8"/>
+ <meta name="theme-color" content="white"/>
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
+ <meta name="description" content="Dow Jones One Identity Login page"/>
+ <link rel="apple-touch-icon" sizes="180x180" href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/images/apple-touch-icon.png"/>
+ <link rel="icon" type="image/png" sizes="32x32" href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/images/favicon-32x32.png"/>
+ <link rel="icon" type="image/png" sizes="16x16" href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/images/favicon-16x16.png"/>
+ <link rel="icon" type="image/png" sizes="192x192" href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/images/android-chrome-192x192.png"/>
+ <link rel="icon" type="image/png" sizes="512x512" href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/images/android-chrome-512x512.png"/>
+ <link rel="prefetch" href="https://cdn.optimizely.com/js/14856860742.js"/>
+ <link rel="preconnect" href="//cdn.optimizely.com"/>
+ <link rel="preconnect" href="//logx.optimizely.com"/>
+ <script type="module" crossorigin src="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/js/index.js"></script>
+ <link rel="modulepreload" crossorigin href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/js/vendor.js">
+ <link rel="modulepreload" crossorigin href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/js/auth.js">
+ <link rel="modulepreload" crossorigin href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/js/router.js">
+ <link rel="stylesheet" crossorigin href="/one_identity_login_pages/login/0ce1520be322adcd762319573804f56d/assets/styles.css">
+ </head>
+ <body>
+ <div id="root" vaul-drawer-wrapper="" class="root-container"></div>
+ <script>
+ const AUTH_CONFIG = 'eyJhdXRoMERvbWFpbiI6InNzby5hY2NvdW50cy5kb3dqb25lcy5jb20iLCJjYWxsYmFja1VSTCI6Imh0dHBzOi8vbXlpYmQuaW52ZXN0b3JzLmNvbS9vaWRjL2NhbGxiYWNrIiwiY2xpZW50SUQiOiJHU1UxcEcyQnJnZDNQdjJLQm5BWjI0enZ5NXVXU0NRbiIsImV4dHJhUGFyYW1zIjp7InByb3RvY29sIjoib2F1dGgyIiwic2NvcGUiOiJvcGVuaWQgaWRwX2lkIHJvbGVzIGVtYWlsIGdpdmVuX25hbWUgZmFtaWx5X25hbWUgdXVpZCBkalVzZXJuYW1lIGRqU3RhdHVzIHRyYWNraWQgdGFncyBwcnRzIHVwZGF0ZWRfYXQgY3JlYXRlZF9hdCBvZmZsaW5lX2FjY2VzcyBkamlkIiwicmVzcG9uc2VfdHlwZSI6ImNvZGUiLCJub25jZSI6IjY0MDJmYWJiLTFiNzUtNGEyYy1hODRmLTExYWQ2MWFhZGI2YiIsInVpX2xvY2FsZXMiOiJlbi11cy14LWliZC0yMy03IiwiX2NzcmYiOiJOZFVSZ3dPQ3VYRU5URXFDcDhNV25tcGtxd3lva2JjU2E2VV9fLTVib3lWc1NzQVNWTkhLU0EiLCJfaW50c3RhdGUiOiJkZXByZWNhdGVkIiwic3RhdGUiOiJlYXJjN3E2UnE2a3lHS3h5LlltbGlxOU4xRXZvU1V0ejhDVjhuMFZBYzZWc1V4RElSTTRTcmxtSWJXMmsifSwiaW50ZXJuYWxPcHRpb25zIjp7InJlc3BvbnNlX3R5cGUiOiJjb2RlIiwiY2xpZW50X2lkIjoiR1NVMXBHMkJyZ2QzUHYyS0JuQVoyNHp2eTV1V1NDUW4iLCJzY29wZSI6Im9wZW5pZCBpZHBfaWQgcm9sZXMgZW1haWwgZ2l2ZW5fbmFtZSBmYW1pbHlfbmFtZSB1dWlkIGRqVXNlcm5hbWUgZGpTdGF0dXMgdHJhY2tpZCB0YWdzIHBydHMgdXBkYXRlZF9hdCBjcmVhdGVkX2F0IG9mZmxpbmVfYWNjZXNzIGRqaWQiLCJyZWRpcmVjdF91cmkiOiJodHRwczovL215aWJkLmludmVzdG9ycy5jb20vb2lkYy9jYWxsYmFjayIsInVpX2xvY2FsZXMiOiJlbi11cy14LWliZC0yMy03IiwiZXVybCI6Imh0dHBzOi8vd3d3LmludmVzdG9ycy5jb20iLCJub25jZSI6IjY0MDJmYWJiLTFiNzUtNGEyYy1hODRmLTExYWQ2MWFhZGI2YiIsInN0YXRlIjoiZWFyYzdxNlJxNmt5R0t4eS5ZbWxpcTlOMUV2b1NVdHo4Q1Y4bjBWQWM2VnNVeERJUk00U3JsbUliVzJrIiwicmVzb3VyY2UiOiJodHRwcyUzQSUyRiUyRnd3dy5pbnZlc3RvcnMuY29tIiwicHJvdG9jb2wiOiJvYXV0aDIiLCJjbGllbnQiOiJHU1UxcEcyQnJnZDNQdjJLQm5BWjI0enZ5NXVXU0NRbiJ9LCJpc1RoaXJkUGFydHlDbGllbnQiOmZhbHNlLCJhdXRob3JpemF0aW9uU2VydmVyIjp7InVybCI6Imh0dHBzOi8vc3NvLmFjY291bnRzLmRvd2pvbmVzLmNvbSIsImlzc3VlciI6Imh0dHBzOi8vc3NvLmFjY291bnRzLmRvd2pvbmVzLmNvbS8ifX0='
+ const ENV_CONFIG = 'production'
+
+ window.sessionStorage.setItem('auth-config', AUTH_CONFIG)
+ window.sessionStorage.setItem('env-config', ENV_CONFIG)
+ </script>
+ <script src="https://cdn.optimizely.com/js/14856860742.js" crossorigin="anonymous"></script>
+ <script type="text/javascript" src="https://dcdd29eaa743c493e732-7dc0216bc6cc2f4ed239035dfc17235b.ssl.cf3.rackcdn.com/tags/wsj/hokbottom.js"></script>
+ <script type="text/javascript" src="/R8As7u5b/init.js"></script>
+ </body>
+</html>
+`
+
+func Test_extractAuthConfig(t *testing.T) {
+ t.Parallel()
+ expectedJSON := `
+{
+ "auth0Domain": "sso.accounts.dowjones.com",
+ "callbackURL": "https://myibd.investors.com/oidc/callback",
+ "clientID": "GSU1pG2Brgd3Pv2KBnAZ24zvy5uWSCQn",
+ "extraParams": {
+ "protocol": "oauth2",
+ "scope": "openid idp_id roles email given_name family_name uuid djUsername djStatus trackid tags prts updated_at created_at offline_access djid",
+ "response_type": "code",
+ "nonce": "6402fabb-1b75-4a2c-a84f-11ad61aadb6b",
+ "ui_locales": "en-us-x-ibd-23-7",
+ "_csrf": "NdURgwOCuXENTEqCp8MWnmpkqwyokbcSa6U__-5boyVsSsASVNHKSA",
+ "_intstate": "deprecated",
+ "state": "earc7q6Rq6kyGKxy.Ymliq9N1EvoSUtz8CV8n0VAc6VsUxDIRM4SrlmIbW2k"
+ },
+ "internalOptions": {
+ "response_type": "code",
+ "client_id": "GSU1pG2Brgd3Pv2KBnAZ24zvy5uWSCQn",
+ "scope": "openid idp_id roles email given_name family_name uuid djUsername djStatus trackid tags prts updated_at created_at offline_access djid",
+ "redirect_uri": "https://myibd.investors.com/oidc/callback",
+ "ui_locales": "en-us-x-ibd-23-7",
+ "eurl": "https://www.investors.com",
+ "nonce": "6402fabb-1b75-4a2c-a84f-11ad61aadb6b",
+ "state": "earc7q6Rq6kyGKxy.Ymliq9N1EvoSUtz8CV8n0VAc6VsUxDIRM4SrlmIbW2k",
+ "resource": "https%3A%2F%2Fwww.investors.com",
+ "protocol": "oauth2",
+ "client": "GSU1pG2Brgd3Pv2KBnAZ24zvy5uWSCQn"
+ },
+ "isThirdPartyClient": false,
+ "authorizationServer": {
+ "url": "https://sso.accounts.dowjones.com",
+ "issuer": "https://sso.accounts.dowjones.com/"
+ }
+}`
+ var expectedCfg authConfig
+ err := json.Unmarshal([]byte(expectedJSON), &expectedCfg)
+ require.NoError(t, err)
+
+ node, err := html.Parse(strings.NewReader(extractAuthHtml))
+ require.NoError(t, err)
+
+ cfg, err := extractAuthConfig(node)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.Equal(t, expectedCfg, *cfg)
+}
+
+const extractTokenParamsHtml = `
+<form method="post" name="hiddenform" action="https://sso.accounts.dowjones.com/postauth/handler">
+ <input type="hidden" name="token" value="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJkalVzZXJuYW1lIjoiYW5zZzE5MUB5YWhvby5jb20iLCJpZCI6IjAxZWFmNTE5LTA0OWItNGIyOS04ZjZhLWQyNjIyZjNiMWJjNiIsImdpdmVuX25hbWUiOiJBbnNodWwiLCJmYW1pbHlfbmFtZSI6Ikd1cHRhIiwibmFtZSI6IkFuc2h1bCBHdXB0YSIsImVtYWlsIjoiYW5zZzE5MUB5YWhvby5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYWNjb3VudF9pZCI6Ijk5NzI5Mzc0NDIxMiIsImRqaWQiOiIwMWVhZjUxOS0wNDliLTRiMjktOGY2YS1kMjYyMmYzYjFiYzYiLCJ0cmFja2lkIjoiMWM0NGQyMTRmM2VlYTZiMzcyNDYxNDc3NDc0NDMyODJmMTRmY2ZjYmI4NmE4NmVjYTI0MDc2ZDVlMzU4ZmUzZCIsInVwZGF0ZWRfYXQiOjE3MTI3OTQxNTYsImNyZWF0ZWRfYXQiOjE3MTI3OTQxNTYsInVhdCI6MTcyMjU1MjMzOSwicm9sZXMiOlsiQkFSUk9OUy1DSEFOR0VQQVNTV09SRCIsIkZSRUVSRUctQkFTRSIsIkZSRUVSRUctSU5ESVZJRFVBTCIsIldTSi1DSEFOR0VQQVNTV09SRCIsIldTSi1BUkNISVZFIiwiV1NKLVNFTEZTRVJWIiwiSUJELUlORElWSURVQUwiLCJJQkQtSUNBIiwiSUJELUFFSSJdLCJkalN0YXR1cyI6WyJJQkRfVVNFUlMiXSwicHJ0cyI6IjIwMjQwNDEwMTcwOTE2LTA0MDAiLCJjcmVhdGVUaW1lc3RhbXAiOiIyMDI0MDQxMTAwMDkxNloiLCJzdXVpZCI6Ik1ERmxZV1kxTVRrdE1EUTVZaTAwWWpJNUxUaG1ObUV0WkRJMk1qSm1NMkl4WW1NMi50S09fM014VkVReks3dE5qTkdxUXNZMlBNbXp5cUxGRkxySnBrZGhrcDZrIiwic3ViIjoiMDFlYWY1MTktMDQ5Yi00YjI5LThmNmEtZDI2MjJmM2IxYmM2IiwiYXVkIjoiR1NVMXBHMkJyZ2QzUHYyS0JuQVoyNHp2eTV1V1NDUW4iLCJpc3MiOiJodHRwczovL3Nzby5hY2NvdW50cy5kb3dqb25lcy5jb20vIiwiaWF0IjoxNzIyNTUyMzM5MTI0LCJleHAiOjE3MjI1NTI3NzExMjR9.HVn33IFttQrG1JKEV2oElIy3mm8TJ-3GpV_jqZE81_cY22z4IMWPz7zUGz0WgOoUuQGyrYXiaNrfxD6GaoimRL6wxrH0Fy5iYC3dOEdlGfldswfgEOwSiZkBJRc2wWTVQLm93EeJ5ZZyKIXGY_ZkwcYfhrwaTAz8McBBnRmZkm0eiNJQ5YK-QZL-yFa3DxMdPPW91jLA2rjOIVnJ-I_0nMwaJ4ZwXHG2Sw4aAXxtbFqIqarKwIdOUSpRFOCSYpeWcxmbliurKlP1djrKrYgYSZxsKOHZhnbikZDtoDCAlPRlfbKOO4u36KXooDYGJ6p__s2kGCLOLLkP_QLHMNU8Jg">
+ <input type="hidden" name="params" value="%7B%22response_type%22%3A%22code%22%2C%22client_id%22%3A%22GSU1pG2Brgd3Pv2KBnAZ24zvy5uWSCQn%22%2C%22redirect_uri%22%3A%22https%3A%2F%2Fmyibd.investors.com%2Foidc%2Fcallback%22%2C%22state%22%3A%22J-ihUYZIYzey682D.aOLszineC9qjPkM6Y6wWgFC61ABYBiuK9u48AHTFS5I%22%2C%22scope%22%3A%22openid%20idp_id%20roles%20email%20given_name%20family_name%20uuid%20djUsername%20djStatus%20trackid%20tags%20prts%20updated_at%20created_at%20offline_access%20djid%22%2C%22nonce%22%3A%22457bb517-f490-43b6-a55f-d93f90d698ad%22%7D">
+ <noscript>
+ <p>Script is disabled. Click Submit to continue.</p>
+ <input type="submit" value="Submit">
+ </noscript>
+</form>
+`
+const extractTokenExpectedToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJkalVzZXJuYW1lIjoiYW5zZzE5MUB5YWhvby5jb20iLCJpZCI6IjAxZWFmNTE5LTA0OWItNGIyOS04ZjZhLWQyNjIyZjNiMWJjNiIsImdpdmVuX25hbWUiOiJBbnNodWwiLCJmYW1pbHlfbmFtZSI6Ikd1cHRhIiwibmFtZSI6IkFuc2h1bCBHdXB0YSIsImVtYWlsIjoiYW5zZzE5MUB5YWhvby5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYWNjb3VudF9pZCI6Ijk5NzI5Mzc0NDIxMiIsImRqaWQiOiIwMWVhZjUxOS0wNDliLTRiMjktOGY2YS1kMjYyMmYzYjFiYzYiLCJ0cmFja2lkIjoiMWM0NGQyMTRmM2VlYTZiMzcyNDYxNDc3NDc0NDMyODJmMTRmY2ZjYmI4NmE4NmVjYTI0MDc2ZDVlMzU4ZmUzZCIsInVwZGF0ZWRfYXQiOjE3MTI3OTQxNTYsImNyZWF0ZWRfYXQiOjE3MTI3OTQxNTYsInVhdCI6MTcyMjU1MjMzOSwicm9sZXMiOlsiQkFSUk9OUy1DSEFOR0VQQVNTV09SRCIsIkZSRUVSRUctQkFTRSIsIkZSRUVSRUctSU5ESVZJRFVBTCIsIldTSi1DSEFOR0VQQVNTV09SRCIsIldTSi1BUkNISVZFIiwiV1NKLVNFTEZTRVJWIiwiSUJELUlORElWSURVQUwiLCJJQkQtSUNBIiwiSUJELUFFSSJdLCJkalN0YXR1cyI6WyJJQkRfVVNFUlMiXSwicHJ0cyI6IjIwMjQwNDEwMTcwOTE2LTA0MDAiLCJjcmVhdGVUaW1lc3RhbXAiOiIyMDI0MDQxMTAwMDkxNloiLCJzdXVpZCI6Ik1ERmxZV1kxTVRrdE1EUTVZaTAwWWpJNUxUaG1ObUV0WkRJMk1qSm1NMkl4WW1NMi50S09fM014VkVReks3dE5qTkdxUXNZMlBNbXp5cUxGRkxySnBrZGhrcDZrIiwic3ViIjoiMDFlYWY1MTktMDQ5Yi00YjI5LThmNmEtZDI2MjJmM2IxYmM2IiwiYXVkIjoiR1NVMXBHMkJyZ2QzUHYyS0JuQVoyNHp2eTV1V1NDUW4iLCJpc3MiOiJodHRwczovL3Nzby5hY2NvdW50cy5kb3dqb25lcy5jb20vIiwiaWF0IjoxNzIyNTUyMzM5MTI0LCJleHAiOjE3MjI1NTI3NzExMjR9.HVn33IFttQrG1JKEV2oElIy3mm8TJ-3GpV_jqZE81_cY22z4IMWPz7zUGz0WgOoUuQGyrYXiaNrfxD6GaoimRL6wxrH0Fy5iYC3dOEdlGfldswfgEOwSiZkBJRc2wWTVQLm93EeJ5ZZyKIXGY_ZkwcYfhrwaTAz8McBBnRmZkm0eiNJQ5YK-QZL-yFa3DxMdPPW91jLA2rjOIVnJ-I_0nMwaJ4ZwXHG2Sw4aAXxtbFqIqarKwIdOUSpRFOCSYpeWcxmbliurKlP1djrKrYgYSZxsKOHZhnbikZDtoDCAlPRlfbKOO4u36KXooDYGJ6p__s2kGCLOLLkP_QLHMNU8Jg"
+const extractTokenExpectedParams = "%7B%22response_type%22%3A%22code%22%2C%22client_id%22%3A%22GSU1pG2Brgd3Pv2KBnAZ24zvy5uWSCQn%22%2C%22redirect_uri%22%3A%22https%3A%2F%2Fmyibd.investors.com%2Foidc%2Fcallback%22%2C%22state%22%3A%22J-ihUYZIYzey682D.aOLszineC9qjPkM6Y6wWgFC61ABYBiuK9u48AHTFS5I%22%2C%22scope%22%3A%22openid%20idp_id%20roles%20email%20given_name%20family_name%20uuid%20djUsername%20djStatus%20trackid%20tags%20prts%20updated_at%20created_at%20offline_access%20djid%22%2C%22nonce%22%3A%22457bb517-f490-43b6-a55f-d93f90d698ad%22%7D"
+
+func Test_extractTokenParams(t *testing.T) {
+ t.Parallel()
+
+ node, err := html.Parse(strings.NewReader(extractTokenParamsHtml))
+ require.NoError(t, err)
+
+ token, params, err := extractTokenParams(node)
+ require.NoError(t, err)
+ assert.Equal(t, extractTokenExpectedToken, token)
+ assert.Equal(t, extractTokenExpectedParams, params)
+}
+
+func TestClient_Authenticate(t *testing.T) {
+ t.Parallel()
+
+ expectedVal := "test-cookie"
+ expectedExp := time.Now().Add(time.Hour).Round(time.Second).In(time.UTC)
+
+ tp := httpmock.NewMockTransport()
+ tp.RegisterResponder("GET", signInUrl,
+ httpmock.NewStringResponder(http.StatusOK, extractAuthHtml))
+ tp.RegisterResponder("POST", authenticateUrl,
+ func(request *http.Request) (*http.Response, error) {
+ var body authRequestBody
+ require.NoError(t, json.NewDecoder(request.Body).Decode(&body))
+ assert.Equal(t, "abc", body.Username)
+ assert.Equal(t, "xyz", body.Password)
+
+ return httpmock.NewStringResponse(http.StatusOK, extractTokenParamsHtml), nil
+ })
+ tp.RegisterResponder("POST", postAuthUrl,
+ func(request *http.Request) (*http.Response, error) {
+ require.NoError(t, request.ParseForm())
+ assert.Equal(t, extractTokenExpectedToken, request.Form.Get("token"))
+
+ params, err := url.QueryUnescape(extractTokenExpectedParams)
+ require.NoError(t, err)
+ assert.Equal(t, params, request.Form.Get("params"))
+
+ resp := httpmock.NewStringResponse(http.StatusOK, "OK")
+ cookie := &http.Cookie{Name: cookieName, Value: expectedVal, Expires: expectedExp}
+ resp.Header.Set("Set-Cookie", cookie.String())
+ return resp, nil
+ })
+
+ client := NewClient(nil, nil, newTransport(tp))
+
+ cookie, err := client.Authenticate(context.Background(), "abc", "xyz")
+ require.NoError(t, err)
+ require.NotNil(t, cookie)
+
+ assert.Equal(t, expectedVal, cookie.Value)
+ assert.Equal(t, expectedExp, cookie.Expires)
+}
+
+func TestClient_Authenticate_401(t *testing.T) {
+ t.Parallel()
+
+ tp := httpmock.NewMockTransport()
+ tp.RegisterResponder("GET", signInUrl,
+ httpmock.NewStringResponder(http.StatusOK, extractAuthHtml))
+ tp.RegisterResponder("POST", authenticateUrl,
+ func(request *http.Request) (*http.Response, error) {
+ var body authRequestBody
+ require.NoError(t, json.NewDecoder(request.Body).Decode(&body))
+ assert.Equal(t, "abc", body.Username)
+ assert.Equal(t, "xyz", body.Password)
+
+ return httpmock.NewStringResponse(http.StatusUnauthorized, `{"name":"ValidationError","code":"ERR016","message":"Wrong username or password","description":"Wrong username or password"}`), nil
+ })
+
+ client := NewClient(nil, nil, newTransport(tp))
+
+ cookie, err := client.Authenticate(context.Background(), "abc", "xyz")
+ assert.Nil(t, cookie)
+ assert.ErrorIs(t, err, ErrBadCredentials)
+}
+
+type testReliableTransport http.Client
+
+func newTransport(tp *httpmock.MockTransport) *testReliableTransport {
+ return (*testReliableTransport)(&http.Client{Transport: tp})
+}
+
+func (t *testReliableTransport) String() string {
+ return "testReliableTransport"
+}
+
+func (t *testReliableTransport) Do(req *http.Request) (*http.Response, error) {
+ return (*http.Client)(t).Do(req)
+}
+
+func (t *testReliableTransport) Properties() transport.Properties {
+ return transport.PropertiesFree | transport.PropertiesReliable
+}
diff --git a/backend/internal/ibd/check_ibd_username.go b/backend/internal/ibd/check_ibd_username.go
new file mode 100644
index 0000000..b026151
--- /dev/null
+++ b/backend/internal/ibd/check_ibd_username.go
@@ -0,0 +1,68 @@
+package ibd
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+const (
+ checkUsernameUrl = "https://sso.accounts.dowjones.com/getuser"
+)
+
+func (c *Client) CheckIBDUsername(ctx context.Context, username string) (bool, error) {
+ cfg, err := c.getLoginPage(ctx)
+ if err != nil {
+ return false, err
+ }
+
+ return c.checkIBDUsername(ctx, cfg, username)
+}
+
+func (c *Client) checkIBDUsername(ctx context.Context, cfg *authConfig, username string) (bool, error) {
+ body := map[string]string{
+ "username": username,
+ "csrf": cfg.ExtraParams.Csrf,
+ }
+ bodyJson, err := json.Marshal(body)
+ if err != nil {
+ return false, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, checkUsernameUrl, bytes.NewReader(bodyJson))
+ if err != nil {
+ return false, err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-REMOTE-USER", username)
+ req.Header.Set("X-REQUEST-EDITIONID", "IBD-EN_US")
+ req.Header.Set("X-REQUEST-SCHEME", "https")
+
+ resp, err := c.Do(req, withExpectedStatuses(http.StatusOK, http.StatusUnauthorized))
+ if err != nil {
+ return false, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return false, nil
+ } else if resp.StatusCode != http.StatusOK {
+ contentBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return false, fmt.Errorf("failed to read response body: %w", err)
+ }
+ content := string(contentBytes)
+ return false, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ content,
+ )
+ }
+ return true, nil
+}
diff --git a/backend/internal/ibd/client.go b/backend/internal/ibd/client.go
new file mode 100644
index 0000000..c8575e3
--- /dev/null
+++ b/backend/internal/ibd/client.go
@@ -0,0 +1,97 @@
+package ibd
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "slices"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+)
+
+var ErrNoAvailableCookies = errors.New("no available cookies")
+var ErrNoAvailableTransports = errors.New("no available transports")
+
+type Client struct {
+ transports []transport.Transport
+ db database.Executor
+ kms keys.KeyManagementService
+}
+
+func NewClient(
+ db database.Executor,
+ kms keys.KeyManagementService,
+ transports ...transport.Transport,
+) *Client {
+ return &Client{transports, db, kms}
+}
+
+func (c *Client) getCookie(ctx context.Context, subject *string) (uint, *http.Cookie, error) {
+ if subject == nil {
+ // No subject requirement, get any cookie
+ cookie, err := database.GetAnyCookie(ctx, c.db, c.kms)
+ if err != nil {
+ return 0, nil, err
+ }
+ if cookie == nil {
+ return 0, nil, ErrNoAvailableCookies
+ }
+
+ return cookie.ID, cookie.ToHTTPCookie(), nil
+ }
+
+ // Get cookie by subject
+ cookies, err := database.GetCookies(ctx, c.db, c.kms, *subject, false)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ if len(cookies) == 0 {
+ return 0, nil, ErrNoAvailableCookies
+ }
+
+ cookie := cookies[0]
+
+ return cookie.ID, cookie.ToHTTPCookie(), nil
+}
+
+func (c *Client) Do(req *http.Request, opts ...optionFunc) (*http.Response, error) {
+ o := defaultOptions
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ // Sort and filter transports by properties
+ transports := transport.FilterTransports(c.transports, o.requiredProps)
+ transport.SortTransports(transports)
+
+ for _, tp := range transports {
+ resp, err := tp.Do(req)
+ if errors.Is(err, transport.ErrUnsupportedRequest) {
+ // Skip unsupported transport
+ continue
+ }
+ if err != nil {
+ slog.ErrorContext(req.Context(), "transport error",
+ "transport", tp.String(),
+ "error", err,
+ )
+ continue
+ }
+ if slices.Contains(o.expectedStatuses, resp.StatusCode) {
+ return resp, nil
+ } else {
+ slog.ErrorContext(req.Context(), "unexpected status code",
+ "transport", tp.String(),
+ "expected", o.expectedStatuses,
+ "actual", resp.StatusCode,
+ )
+ continue
+ }
+ }
+
+ return nil, ErrNoAvailableTransports
+}
diff --git a/backend/internal/ibd/client_test.go b/backend/internal/ibd/client_test.go
new file mode 100644
index 0000000..2368a31
--- /dev/null
+++ b/backend/internal/ibd/client_test.go
@@ -0,0 +1,201 @@
+package ibd
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "math/rand/v2"
+ "testing"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ _ "github.com/lib/pq"
+ "github.com/ory/dockertest/v3"
+ "github.com/ory/dockertest/v3/docker"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ db *sql.DB
+ maxTime = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
+ letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+)
+
+func TestMain(m *testing.M) {
+ pool, err := dockertest.NewPool("")
+ if err != nil {
+ log.Fatalf("Could not create pool: %s", err)
+ }
+
+ err = pool.Client.Ping()
+ if err != nil {
+ log.Fatalf("Could not connect to Docker: %s", err)
+ }
+
+ resource, err := pool.RunWithOptions(&dockertest.RunOptions{
+ Repository: "postgres",
+ Tag: "16",
+ Env: []string{
+ "POSTGRES_PASSWORD=secret",
+ "POSTGRES_USER=ibd-client-test",
+ "POSTGRES_DB=ibd-client-test",
+ "listen_addresses='*'",
+ },
+ Cmd: []string{
+ "postgres",
+ "-c",
+ "log_statement=all",
+ },
+ }, func(config *docker.HostConfig) {
+ config.AutoRemove = true
+ config.RestartPolicy = docker.RestartPolicy{Name: "no"}
+ })
+ if err != nil {
+ log.Fatalf("Could not start resource: %s", err)
+ }
+
+ hostAndPort := resource.GetHostPort("5432/tcp")
+ databaseUrl := fmt.Sprintf("postgres://ibd-client-test:secret@%s/ibd-client-test?sslmode=disable", hostAndPort)
+
+ // Kill container after 120 seconds
+ _ = resource.Expire(120)
+
+ pool.MaxWait = 120 * time.Second
+ if err = pool.Retry(func() error {
+ db, err = sql.Open("postgres", databaseUrl)
+ if err != nil {
+ return err
+ }
+ return db.Ping()
+ }); err != nil {
+ log.Fatalf("Could not connect to database: %s", err)
+ }
+
+ err = database.Migrate(context.Background(), databaseUrl)
+ if err != nil {
+ log.Fatalf("Could not migrate database: %s", err)
+ }
+
+ defer func() {
+ if err := pool.Purge(resource); err != nil {
+ log.Fatalf("Could not purge resource: %s", err)
+ }
+ }()
+
+ m.Run()
+}
+
+func randStringRunes(n int) string {
+ b := make([]rune, n)
+ for i := range b {
+ b[i] = letterRunes[rand.IntN(len(letterRunes))]
+ }
+ return string(b)
+}
+
+func addCookie(t *testing.T) (user, token string) {
+ t.Helper()
+
+ // Randomly generate a user and token
+ user = randStringRunes(8)
+ token = randStringRunes(16)
+
+ ciphertext, key, err := keys.Encrypt(context.Background(), new(kmsStub), "", []byte(token))
+ require.NoError(t, err)
+
+ tx, err := db.Begin()
+ require.NoError(t, err)
+
+ var keyID uint
+ err = tx.QueryRow(`
+INSERT INTO keys (kms_key_name, encrypted_key)
+ VALUES ('', $1)
+ RETURNING id;
+`, key).Scan(&keyID)
+ require.NoError(t, err)
+
+ _, err = tx.Exec(`
+INSERT
+INTO users (subject, encryption_key)
+VALUES ($1, $2);
+`, user, keyID)
+ require.NoError(t, err)
+
+ _, err = tx.Exec(`
+INSERT
+INTO ibd_tokens (user_subject, token, encryption_key, expires_at)
+VALUES ($1, $2, $3, $4);`,
+ user,
+ ciphertext,
+ keyID,
+ maxTime,
+ )
+ require.NoError(t, err)
+
+ err = tx.Commit()
+ require.NoError(t, err)
+
+ return user, token
+}
+
+func TestClient_getCookie(t *testing.T) {
+ t.Run("no cookies", func(t *testing.T) {
+ client := NewClient(db, new(kmsStub))
+
+ _, _, err := client.getCookie(context.Background(), nil)
+ assert.ErrorIs(t, err, ErrNoAvailableCookies)
+ })
+
+ t.Run("no cookies by subject", func(t *testing.T) {
+ client := NewClient(db, new(kmsStub))
+
+ subject := "test"
+ _, _, err := client.getCookie(context.Background(), &subject)
+ assert.ErrorIs(t, err, ErrNoAvailableCookies)
+ })
+
+ t.Run("get any cookie", func(t *testing.T) {
+ _, token := addCookie(t)
+
+ client := NewClient(db, new(kmsStub))
+
+ _, cookie, err := client.getCookie(context.Background(), nil)
+ require.NoError(t, err)
+ assert.Equal(t, cookieName, cookie.Name)
+ assert.Equal(t, token, cookie.Value)
+ assert.Equal(t, "/", cookie.Path)
+ assert.Equal(t, maxTime, cookie.Expires)
+ assert.Equal(t, "investors.com", cookie.Domain)
+ })
+
+ t.Run("get cookie by subject", func(t *testing.T) {
+ subject, token := addCookie(t)
+
+ client := NewClient(db, new(kmsStub))
+
+ _, cookie, err := client.getCookie(context.Background(), &subject)
+ require.NoError(t, err)
+ assert.Equal(t, cookieName, cookie.Name)
+ assert.Equal(t, token, cookie.Value)
+ assert.Equal(t, "/", cookie.Path)
+ assert.Equal(t, maxTime, cookie.Expires)
+ assert.Equal(t, "investors.com", cookie.Domain)
+ })
+}
+
+type kmsStub struct{}
+
+func (k *kmsStub) Close() error {
+ return nil
+}
+
+func (k *kmsStub) Encrypt(_ context.Context, _ string, plaintext []byte) ([]byte, error) {
+ return plaintext, nil
+}
+
+func (k *kmsStub) Decrypt(_ context.Context, _ string, ciphertext []byte) ([]byte, error) {
+ return ciphertext, nil
+}
diff --git a/backend/internal/ibd/html_helpers.go b/backend/internal/ibd/html_helpers.go
new file mode 100644
index 0000000..0176bc5
--- /dev/null
+++ b/backend/internal/ibd/html_helpers.go
@@ -0,0 +1,99 @@
+package ibd
+
+import (
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+func findChildren(node *html.Node, f func(node *html.Node) bool) (found []*html.Node) {
+ for c := node.FirstChild; c != nil; c = c.NextSibling {
+ if f(c) {
+ found = append(found, c)
+ }
+ }
+ return
+}
+
+func findChildrenRecursive(node *html.Node, f func(node *html.Node) bool) (found []*html.Node) {
+ if f(node) {
+ found = append(found, node)
+ }
+
+ for c := node.FirstChild; c != nil; c = c.NextSibling {
+ found = append(found, findChildrenRecursive(c, f)...)
+ }
+
+ return
+}
+
+func findClass(node *html.Node, className string) (found *html.Node) {
+ if isClass(node, className) {
+ return node
+ }
+
+ for c := node.FirstChild; c != nil; c = c.NextSibling {
+ if found = findClass(c, className); found != nil {
+ return
+ }
+ }
+
+ return
+}
+
+func isClass(node *html.Node, className string) bool {
+ if node.Type == html.ElementNode {
+ for _, attr := range node.Attr {
+ if attr.Key != "class" {
+ continue
+ }
+ classes := strings.Fields(attr.Val)
+ for _, class := range classes {
+ if class == className {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func extractText(node *html.Node) string {
+ var result strings.Builder
+ extractTextInner(node, &result)
+ return result.String()
+}
+
+func extractTextInner(node *html.Node, result *strings.Builder) {
+ if node.Type == html.TextNode {
+ result.WriteString(node.Data)
+ }
+ for c := node.FirstChild; c != nil; c = c.NextSibling {
+ extractTextInner(c, result)
+ }
+}
+
+func findId(node *html.Node, id string) (found *html.Node) {
+ if isId(node, id) {
+ return node
+ }
+
+ for c := node.FirstChild; c != nil; c = c.NextSibling {
+ if found = findId(c, id); found != nil {
+ return
+ }
+ }
+
+ return
+}
+
+func isId(node *html.Node, id string) bool {
+ if node.Type == html.ElementNode {
+ for _, attr := range node.Attr {
+ if attr.Key == "id" && attr.Val == id {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/backend/internal/ibd/html_helpers_test.go b/backend/internal/ibd/html_helpers_test.go
new file mode 100644
index 0000000..d251c39
--- /dev/null
+++ b/backend/internal/ibd/html_helpers_test.go
@@ -0,0 +1,79 @@
+package ibd
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/net/html"
+)
+
+func Test_findClass(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ html string
+ className string
+ found bool
+ expData string
+ }{
+ {
+ name: "class exists",
+ html: `<div class="foo"></div>`,
+ className: "foo",
+ found: true,
+ expData: "div",
+ },
+ {
+ name: "class exists nested",
+ html: `<div class="foo"><a class="abc"></a></div>`,
+ className: "abc",
+ found: true,
+ expData: "a",
+ },
+ {
+ name: "class exists multiple",
+ html: `<div class="foo"><a class="foo"></a></div>`,
+ className: "foo",
+ found: true,
+ expData: "div",
+ },
+ {
+ name: "class missing",
+ html: `<div class="abc"><a class="xyz"></a></div>`,
+ className: "foo",
+ found: false,
+ expData: "",
+ },
+ {
+ name: "class missing",
+ html: `<div id="foo"><a abc="xyz"></a></div>`,
+ className: "foo",
+ found: false,
+ expData: "",
+ },
+ {
+ name: "class exists multiple save div",
+ html: `<div class="foo bar"></div>`,
+ className: "bar",
+ found: true,
+ expData: "div",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ node, err := html.Parse(strings.NewReader(tt.html))
+ require.NoError(t, err)
+
+ got := findClass(node, tt.className)
+ if !tt.found {
+ require.Nil(t, got)
+ return
+ }
+ require.NotNil(t, got)
+ assert.Equal(t, tt.expData, got.Data)
+ })
+ }
+}
diff --git a/backend/internal/ibd/ibd50.go b/backend/internal/ibd/ibd50.go
new file mode 100644
index 0000000..52e28aa
--- /dev/null
+++ b/backend/internal/ibd/ibd50.go
@@ -0,0 +1,182 @@
+package ibd
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+)
+
+const ibd50Url = "https://research.investors.com/Services/SiteAjaxService.asmx/GetIBD50?sortcolumn1=%22ibd100rank%22&sortOrder1=%22asc%22&sortcolumn2=%22%22&sortOrder2=%22ASC%22"
+
+// GetIBD50 returns the IBD50 list.
+func (c *Client) GetIBD50(ctx context.Context) ([]*Stock, error) {
+ // We cannot use the scraper here because scrapfly does not support
+ // Content-Type in GET requests.
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ibd50Url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ cookieId, cookie, err := c.getCookie(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.AddCookie(cookie)
+
+ req.Header.Add("content-type", "application/json; charset=utf-8")
+ // Add browser-emulating headers
+ req.Header.Add("accept", "*/*")
+ req.Header.Add("accept-language", "en-US,en;q=0.9")
+ req.Header.Add("newrelic", "eyJ2IjpbMCwxXSwiZCI6eyJ0eSI6IkJyb3dzZXIiLCJhYyI6IjMzOTYxMDYiLCJhcCI6IjEzODU5ODMwMDEiLCJpZCI6IjM1Zjk5NmM2MzNjYTViMWYiLCJ0ciI6IjM3ZmRhZmJlOGY2YjhmYTMwYWMzOTkzOGNlMmM0OWMxIiwidGkiOjE3MjIyNzg0NTk3MjUsInRrIjoiMTAyMjY4MSJ9fQ==")
+ req.Header.Add("priority", "u=1, i")
+ req.Header.Add("referer", "https://research.investors.com/stock-lists/ibd-50/")
+ req.Header.Add("sec-ch-ua", "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\"")
+ req.Header.Add("sec-ch-ua-mobile", "?0")
+ req.Header.Add("sec-ch-ua-platform", "\"macOS\"")
+ req.Header.Add("sec-fetch-dest", "empty")
+ req.Header.Add("sec-fetch-mode", "cors")
+ req.Header.Add("sec-fetch-site", "same-origin")
+ req.Header.Add("traceparent", "00-37fdafbe8f6b8fa30ac39938ce2c49c1-35f996c633ca5b1f-01")
+ req.Header.Add("tracestate", "1022681@nr=0-1-3396106-1385983001-35f996c633ca5b1f----1722278459725")
+ req.Header.Add("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
+ req.Header.Add("x-newrelic-id", "VwUOV1dTDhABV1FRBgQOVVUF")
+ req.Header.Add("x-requested-with", "XMLHttpRequest")
+
+ resp, err := c.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ var ibd50Resp getIBD50Response
+ if err = json.NewDecoder(resp.Body).Decode(&ibd50Resp); err != nil {
+ return nil, err
+ }
+
+ // If there are less than 10 stocks in the IBD50 list, it's likely that authentication failed.
+ if len(ibd50Resp.D.ETablesDataList) < 10 {
+ // Report cookie failure to DB
+ if err = database.ReportCookieFailure(ctx, c.db, cookieId); err != nil {
+ slog.Error("Failed to report cookie failure", "error", err)
+ }
+ return nil, errors.New("failed to get IBD50 list")
+ }
+
+ return ibd50Resp.ToStockList(), nil
+}
+
+type Stock struct {
+ Rank int64
+ Symbol string
+ Name string
+
+ QuoteURL *url.URL
+}
+
+type getIBD50Response struct {
+ D struct {
+ Type *string `json:"__type"`
+ ETablesDataList []struct {
+ Rank string `json:"Rank"`
+ Symbol string `json:"Symbol"`
+ CompanyName string `json:"CompanyName"`
+ CompRating *string `json:"CompRating"`
+ EPSRank *string `json:"EPSRank"`
+ RelSt *string `json:"RelSt"`
+ GrpStr *string `json:"GrpStr"`
+ Smr *string `json:"Smr"`
+ AccDis *string `json:"AccDis"`
+ SponRating *string `json:"SponRating"`
+ Price *string `json:"Price"`
+ PriceClose *string `json:"PriceClose"`
+ PriceChange *string `json:"PriceChange"`
+ PricePerChange *string `json:"PricePerChange"`
+ VolPerChange *string `json:"VolPerChange"`
+ DailyVol *string `json:"DailyVol"`
+ WeekHigh52 *string `json:"WeekHigh52"`
+ PerOffHigh *string `json:"PerOffHigh"`
+ PERatio *string `json:"PERatio"`
+ DivYield *string `json:"DivYield"`
+ LastQtrSalesPerChg *string `json:"LastQtrSalesPerChg"`
+ LastQtrEpsPerChg *string `json:"LastQtrEpsPerChg"`
+ ConsecQtrEpsGrt15 *string `json:"ConsecQtrEpsGrt15"`
+ CurQtrEpsEstPerChg *string `json:"CurQtrEpsEstPerChg"`
+ CurYrEpsEstPerChg *string `json:"CurYrEpsEstPerChg"`
+ PretaxMargin *string `json:"PretaxMargin"`
+ ROE *string `json:"ROE"`
+ MgmtOwnsPer *string `json:"MgmtOwnsPer"`
+ QuoteUrl *string `json:"QuoteUrl"`
+ StockCheckupUrl *string `json:"StockCheckupUrl"`
+ MarketsmithUrl *string `json:"MarketsmithUrl"`
+ LeaderboardUrl *string `json:"LeaderboardUrl"`
+ ChartAnalysisUrl *string `json:"ChartAnalysisUrl"`
+ Ibd100NewEntryFlag *string `json:"Ibd100NewEntryFlag"`
+ Ibd100UpInRankFlag *string `json:"Ibd100UpInRankFlag"`
+ IbdBigCap20NewEntryFlag *string `json:"IbdBigCap20NewEntryFlag"`
+ CompDesc *string `json:"CompDesc"`
+ NumberFunds *string `json:"NumberFunds"`
+ GlobalRank *string `json:"GlobalRank"`
+ EPSPriorQtr *string `json:"EPSPriorQtr"`
+ QtrsFundIncrease *string `json:"QtrsFundIncrease"`
+ } `json:"ETablesDataList"`
+ IBD50PdfUrl *string `json:"IBD50PdfUrl"`
+ CAP20PdfUrl *string `json:"CAP20PdfUrl"`
+ IBD50Date *string `json:"IBD50Date"`
+ CAP20Date *string `json:"CAP20Date"`
+ UpdatedDate *string `json:"UpdatedDate"`
+ GetAllFlags *string `json:"getAllFlags"`
+ Flag *int `json:"flag"`
+ Message *string `json:"Message"`
+ PaywallDesktopMarkup *string `json:"PaywallDesktopMarkup"`
+ PaywallMobileMarkup *string `json:"PaywallMobileMarkup"`
+ } `json:"d"`
+}
+
+func (r getIBD50Response) ToStockList() (ibd []*Stock) {
+ ibd = make([]*Stock, 0, len(r.D.ETablesDataList))
+ for _, data := range r.D.ETablesDataList {
+ rank, err := strconv.ParseInt(data.Rank, 10, 64)
+ if err != nil {
+ slog.Error(
+ "Failed to parse Rank",
+ "error", err,
+ "rank", data.Rank,
+ "symbol", data.Symbol,
+ "name", data.CompanyName,
+ )
+ continue
+ }
+
+ var quoteUrl *url.URL
+ if data.QuoteUrl != nil {
+ quoteUrl, err = url.Parse(*data.QuoteUrl)
+ if err != nil {
+ slog.Error(
+ "Failed to parse QuoteUrl",
+ "error", err,
+ "quoteUrl", *data.QuoteUrl,
+ "rank", data.Rank,
+ "symbol", data.Symbol,
+ "name", data.CompanyName,
+ )
+ }
+ }
+
+ ibd = append(ibd, &Stock{
+ Rank: rank,
+ Symbol: data.Symbol,
+ Name: data.CompanyName,
+ QuoteURL: quoteUrl,
+ })
+ }
+ return
+}
diff --git a/backend/internal/ibd/options.go b/backend/internal/ibd/options.go
new file mode 100644
index 0000000..5c378d5
--- /dev/null
+++ b/backend/internal/ibd/options.go
@@ -0,0 +1,26 @@
+package ibd
+
+import "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+
+type optionFunc func(*options)
+
+var defaultOptions = options{
+ expectedStatuses: []int{200},
+}
+
+type options struct {
+ expectedStatuses []int
+ requiredProps transport.Properties
+}
+
+func withExpectedStatuses(statuses ...int) optionFunc {
+ return func(o *options) {
+ o.expectedStatuses = append(o.expectedStatuses, statuses...)
+ }
+}
+
+func withRequiredProps(props transport.Properties) optionFunc {
+ return func(o *options) {
+ o.requiredProps = props
+ }
+}
diff --git a/backend/internal/ibd/search.go b/backend/internal/ibd/search.go
new file mode 100644
index 0000000..341b14b
--- /dev/null
+++ b/backend/internal/ibd/search.go
@@ -0,0 +1,111 @@
+package ibd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+)
+
+const (
+ searchUrl = "https://ibdservices.investors.com/im/api/search"
+)
+
+var ErrSymbolNotFound = fmt.Errorf("symbol not found")
+
+func (c *Client) Search(ctx context.Context, symbol string) (database.Stock, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchUrl, nil)
+ if err != nil {
+ return database.Stock{}, err
+ }
+
+ _, cookie, err := c.getCookie(ctx, nil)
+ if err != nil {
+ return database.Stock{}, err
+ }
+ req.AddCookie(cookie)
+
+ params := url.Values{}
+ params.Set("key", symbol)
+ req.URL.RawQuery = params.Encode()
+
+ resp, err := c.Do(req)
+ if err != nil {
+ return database.Stock{}, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return database.Stock{}, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return database.Stock{}, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ var sr searchResponse
+ if err = json.NewDecoder(resp.Body).Decode(&sr); err != nil {
+ return database.Stock{}, err
+ }
+
+ for _, stock := range sr.StockData {
+ if stock.Symbol == symbol {
+ return database.Stock{
+ Symbol: stock.Symbol,
+ Name: stock.Company,
+ IBDUrl: stock.QuoteUrl,
+ }, nil
+ }
+ }
+
+ return database.Stock{}, ErrSymbolNotFound
+}
+
+type searchResponse struct {
+ Status int `json:"_status"`
+ Timestamp string `json:"_timestamp"`
+ StockData []struct {
+ Id int `json:"id"`
+ Symbol string `json:"symbol"`
+ Company string `json:"company"`
+ PriceDate string `json:"priceDate"`
+ Price float64 `json:"price"`
+ PreviousPrice float64 `json:"previousPrice"`
+ PriceChange float64 `json:"priceChange"`
+ PricePctChange float64 `json:"pricePctChange"`
+ Volume int `json:"volume"`
+ VolumeChange int `json:"volumeChange"`
+ VolumePctChange int `json:"volumePctChange"`
+ QuoteUrl string `json:"quoteUrl"`
+ } `json:"stockData"`
+ News []struct {
+ Title string `json:"title"`
+ Category string `json:"category"`
+ Body string `json:"body"`
+ ImageAlt string `json:"imageAlt"`
+ ImageUrl string `json:"imageUrl"`
+ NewsUrl string `json:"newsUrl"`
+ CategoryUrl string `json:"categoryUrl"`
+ PublishDate time.Time `json:"publishDate"`
+ PublishDateUnixts int `json:"publishDateUnixts"`
+ Stocks []struct {
+ Id int `json:"id"`
+ Index int `json:"index"`
+ Symbol string `json:"symbol"`
+ PricePctChange string `json:"pricePctChange"`
+ } `json:"stocks"`
+ VideoFormat bool `json:"videoFormat"`
+ } `json:"news"`
+ FullUrl string `json:"fullUrl"`
+}
diff --git a/backend/internal/ibd/search_test.go b/backend/internal/ibd/search_test.go
new file mode 100644
index 0000000..05e93dc
--- /dev/null
+++ b/backend/internal/ibd/search_test.go
@@ -0,0 +1,205 @@
+package ibd
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+ "github.com/jarcoal/httpmock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const searchResponseJSON = `
+{
+ "_status": 200,
+ "_timestamp": "1722879439.724106",
+ "stockData": [
+ {
+ "id": 13717,
+ "symbol": "AAPL",
+ "company": "Apple",
+ "priceDate": "2024-08-05T09:18:00",
+ "price": 212.33,
+ "previousPrice": 219.86,
+ "priceChange": -7.53,
+ "pricePctChange": -3.42,
+ "volume": 643433,
+ "volumeChange": -2138,
+ "volumePctChange": 124,
+ "quoteUrl": "https://research.investors.com/stock-quotes/nasdaq-apple-aapl.htm"
+ },
+ {
+ "id": 79964,
+ "symbol": "AAPU",
+ "company": "Direxion AAPL Bull 2X",
+ "priceDate": "2024-08-05T09:18:00",
+ "price": 32.48,
+ "previousPrice": 34.9,
+ "priceChange": -2.42,
+ "pricePctChange": -6.92,
+ "volume": 15265,
+ "volumeChange": -35,
+ "volumePctChange": 212,
+ "quoteUrl": "https://research.investors.com/stock-quotes/nasdaq-direxion-aapl-bull-2x-aapu.htm"
+ },
+ {
+ "id": 80423,
+ "symbol": "APLY",
+ "company": "YieldMax AAPL Option Incm",
+ "priceDate": "2024-08-05T09:11:00",
+ "price": 17.52,
+ "previousPrice": 18.15,
+ "priceChange": -0.63,
+ "pricePctChange": -3.47,
+ "volume": 617,
+ "volumeChange": -2,
+ "volumePctChange": 97,
+ "quoteUrl": "https://research.investors.com/stock-quotes/nyse-yieldmax-aapl-option-incm-aply.htm"
+ },
+ {
+ "id": 79962,
+ "symbol": "AAPD",
+ "company": "Direxion Dly AAPL Br 1X",
+ "priceDate": "2024-08-05T09:18:00",
+ "price": 18.11,
+ "previousPrice": 17.53,
+ "priceChange": 0.58,
+ "pricePctChange": 3.31,
+ "volume": 14572,
+ "volumeChange": -7,
+ "volumePctChange": 885,
+ "quoteUrl": "https://research.investors.com/stock-quotes/nasdaq-direxion-dly-aapl-br-1x-aapd.htm"
+ },
+ {
+ "id": 79968,
+ "symbol": "AAPB",
+ "company": "GraniteSh 2x Lg AAPL",
+ "priceDate": "2024-08-05T09:16:00",
+ "price": 25.22,
+ "previousPrice": 27.25,
+ "priceChange": -2.03,
+ "pricePctChange": -7.45,
+ "volume": 2505,
+ "volumeChange": -7,
+ "volumePctChange": 151,
+ "quoteUrl": "https://research.investors.com/stock-quotes/nasdaq-granitesh-2x-lg-aapl-aapb.htm"
+ }
+ ],
+ "news": [
+ {
+ "title": "Warren Buffett Dumped Berkshire Hathaway's Favorite Stocks — Right Before They Plunged",
+ "category": "News",
+ "body": "Berkshire Hathaway earnings rose solidly in Q2. Warren Buffett sold nearly half his Apple stock stake. Berkshire stock fell...",
+ "imageAlt": "",
+ "imageUrl": "https://www.investors.com/wp-content/uploads/2024/06/Stock-WarrenBuffettwave-01-shutt-640x360.jpg",
+ "newsUrl": "https://investors.com/news/berkshire-hathaway-earnings-q2-2024-warren-buffett-apple/",
+ "categoryUrl": "https://investors.com/category/news/",
+ "publishDate": "2024-08-05T15:51:57+00:00",
+ "publishDateUnixts": 1722858717,
+ "stocks": [
+ {
+ "id": 13717,
+ "index": 0,
+ "symbol": "AAPL",
+ "pricePctChange": "-3.42"
+ }
+ ],
+ "videoFormat": false
+ },
+ {
+ "title": "Nvidia Plunges On Report Of AI Chip Flaw; Is It A Buy Now?",
+ "category": "Research",
+ "body": "Nvidia will roll out its Blackwell chip at least three months later than planned.",
+ "imageAlt": "",
+ "imageUrl": "https://www.investors.com/wp-content/uploads/2024/01/Stock-Nvidia-studio-01-company-640x360.jpg",
+ "newsUrl": "https://investors.com/research/nvda-stock-is-nvidia-a-buy-2/",
+ "categoryUrl": "https://investors.com/category/research/",
+ "publishDate": "2024-08-05T14:59:22+00:00",
+ "publishDateUnixts": 1722855562,
+ "stocks": [
+ {
+ "id": 38607,
+ "index": 0,
+ "symbol": "NVDA",
+ "pricePctChange": "-5.18"
+ }
+ ],
+ "videoFormat": false
+ },
+ {
+ "title": "Magnificent Seven Stocks Roiled: Nvidia Plunges On AI Chip Delay; Apple, Tesla Dive",
+ "category": "Research",
+ "body": "Nvidia stock dived Monday, while Apple and Tesla also fell sharply.",
+ "imageAlt": "",
+ "imageUrl": "https://www.investors.com/wp-content/uploads/2022/08/Stock-Nvidia-RTXa5500-comp-640x360.jpg",
+ "newsUrl": "https://investors.com/research/magnificent-seven-stocks-to-buy-and-and-watch/",
+ "categoryUrl": "https://investors.com/category/research/",
+ "publishDate": "2024-08-05T14:51:42+00:00",
+ "publishDateUnixts": 1722855102,
+ "stocks": [
+ {
+ "id": 13717,
+ "index": 0,
+ "symbol": "AAPL",
+ "pricePctChange": "-3.42"
+ }
+ ],
+ "videoFormat": false
+ }
+ ],
+ "fullUrl": "https://www.investors.com/search-results/?query=AAPL"
+}`
+
+const emptySearchResponseJSON = `
+{
+ "_status": 200,
+ "_timestamp": "1722879662.804395",
+ "stockData": [],
+ "news": [],
+ "fullUrl": "https://www.investors.com/search-results/?query=abcdefg"
+}`
+
+func TestClient_Search(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ f func(t *testing.T, client *Client)
+ }{
+ {
+ name: "found",
+ response: searchResponseJSON,
+ f: func(t *testing.T, client *Client) {
+ u, err := client.Search(context.Background(), "AAPL")
+ require.NoError(t, err)
+ assert.Equal(t, "AAPL", u.Symbol)
+ assert.Equal(t, "Apple", u.Name)
+ assert.Equal(t, "https://research.investors.com/stock-quotes/nasdaq-apple-aapl.htm", u.IBDUrl)
+ },
+ },
+ {
+ name: "not found",
+ response: emptySearchResponseJSON,
+ f: func(t *testing.T, client *Client) {
+ _, err := client.Search(context.Background(), "abcdefg")
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, ErrSymbolNotFound)
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tp := httpmock.NewMockTransport()
+ tp.RegisterResponder("GET", searchUrl, httpmock.NewStringResponder(200, tt.response))
+
+ client := NewClient(
+ db,
+ new(kmsStub),
+ transport.NewStandardTransport(&http.Client{Transport: tp}),
+ )
+
+ tt.f(t, client)
+ })
+ }
+}
diff --git a/backend/internal/ibd/stockinfo.go b/backend/internal/ibd/stockinfo.go
new file mode 100644
index 0000000..1e3b96f
--- /dev/null
+++ b/backend/internal/ibd/stockinfo.go
@@ -0,0 +1,233 @@
+package ibd
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/utils"
+
+ "github.com/Rhymond/go-money"
+ "golang.org/x/net/html"
+)
+
+func (c *Client) StockInfo(ctx context.Context, uri string) (*database.StockInfo, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ _, cookie, err := c.getCookie(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.AddCookie(cookie)
+
+ // Set required query parameters
+ params := url.Values{}
+ params.Set("list", "ibd50")
+ params.Set("type", "weekly")
+ req.URL.RawQuery = params.Encode()
+
+ resp, err := c.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return nil, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ node, err := html.Parse(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ name, symbol, err := extractNameAndSymbol(node)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract name and symbol: %w", err)
+ }
+ chartAnalysis, err := extractChartAnalysis(node)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract chart analysis: %w", err)
+ }
+ ratings, err := extractRatings(node)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract ratings: %w", err)
+ }
+ price, err := extractPrice(node)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract price: %w", err)
+ }
+
+ return &database.StockInfo{
+ Symbol: symbol,
+ Name: name,
+ ChartAnalysis: chartAnalysis,
+ Ratings: ratings,
+ Price: price,
+ }, nil
+}
+
+func extractNameAndSymbol(node *html.Node) (name string, symbol string, err error) {
+ // Find span with ID "quote-symbol"
+ quoteSymbolNode := findId(node, "quote-symbol")
+ if quoteSymbolNode == nil {
+ return "", "", fmt.Errorf("could not find `quote-symbol` span")
+ }
+
+ // Get the text of the quote-symbol span
+ name = strings.TrimSpace(extractText(quoteSymbolNode))
+
+ // Find span with ID "qteSymb"
+ qteSymbNode := findId(node, "qteSymb")
+ if qteSymbNode == nil {
+ return "", "", fmt.Errorf("could not find `qteSymb` span")
+ }
+
+ // Get the text of the qteSymb span
+ symbol = strings.TrimSpace(extractText(qteSymbNode))
+
+ // Get index of last closing parenthesis
+ lastParenIndex := strings.LastIndex(name, ")")
+ if lastParenIndex == -1 {
+ return
+ }
+
+ // Find the last opening parenthesis before the closing parenthesis
+ lastOpenParenIndex := strings.LastIndex(name[:lastParenIndex], "(")
+ if lastOpenParenIndex == -1 {
+ return
+ }
+
+ // Remove the parenthesis pair
+ name = strings.TrimSpace(name[:lastOpenParenIndex] + name[lastParenIndex+1:])
+ return
+}
+
+func extractPrice(node *html.Node) (*money.Money, error) {
+ // Find the div with the ID "lstPrice"
+ lstPriceNode := findId(node, "lstPrice")
+ if lstPriceNode == nil {
+ return nil, fmt.Errorf("could not find `lstPrice` div")
+ }
+
+ // Get the text of the lstPrice div
+ priceStr := strings.TrimSpace(extractText(lstPriceNode))
+
+ // Parse the price
+ price, err := utils.ParseMoney(priceStr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse price: %w", err)
+ }
+
+ return price, nil
+}
+
+func extractRatings(node *html.Node) (ratings database.Ratings, err error) {
+ // Find the div with class "smartContent"
+ smartSelectNode := findClass(node, "smartContent")
+ if smartSelectNode == nil {
+ return ratings, fmt.Errorf("could not find `smartContent` div")
+ }
+
+ // Iterate over children, looking for "smartRating" divs
+ for c := smartSelectNode.FirstChild; c != nil; c = c.NextSibling {
+ if !isClass(c, "smartRating") {
+ continue
+ }
+
+ err = processSmartRating(c, &ratings)
+ if err != nil {
+ return
+ }
+ }
+ return
+}
+
+// processSmartRating extracts the rating from a "smartRating" div and updates the ratings struct.
+//
+// The node should look like this:
+//
+// <ul class="smartRating">
+// <li><a><span>Composite Rating</span></a></li>
+// <li>94</li>
+// ...
+// </ul>
+func processSmartRating(node *html.Node, ratings *database.Ratings) error {
+ // Check that the node is a ul
+ if node.Type != html.ElementNode || node.Data != "ul" {
+ return fmt.Errorf("expected ul node, got %s", node.Data)
+ }
+
+ // Get all `li` children
+ children := findChildren(node, func(node *html.Node) bool {
+ return node.Type == html.ElementNode && node.Data == "li"
+ })
+
+ // Extract the rating name
+ ratingName := strings.TrimSpace(extractText(children[0]))
+
+ // Extract the rating value
+ ratingValueStr := strings.TrimSpace(extractText(children[1]))
+
+ switch ratingName {
+ case "Composite Rating":
+ ratingValue, err := strconv.ParseUint(ratingValueStr, 10, 8)
+ if err != nil {
+ return fmt.Errorf("failed to parse Composite Rating: %w", err)
+ }
+ ratings.Composite = uint8(ratingValue)
+ case "EPS Rating":
+ ratingValue, err := strconv.ParseUint(ratingValueStr, 10, 8)
+ if err != nil {
+ return fmt.Errorf("failed to parse EPS Rating: %w", err)
+ }
+ ratings.EPS = uint8(ratingValue)
+ case "RS Rating":
+ ratingValue, err := strconv.ParseUint(ratingValueStr, 10, 8)
+ if err != nil {
+ return fmt.Errorf("failed to parse RS Rating: %w", err)
+ }
+ ratings.RelStr = uint8(ratingValue)
+ case "Group RS Rating":
+ ratings.GroupRelStr = database.LetterRatingFromString(ratingValueStr)
+ case "SMR Rating":
+ ratings.SMR = database.LetterRatingFromString(ratingValueStr)
+ case "Acc/Dis Rating":
+ ratings.AccDis = database.LetterRatingFromString(ratingValueStr)
+ default:
+ return fmt.Errorf("unknown rating name: %s", ratingName)
+ }
+
+ return nil
+}
+
+func extractChartAnalysis(node *html.Node) (string, error) {
+ // Find the div with class "chartAnalysis"
+ chartAnalysisNode := findClass(node, "chartAnalysis")
+ if chartAnalysisNode == nil {
+ return "", fmt.Errorf("could not find `chartAnalysis` div")
+ }
+
+ // Get the text of the chart analysis div
+ chartAnalysis := strings.TrimSpace(extractText(chartAnalysisNode))
+
+ return chartAnalysis, nil
+}
diff --git a/backend/internal/ibd/transport/scrapfly/options.go b/backend/internal/ibd/transport/scrapfly/options.go
new file mode 100644
index 0000000..f16a4b0
--- /dev/null
+++ b/backend/internal/ibd/transport/scrapfly/options.go
@@ -0,0 +1,84 @@
+package scrapfly
+
+const BaseURL = "https://api.scrapfly.io/scrape"
+
+var defaultScrapeOptions = ScrapeOptions{
+ baseURL: BaseURL,
+ country: nil,
+ asp: true,
+ proxyPool: ProxyPoolDatacenter,
+ renderJS: false,
+ cache: false,
+}
+
+type ScrapeOption func(*ScrapeOptions)
+
+type ScrapeOptions struct {
+ baseURL string
+ country *string
+ asp bool
+ proxyPool ProxyPool
+ renderJS bool
+ cache bool
+ debug bool
+}
+
+type ProxyPool uint8
+
+const (
+ ProxyPoolDatacenter ProxyPool = iota
+ ProxyPoolResidential
+)
+
+func (p ProxyPool) String() string {
+ switch p {
+ case ProxyPoolDatacenter:
+ return "public_datacenter_pool"
+ case ProxyPoolResidential:
+ return "public_residential_pool"
+ default:
+ panic("invalid proxy pool")
+ }
+}
+
+func WithCountry(country string) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.country = &country
+ }
+}
+
+func WithASP(asp bool) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.asp = asp
+ }
+}
+
+func WithProxyPool(proxyPool ProxyPool) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.proxyPool = proxyPool
+ }
+}
+
+func WithRenderJS(jsRender bool) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.renderJS = jsRender
+ }
+}
+
+func WithCache(cache bool) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.cache = cache
+ }
+}
+
+func WithDebug(debug bool) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.debug = debug
+ }
+}
+
+func WithBaseURL(baseURL string) ScrapeOption {
+ return func(o *ScrapeOptions) {
+ o.baseURL = baseURL
+ }
+}
diff --git a/backend/internal/ibd/transport/scrapfly/scraper_types.go b/backend/internal/ibd/transport/scrapfly/scraper_types.go
new file mode 100644
index 0000000..f3cf651
--- /dev/null
+++ b/backend/internal/ibd/transport/scrapfly/scraper_types.go
@@ -0,0 +1,253 @@
+package scrapfly
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type ScraperResponse struct {
+ Config struct {
+ Asp bool `json:"asp"`
+ AutoScroll bool `json:"auto_scroll"`
+ Body interface{} `json:"body"`
+ Cache bool `json:"cache"`
+ CacheClear bool `json:"cache_clear"`
+ CacheTtl int `json:"cache_ttl"`
+ CorrelationId interface{} `json:"correlation_id"`
+ CostBudget interface{} `json:"cost_budget"`
+ Country interface{} `json:"country"`
+ Debug bool `json:"debug"`
+ Dns bool `json:"dns"`
+ Env string `json:"env"`
+ Extract interface{} `json:"extract"`
+ ExtractionModel interface{} `json:"extraction_model"`
+ ExtractionModelCustomSchema interface{} `json:"extraction_model_custom_schema"`
+ ExtractionPrompt interface{} `json:"extraction_prompt"`
+ ExtractionTemplate interface{} `json:"extraction_template"`
+ Format string `json:"format"`
+ Geolocation interface{} `json:"geolocation"`
+ Headers struct {
+ Cookie []string `json:"Cookie"`
+ } `json:"headers"`
+ JobUuid interface{} `json:"job_uuid"`
+ Js interface{} `json:"js"`
+ JsScenario interface{} `json:"js_scenario"`
+ Lang interface{} `json:"lang"`
+ LogEvictionDate string `json:"log_eviction_date"`
+ Method string `json:"method"`
+ Origin string `json:"origin"`
+ Os interface{} `json:"os"`
+ Project string `json:"project"`
+ ProxyPool string `json:"proxy_pool"`
+ RenderJs bool `json:"render_js"`
+ RenderingStage string `json:"rendering_stage"`
+ RenderingWait int `json:"rendering_wait"`
+ Retry bool `json:"retry"`
+ ScheduleName interface{} `json:"schedule_name"`
+ ScreenshotFlags interface{} `json:"screenshot_flags"`
+ ScreenshotResolution interface{} `json:"screenshot_resolution"`
+ Screenshots interface{} `json:"screenshots"`
+ Session interface{} `json:"session"`
+ SessionStickyProxy bool `json:"session_sticky_proxy"`
+ Ssl bool `json:"ssl"`
+ Tags interface{} `json:"tags"`
+ Timeout int `json:"timeout"`
+ Url string `json:"url"`
+ UserUuid string `json:"user_uuid"`
+ Uuid string `json:"uuid"`
+ WaitForSelector interface{} `json:"wait_for_selector"`
+ WebhookName interface{} `json:"webhook_name"`
+ } `json:"config"`
+ Context struct {
+ Asp interface{} `json:"asp"`
+ BandwidthConsumed int `json:"bandwidth_consumed"`
+ BandwidthImagesConsumed int `json:"bandwidth_images_consumed"`
+ Cache struct {
+ Entry interface{} `json:"entry"`
+ State string `json:"state"`
+ } `json:"cache"`
+ Cookies []struct {
+ Comment interface{} `json:"comment"`
+ Domain string `json:"domain"`
+ Expires *string `json:"expires"`
+ HttpOnly bool `json:"http_only"`
+ MaxAge interface{} `json:"max_age"`
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Secure bool `json:"secure"`
+ Size int `json:"size"`
+ Value string `json:"value"`
+ Version interface{} `json:"version"`
+ } `json:"cookies"`
+ Cost struct {
+ Details []struct {
+ Amount int `json:"amount"`
+ Code string `json:"code"`
+ Description string `json:"description"`
+ } `json:"details"`
+ Total int `json:"total"`
+ } `json:"cost"`
+ CreatedAt string `json:"created_at"`
+ Debug interface{} `json:"debug"`
+ Env string `json:"env"`
+ Fingerprint string `json:"fingerprint"`
+ Headers struct {
+ Cookie string `json:"Cookie"`
+ } `json:"headers"`
+ IsXmlHttpRequest bool `json:"is_xml_http_request"`
+ Job interface{} `json:"job"`
+ Lang []string `json:"lang"`
+ Os struct {
+ Distribution string `json:"distribution"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Version string `json:"version"`
+ } `json:"os"`
+ Project string `json:"project"`
+ Proxy struct {
+ Country string `json:"country"`
+ Identity string `json:"identity"`
+ Network string `json:"network"`
+ Pool string `json:"pool"`
+ } `json:"proxy"`
+ Redirects []interface{} `json:"redirects"`
+ Retry int `json:"retry"`
+ Schedule interface{} `json:"schedule"`
+ Session interface{} `json:"session"`
+ Spider interface{} `json:"spider"`
+ Throttler interface{} `json:"throttler"`
+ Uri struct {
+ BaseUrl string `json:"base_url"`
+ Fragment interface{} `json:"fragment"`
+ Host string `json:"host"`
+ Params interface{} `json:"params"`
+ Port int `json:"port"`
+ Query string `json:"query"`
+ RootDomain string `json:"root_domain"`
+ Scheme string `json:"scheme"`
+ } `json:"uri"`
+ Url string `json:"url"`
+ Webhook interface{} `json:"webhook"`
+ } `json:"context"`
+ Insights interface{} `json:"insights"`
+ Result ScraperResult `json:"result"`
+ Uuid string `json:"uuid"`
+}
+
+type ScraperResult struct {
+ BrowserData struct {
+ JavascriptEvaluationResult interface{} `json:"javascript_evaluation_result"`
+ JsScenario []interface{} `json:"js_scenario"`
+ LocalStorageData struct {
+ } `json:"local_storage_data"`
+ SessionStorageData struct {
+ } `json:"session_storage_data"`
+ Websockets []interface{} `json:"websockets"`
+ XhrCall interface{} `json:"xhr_call"`
+ } `json:"browser_data"`
+ Content string `json:"content"`
+ ContentEncoding string `json:"content_encoding"`
+ ContentFormat string `json:"content_format"`
+ ContentType string `json:"content_type"`
+ Cookies []ScraperCookie `json:"cookies"`
+ Data interface{} `json:"data"`
+ Dns interface{} `json:"dns"`
+ Duration float64 `json:"duration"`
+ Error interface{} `json:"error"`
+ ExtractedData interface{} `json:"extracted_data"`
+ Format string `json:"format"`
+ Iframes []interface{} `json:"iframes"`
+ LogUrl string `json:"log_url"`
+ Reason string `json:"reason"`
+ RequestHeaders map[string]string `json:"request_headers"`
+ ResponseHeaders map[string]string `json:"response_headers"`
+ Screenshots struct {
+ } `json:"screenshots"`
+ Size int `json:"size"`
+ Ssl interface{} `json:"ssl"`
+ Status string `json:"status"`
+ StatusCode int `json:"status_code"`
+ Success bool `json:"success"`
+ Url string `json:"url"`
+}
+
+type ScraperCookie struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+ Expires string `json:"expires"`
+ Path string `json:"path"`
+ Comment string `json:"comment"`
+ Domain string `json:"domain"`
+ MaxAge int `json:"max_age"`
+ Secure bool `json:"secure"`
+ HttpOnly bool `json:"http_only"`
+ Version string `json:"version"`
+ Size int `json:"size"`
+}
+
+func (c *ScraperCookie) ToHTTPCookie() (*http.Cookie, error) {
+ var expires time.Time
+ if c.Expires != "" {
+ var err error
+ expires, err = time.Parse("2006-01-02 15:04:05", c.Expires)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse cookie expiration: %w", err)
+ }
+ }
+ return &http.Cookie{
+ Name: c.Name,
+ Value: c.Value,
+ Path: c.Path,
+ Domain: c.Domain,
+ Expires: expires,
+ Secure: c.Secure,
+ HttpOnly: c.HttpOnly,
+ }, nil
+}
+
+func (c *ScraperCookie) FromHTTPCookie(cookie *http.Cookie) {
+ var expires string
+ if !cookie.Expires.IsZero() {
+ expires = cookie.Expires.Format("2006-01-02 15:04:05")
+ }
+ *c = ScraperCookie{
+ Comment: "",
+ Domain: cookie.Domain,
+ Expires: expires,
+ HttpOnly: cookie.HttpOnly,
+ MaxAge: cookie.MaxAge,
+ Name: cookie.Name,
+ Path: cookie.Path,
+ Secure: cookie.Secure,
+ Size: len(cookie.Value),
+ Value: cookie.Value,
+ Version: "",
+ }
+}
+
+func (r *ScraperResponse) ToHTTPResponse() (*http.Response, error) {
+ resp := &http.Response{
+ StatusCode: r.Result.StatusCode,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(r.Result.Content)),
+ ContentLength: int64(len(r.Result.Content)),
+ Close: true,
+ }
+
+ for k, v := range r.Result.ResponseHeaders {
+ resp.Header.Set(k, v)
+ }
+
+ for _, c := range r.Result.Cookies {
+ cookie, err := c.ToHTTPCookie()
+ if err != nil {
+ return nil, err
+ }
+ resp.Header.Add("Set-Cookie", cookie.String())
+ }
+
+ return resp, nil
+}
diff --git a/backend/internal/ibd/transport/scrapfly/scrapfly.go b/backend/internal/ibd/transport/scrapfly/scrapfly.go
new file mode 100644
index 0000000..3b414de
--- /dev/null
+++ b/backend/internal/ibd/transport/scrapfly/scrapfly.go
@@ -0,0 +1,103 @@
+package scrapfly
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/ansg191/ibd-trader-backend/internal/ibd/transport"
+)
+
+type ScrapflyTransport struct {
+ client *http.Client
+ apiKey string
+ options ScrapeOptions
+}
+
+func New(client *http.Client, apiKey string, opts ...ScrapeOption) *ScrapflyTransport {
+ options := defaultScrapeOptions
+ for _, opt := range opts {
+ opt(&options)
+ }
+
+ return &ScrapflyTransport{
+ client: client,
+ apiKey: apiKey,
+ options: options,
+ }
+}
+
+func (s *ScrapflyTransport) String() string {
+ return "scrapfly"
+}
+
+func (s *ScrapflyTransport) Do(req *http.Request) (*http.Response, error) {
+ // Construct scrape request URL
+ scrapeUrl, err := url.Parse(s.options.baseURL)
+ if err != nil {
+ panic(err)
+ }
+ scrapeUrl.RawQuery = s.constructRawQuery(req.URL, req.Header)
+
+ // We can't handle `Content-Type` header on GET requests
+ // Wierd quirk of the Scrapfly API
+ if req.Method == http.MethodGet && req.Header.Get("Content-Type") != "" {
+ return nil, transport.ErrUnsupportedRequest
+ }
+
+ // Construct scrape request
+ scrapeReq, err := http.NewRequestWithContext(req.Context(), req.Method, scrapeUrl.String(), req.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // Send scrape request
+ resp, err := s.client.Do(scrapeReq)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ // Parse scrape response
+ scraperResponse := new(ScraperResponse)
+ err = json.NewDecoder(resp.Body).Decode(scraperResponse)
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert scraper response to http.Response
+ return scraperResponse.ToHTTPResponse()
+}
+
+func (s *ScrapflyTransport) Properties() transport.Properties {
+ return transport.PropertiesReliable
+}
+
+func (s *ScrapflyTransport) constructRawQuery(u *url.URL, headers http.Header) string {
+ params := url.Values{}
+ params.Set("key", s.apiKey)
+ params.Set("url", u.String())
+ if s.options.country != nil {
+ params.Set("country", *s.options.country)
+ }
+ params.Set("asp", strconv.FormatBool(s.options.asp))
+ params.Set("proxy_pool", s.options.proxyPool.String())
+ params.Set("render_js", strconv.FormatBool(s.options.renderJS))
+ params.Set("cache", strconv.FormatBool(s.options.cache))
+
+ for k, v := range headers {
+ for i, vv := range v {
+ params.Add(
+ fmt.Sprintf("headers[%s][%d]", k, i),
+ vv,
+ )
+ }
+ }
+
+ return params.Encode()
+}
diff --git a/backend/internal/ibd/transport/standard.go b/backend/internal/ibd/transport/standard.go
new file mode 100644
index 0000000..9fa9ff9
--- /dev/null
+++ b/backend/internal/ibd/transport/standard.go
@@ -0,0 +1,41 @@
+package transport
+
+import (
+ "net/http"
+
+ "github.com/EDDYCJY/fake-useragent"
+)
+
+type StandardTransport http.Client
+
+func NewStandardTransport(client *http.Client) *StandardTransport {
+ return (*StandardTransport)(client)
+}
+
+func (t *StandardTransport) Do(req *http.Request) (*http.Response, error) {
+ addFakeHeaders(req)
+ return (*http.Client)(t).Do(req)
+}
+
+func (t *StandardTransport) String() string {
+ return "standard"
+}
+
+func (t *StandardTransport) Properties() Properties {
+ return PropertiesFree
+}
+
+func addFakeHeaders(req *http.Request) {
+ req.Header.Set("User-Agent", browser.Linux())
+ req.Header.Set("Sec-CH-UA", `"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"`)
+ req.Header.Set("Sec-CH-UA-Mobile", "?0")
+ req.Header.Set("Sec-CH-UA-Platform", "Linux")
+ req.Header.Set("Upgrade-Insecure-Requests", "1")
+ req.Header.Set("Priority", "u=0, i")
+ req.Header.Set("Sec-Fetch-Site", "none")
+ req.Header.Set("Sec-Fetch-Mode", "navigate")
+ req.Header.Set("Sec-Fetch-Dest", "document")
+ req.Header.Set("Sec-Fetch-User", "?1")
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
+}
diff --git a/backend/internal/ibd/transport/transport.go b/backend/internal/ibd/transport/transport.go
new file mode 100644
index 0000000..95e9ef3
--- /dev/null
+++ b/backend/internal/ibd/transport/transport.go
@@ -0,0 +1,66 @@
+package transport
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+)
+
+var ErrUnsupportedRequest = errors.New("unsupported request")
+
+type Properties uint8
+
+const (
+ // PropertiesFree indicates that the transport is free.
+ // This means that requests made with this transport don't cost any money.
+ PropertiesFree Properties = 1 << iota
+ // PropertiesReliable indicates that the transport is reliable.
+ // This means that requests made with this transport are guaranteed to be
+ // successful if the server is reachable.
+ PropertiesReliable
+)
+
+func (p Properties) IsReliable() bool {
+ return p&PropertiesReliable != 0
+}
+
+func (p Properties) IsFree() bool {
+ return p&PropertiesFree != 0
+}
+
+type Transport interface {
+ fmt.Stringer
+
+ Do(req *http.Request) (*http.Response, error)
+ Properties() Properties
+}
+
+// SortTransports sorts the transports by their properties.
+//
+// The transports are sorted in the following order:
+// 1. Free transports
+// 2. Reliable transports
+func SortTransports(transports []Transport) {
+ priorities := map[Properties]int{
+ PropertiesFree | PropertiesReliable: 0,
+ PropertiesFree: 1,
+ PropertiesReliable: 2,
+ }
+ slices.SortStableFunc(transports, func(a, b Transport) int {
+ iPriority := priorities[a.Properties()]
+ jPriority := priorities[b.Properties()]
+ return cmp.Compare(iPriority, jPriority)
+ })
+}
+
+func FilterTransports(transport []Transport, props Properties) []Transport {
+ var filtered []Transport
+ for _, tp := range transport {
+ if tp.Properties()&props == props {
+ filtered = append(filtered, tp)
+ }
+ }
+ return filtered
+}
diff --git a/backend/internal/ibd/userinfo.go b/backend/internal/ibd/userinfo.go
new file mode 100644
index 0000000..ed61497
--- /dev/null
+++ b/backend/internal/ibd/userinfo.go
@@ -0,0 +1,156 @@
+package ibd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+)
+
+const (
+ userInfoUrl = "https://myibd.investors.com/services/userprofile.aspx?format=json"
+)
+
+func (c *Client) UserInfo(ctx context.Context, cookie *http.Cookie) (*UserProfile, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, userInfoUrl, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.AddCookie(cookie)
+
+ resp, err := c.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf(
+ "unexpected status code %d: %s",
+ resp.StatusCode,
+ string(content),
+ )
+ }
+
+ up := new(UserProfile)
+ if err = up.UnmarshalJSON(content); err != nil {
+ return nil, err
+ }
+
+ return up, nil
+}
+
+type UserStatus string
+
+const (
+ UserStatusUnknown UserStatus = ""
+ UserStatusVisitor UserStatus = "Visitor"
+ UserStatusSubscriber UserStatus = "Subscriber"
+)
+
+type UserProfile struct {
+ DisplayName string
+ Email string
+ FirstName string
+ LastName string
+ Status UserStatus
+}
+
+func (u *UserProfile) UnmarshalJSON(bytes []byte) error {
+ var resp userProfileResponse
+ if err := json.Unmarshal(bytes, &resp); err != nil {
+ return err
+ }
+
+ u.DisplayName = resp.UserProfile.UserDisplayName
+ u.Email = resp.UserProfile.UserEmailAddress
+ u.FirstName = resp.UserProfile.UserFirstName
+ u.LastName = resp.UserProfile.UserLastName
+
+ switch resp.UserProfile.UserTrialStatus {
+ case "Visitor":
+ u.Status = UserStatusVisitor
+ case "Subscriber":
+ u.Status = UserStatusSubscriber
+ default:
+ slog.Warn("Unknown user status", "status", resp.UserProfile.UserTrialStatus)
+ u.Status = UserStatusUnknown
+ }
+
+ return nil
+}
+
+type userProfileResponse struct {
+ UserProfile userProfile `json:"userProfile"`
+}
+
+type userProfile struct {
+ UserSubType string `json:"userSubType"`
+ UserId string `json:"userId"`
+ UserDisplayName string `json:"userDisplayName"`
+ Countrycode string `json:"countrycode"`
+ IsEUCountry string `json:"isEUCountry"`
+ Log string `json:"log"`
+ AgeGroup string `json:"ageGroup"`
+ Gender string `json:"gender"`
+ InvestingExperience string `json:"investingExperience"`
+ NumberOfTrades string `json:"numberOfTrades"`
+ Occupation string `json:"occupation"`
+ TypeOfInvestments string `json:"typeOfInvestments"`
+ UserEmailAddress string `json:"userEmailAddress"`
+ UserEmailAddressSHA1 string `json:"userEmailAddressSHA1"`
+ UserEmailAddressSHA256 string `json:"userEmailAddressSHA256"`
+ UserEmailAddressMD5 string `json:"userEmailAddressMD5"`
+ UserFirstName string `json:"userFirstName"`
+ UserLastName string `json:"userLastName"`
+ UserZip string `json:"userZip"`
+ UserTrialStatus string `json:"userTrialStatus"`
+ UserProductsOnTrial string `json:"userProductsOnTrial"`
+ UserProductsOwned string `json:"userProductsOwned"`
+ UserAdTrade string `json:"userAdTrade"`
+ UserAdTime string `json:"userAdTime"`
+ UserAdHold string `json:"userAdHold"`
+ UserAdJob string `json:"userAdJob"`
+ UserAdAge string `json:"userAdAge"`
+ UserAdOutSell string `json:"userAdOutSell"`
+ UserVisitCount string `json:"userVisitCount"`
+ RoleLeaderboard bool `json:"role_leaderboard"`
+ RoleOws bool `json:"role_ows"`
+ RoleIbdlive bool `json:"role_ibdlive"`
+ RoleFounderclub bool `json:"role_founderclub"`
+ RoleEibd bool `json:"role_eibd"`
+ RoleIcom bool `json:"role_icom"`
+ RoleEtables bool `json:"role_etables"`
+ RoleTru10 bool `json:"role_tru10"`
+ RoleMarketsurge bool `json:"role_marketsurge"`
+ RoleSwingtrader bool `json:"role_swingtrader"`
+ RoleAdfree bool `json:"role_adfree"`
+ RoleMarketdiem bool `json:"role_marketdiem"`
+ RoleWsjPlus bool `json:"role_wsj_plus"`
+ RoleWsj bool `json:"role_wsj"`
+ RoleBarrons bool `json:"role_barrons"`
+ RoleMarketwatch bool `json:"role_marketwatch"`
+ UserAdRoles string `json:"userAdRoles"`
+ TrialDailyPrintNeg bool `json:"trial_daily_print_neg"`
+ TrialDailyPrintNon bool `json:"trial_daily_print_non"`
+ TrialWeeklyPrintNeg bool `json:"trial_weekly_print_neg"`
+ TrialWeeklyPrintNon bool `json:"trial_weekly_print_non"`
+ TrialDailyComboNeg bool `json:"trial_daily_combo_neg"`
+ TrialDailyComboNon bool `json:"trial_daily_combo_non"`
+ TrialWeeklyComboNeg bool `json:"trial_weekly_combo_neg"`
+ TrialWeeklyComboNon bool `json:"trial_weekly_combo_non"`
+ TrialEibdNeg bool `json:"trial_eibd_neg"`
+ TrialEibdNon bool `json:"trial_eibd_non"`
+ UserVideoPreference string `json:"userVideoPreference"`
+ UserProfessionalStatus bool `json:"userProfessionalStatus"`
+}
diff --git a/backend/internal/keys/gcp.go b/backend/internal/keys/gcp.go
new file mode 100644
index 0000000..9d10fc5
--- /dev/null
+++ b/backend/internal/keys/gcp.go
@@ -0,0 +1,131 @@
+package keys
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "hash/crc32"
+ "sync"
+
+ kms "cloud.google.com/go/kms/apiv1"
+ "cloud.google.com/go/kms/apiv1/kmspb"
+ "google.golang.org/protobuf/types/known/wrapperspb"
+)
+
+type GoogleKMS struct {
+ client *kms.KeyManagementClient
+
+ mx sync.RWMutex
+ keyCache map[string]*rsa.PublicKey
+}
+
+func NewGoogleKMS(ctx context.Context) (*GoogleKMS, error) {
+ client, err := kms.NewKeyManagementClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return &GoogleKMS{
+ client: client,
+ keyCache: make(map[string]*rsa.PublicKey),
+ }, nil
+}
+
+func (g *GoogleKMS) checkCache(keyName string) *rsa.PublicKey {
+ g.mx.RLock()
+ defer g.mx.RUnlock()
+
+ return g.keyCache[keyName]
+}
+
+func (g *GoogleKMS) getPublicKey(ctx context.Context, keyName string) (*rsa.PublicKey, error) {
+ if key := g.checkCache(keyName); key != nil {
+ return key, nil
+ }
+
+ response, err := g.client.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: keyName})
+ if err != nil {
+ return nil, err
+ }
+
+ block, _ := pem.Decode([]byte(response.Pem))
+ if block == nil || block.Type != "PUBLIC KEY" {
+ return nil, errors.New("failed to decode PEM public key")
+ }
+ publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse public key: %w", err)
+ }
+ rsaKey, ok := publicKey.(*rsa.PublicKey)
+ if !ok {
+ return nil, errors.New("public key is not an RSA key")
+ }
+
+ g.mx.Lock()
+ defer g.mx.Unlock()
+ g.keyCache[keyName] = rsaKey
+
+ return rsaKey, nil
+}
+
+func (g *GoogleKMS) Encrypt(ctx context.Context, keyName string, plaintext []byte) ([]byte, error) {
+ publicKey, err := g.getPublicKey(ctx, keyName)
+ if err != nil {
+ return nil, err
+ }
+
+ cipherText, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, plaintext, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encrypt plaintext: %w", err)
+ }
+
+ return cipherText, nil
+}
+
+func (g *GoogleKMS) Decrypt(ctx context.Context, keyName string, ciphertext []byte) ([]byte, error) {
+ req := &kmspb.AsymmetricDecryptRequest{
+ Name: keyName,
+ Ciphertext: ciphertext,
+ CiphertextCrc32C: wrapperspb.Int64(int64(calcCRC32(ciphertext))),
+ }
+
+ result, err := g.client.AsymmetricDecrypt(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decrypt ciphertext: %w", err)
+ }
+
+ if !result.VerifiedCiphertextCrc32C {
+ return nil, errors.New("AsymmetricDecrypt: request corrupted in-transit")
+ }
+ if int64(calcCRC32(result.Plaintext)) != result.PlaintextCrc32C.Value {
+ return nil, fmt.Errorf("AsymmetricDecrypt: response corrupted in-transit")
+ }
+
+ return result.Plaintext, nil
+}
+
+func (g *GoogleKMS) Close() error {
+ return g.client.Close()
+}
+
+func calcCRC32(data []byte) uint32 {
+ t := crc32.MakeTable(crc32.Castagnoli)
+ return crc32.Checksum(data, t)
+}
+
+type GCPKeyName struct {
+ Project string
+ Location string
+ KeyRing string
+ CryptoKey string
+ CryptoKeyVersion string
+}
+
+func (k GCPKeyName) String() string {
+ return fmt.Sprintf("projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s/cryptoKeyVersions/%s", k.Project, k.Location, k.KeyRing, k.CryptoKey, k.CryptoKeyVersion)
+}
diff --git a/backend/internal/keys/keys.go b/backend/internal/keys/keys.go
new file mode 100644
index 0000000..ac73173
--- /dev/null
+++ b/backend/internal/keys/keys.go
@@ -0,0 +1,150 @@
+package keys
+
+import (
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "fmt"
+ "io"
+)
+
+var CSRNG = rand.Reader
+
+//go:generate mockgen -destination mock_keys_test.go -package keys_test -typed . KeyManagementService
+type KeyManagementService interface {
+ io.Closer
+
+ // Encrypt encrypts the given plaintext using the key with the given key name.
+ Encrypt(ctx context.Context, keyName string, plaintext []byte) ([]byte, error)
+
+ // Decrypt decrypts the given ciphertext using the key with the given key name.
+ Decrypt(ctx context.Context, keyName string, ciphertext []byte) ([]byte, error)
+}
+
+// Encrypt encrypts the given plaintext using a hybrid encryption scheme.
+//
+// It first generates a random AES 256-bit key and encrypts the plaintext with it.
+// Then, it encrypts the AES key using the KMS.
+//
+// It returns the ciphertext, the encrypted AES key, and any errors that occurred.
+func Encrypt(
+ ctx context.Context,
+ kms KeyManagementService,
+ keyName string,
+ plaintext []byte,
+) (ciphertext []byte, encryptedKey []byte, err error) {
+ // Generate a random AES key
+ aesKey := make([]byte, 32)
+ if _, err = io.ReadFull(CSRNG, aesKey); err != nil {
+ return nil, nil, fmt.Errorf("unable to generate AES key: %w", err)
+ }
+
+ // Encrypt the plaintext using the AES key
+ ciphertext, err = encrypt(aesKey, plaintext)
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to encrypt plaintext: %w", err)
+ }
+
+ // Encrypt the AES key using the KMS
+ encryptedKey, err = kms.Encrypt(ctx, keyName, aesKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to encrypt AES key: %w", err)
+ }
+
+ return ciphertext, encryptedKey, nil
+}
+
+// EncryptWithKey encrypts the given plaintext using a hybrid encryption scheme.
+//
+// This works similarly to Encrypt, but instead of generating a new AES key, it uses a given already encrypted AES key.
+func EncryptWithKey(
+ ctx context.Context,
+ kms KeyManagementService,
+ keyName string,
+ encryptedKey []byte,
+ plaintext []byte,
+) ([]byte, error) {
+ // Decrypt the AES key
+ aesKey, err := kms.Decrypt(ctx, keyName, encryptedKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to decrypt AES key: %w", err)
+ }
+
+ // Encrypt the plaintext using the AES key
+ ciphertext, err := encrypt(aesKey, plaintext)
+ if err != nil {
+ return nil, fmt.Errorf("unable to encrypt plaintext: %w", err)
+ }
+
+ return ciphertext, nil
+}
+
+func encrypt(aesKey []byte, plaintext []byte) ([]byte, error) {
+ // Create an AES cipher
+ blockCipher, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create AES cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(blockCipher)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create GCM: %w", err)
+ }
+
+ // Generate a random nonce
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err = io.ReadFull(CSRNG, nonce); err != nil {
+ return nil, fmt.Errorf("unable to generate nonce: %w", err)
+ }
+
+ // Encrypt the plaintext
+ ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
+ return ciphertext, nil
+}
+
+// Decrypt decrypts the given ciphertext using a hybrid encryption scheme.
+//
+// It first decrypts the AES key using the KMS.
+// Then, it decrypts the ciphertext using the decrypted AES key.
+//
+// It returns the plaintext and any errors that occurred.
+func Decrypt(
+ ctx context.Context,
+ kms KeyManagementService,
+ keyName string,
+ ciphertext []byte,
+ encryptedKey []byte,
+) ([]byte, error) {
+ // Decrypt the AES key
+ aesKey, err := kms.Decrypt(ctx, keyName, encryptedKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to decrypt AES key: %w", err)
+ }
+
+ // Create an AES cipher
+ blockCipher, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create AES cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(blockCipher)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create GCM: %w", err)
+ }
+
+ // Extract the nonce from the ciphertext
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return nil, fmt.Errorf("ciphertext is too short")
+ }
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+
+ // Decrypt the ciphertext
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return nil, fmt.Errorf("unable to decrypt ciphertext: %w", err)
+ }
+
+ return plaintext, nil
+}
diff --git a/backend/internal/keys/keys_test.go b/backend/internal/keys/keys_test.go
new file mode 100644
index 0000000..34aa493
--- /dev/null
+++ b/backend/internal/keys/keys_test.go
@@ -0,0 +1,64 @@
+package keys_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "testing"
+
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+func TestEncrypt(t *testing.T) {
+ ctrl := gomock.NewController(t)
+
+ // Replace RNG with a deterministic RNG
+ aesKey := []byte("0123456789abcdef0123456789abcdef")
+ nonce := []byte("0123456789ab")
+ keys.CSRNG = bytes.NewReader(append(aesKey, nonce...))
+
+ // Create a mock KMS
+ kms := NewMockKeyManagementService(ctrl)
+ keyName := "keyName"
+
+ ctx := context.Background()
+ plaintext := []byte("plaintext")
+
+ kms.EXPECT().
+ Encrypt(ctx, keyName, aesKey).
+ Return([]byte("encryptedKey"), nil)
+
+ ciphertext, encryptedKey, err := keys.Encrypt(ctx, kms, keyName, plaintext)
+ require.NoError(t, err)
+
+ encrypted, err := hex.DecodeString("e9c586532dbefd63812293e1c4baf71edb7042a294c49c2020")
+ require.NoError(t, err)
+ assert.Equal(t, append(nonce, encrypted...), ciphertext)
+ assert.Equal(t, []byte("encryptedKey"), encryptedKey)
+}
+
+func TestDecrypt(t *testing.T) {
+ ctrl := gomock.NewController(t)
+
+ kms := NewMockKeyManagementService(ctrl)
+ keyName := "keyName"
+
+ ctx := context.Background()
+ encryptedKey := []byte("encryptedKey")
+ ciphertext, err := hex.DecodeString("e9c586532dbefd63812293e1c4baf71edb7042a294c49c2020")
+ require.NoError(t, err)
+ ciphertext = append([]byte("0123456789ab"), ciphertext...)
+
+ aesKey := []byte("0123456789abcdef0123456789abcdef")
+ kms.EXPECT().
+ Decrypt(ctx, keyName, encryptedKey).
+ Return(aesKey, nil)
+
+ plaintext, err := keys.Decrypt(ctx, kms, keyName, ciphertext, encryptedKey)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("plaintext"), plaintext)
+}
diff --git a/backend/internal/keys/mock_keys_test.go b/backend/internal/keys/mock_keys_test.go
new file mode 100644
index 0000000..19316e0
--- /dev/null
+++ b/backend/internal/keys/mock_keys_test.go
@@ -0,0 +1,156 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/ansg191/ibd-trader-backend/internal/keys (interfaces: KeyManagementService)
+//
+// Generated by this command:
+//
+// mockgen -destination mock_keys_test.go -package keys_test -typed . KeyManagementService
+//
+
+// Package keys_test is a generated GoMock package.
+package keys_test
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockKeyManagementService is a mock of KeyManagementService interface.
+type MockKeyManagementService struct {
+ ctrl *gomock.Controller
+ recorder *MockKeyManagementServiceMockRecorder
+}
+
+// MockKeyManagementServiceMockRecorder is the mock recorder for MockKeyManagementService.
+type MockKeyManagementServiceMockRecorder struct {
+ mock *MockKeyManagementService
+}
+
+// NewMockKeyManagementService creates a new mock instance.
+func NewMockKeyManagementService(ctrl *gomock.Controller) *MockKeyManagementService {
+ mock := &MockKeyManagementService{ctrl: ctrl}
+ mock.recorder = &MockKeyManagementServiceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockKeyManagementService) EXPECT() *MockKeyManagementServiceMockRecorder {
+ return m.recorder
+}
+
+// Close mocks base method.
+func (m *MockKeyManagementService) Close() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Close")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockKeyManagementServiceMockRecorder) Close() *MockKeyManagementServiceCloseCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockKeyManagementService)(nil).Close))
+ return &MockKeyManagementServiceCloseCall{Call: call}
+}
+
+// MockKeyManagementServiceCloseCall wrap *gomock.Call
+type MockKeyManagementServiceCloseCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockKeyManagementServiceCloseCall) Return(arg0 error) *MockKeyManagementServiceCloseCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockKeyManagementServiceCloseCall) Do(f func() error) *MockKeyManagementServiceCloseCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockKeyManagementServiceCloseCall) DoAndReturn(f func() error) *MockKeyManagementServiceCloseCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// Decrypt mocks base method.
+func (m *MockKeyManagementService) Decrypt(arg0 context.Context, arg1 string, arg2 []byte) ([]byte, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Decrypt", arg0, arg1, arg2)
+ ret0, _ := ret[0].([]byte)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Decrypt indicates an expected call of Decrypt.
+func (mr *MockKeyManagementServiceMockRecorder) Decrypt(arg0, arg1, arg2 any) *MockKeyManagementServiceDecryptCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Decrypt", reflect.TypeOf((*MockKeyManagementService)(nil).Decrypt), arg0, arg1, arg2)
+ return &MockKeyManagementServiceDecryptCall{Call: call}
+}
+
+// MockKeyManagementServiceDecryptCall wrap *gomock.Call
+type MockKeyManagementServiceDecryptCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockKeyManagementServiceDecryptCall) Return(arg0 []byte, arg1 error) *MockKeyManagementServiceDecryptCall {
+ c.Call = c.Call.Return(arg0, arg1)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockKeyManagementServiceDecryptCall) Do(f func(context.Context, string, []byte) ([]byte, error)) *MockKeyManagementServiceDecryptCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockKeyManagementServiceDecryptCall) DoAndReturn(f func(context.Context, string, []byte) ([]byte, error)) *MockKeyManagementServiceDecryptCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// Encrypt mocks base method.
+func (m *MockKeyManagementService) Encrypt(arg0 context.Context, arg1 string, arg2 []byte) ([]byte, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Encrypt", arg0, arg1, arg2)
+ ret0, _ := ret[0].([]byte)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Encrypt indicates an expected call of Encrypt.
+func (mr *MockKeyManagementServiceMockRecorder) Encrypt(arg0, arg1, arg2 any) *MockKeyManagementServiceEncryptCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encrypt", reflect.TypeOf((*MockKeyManagementService)(nil).Encrypt), arg0, arg1, arg2)
+ return &MockKeyManagementServiceEncryptCall{Call: call}
+}
+
+// MockKeyManagementServiceEncryptCall wrap *gomock.Call
+type MockKeyManagementServiceEncryptCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockKeyManagementServiceEncryptCall) Return(arg0 []byte, arg1 error) *MockKeyManagementServiceEncryptCall {
+ c.Call = c.Call.Return(arg0, arg1)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockKeyManagementServiceEncryptCall) Do(f func(context.Context, string, []byte) ([]byte, error)) *MockKeyManagementServiceEncryptCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockKeyManagementServiceEncryptCall) DoAndReturn(f func(context.Context, string, []byte) ([]byte, error)) *MockKeyManagementServiceEncryptCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
diff --git a/backend/internal/leader/election/election.go b/backend/internal/leader/election/election.go
new file mode 100644
index 0000000..6f83298
--- /dev/null
+++ b/backend/internal/leader/election/election.go
@@ -0,0 +1,128 @@
+package election
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/bsm/redislock"
+)
+
+var defaultLeaderElectionOptions = leaderElectionOptions{
+ lockKey: "ibd-leader-election",
+ lockTTL: 10 * time.Second,
+}
+
+func RunOrDie(
+ ctx context.Context,
+ client redislock.RedisClient,
+ onLeader func(context.Context),
+ opts ...LeaderElectionOption,
+) {
+ o := defaultLeaderElectionOptions
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ locker := redislock.New(client)
+
+ // Election loop
+ for {
+ lock, err := locker.Obtain(ctx, o.lockKey, o.lockTTL, nil)
+ if errors.Is(err, redislock.ErrNotObtained) {
+ // Another instance is the leader
+ } else if err != nil {
+ slog.ErrorContext(ctx, "failed to obtain lock", "error", err)
+ } else {
+ // We are the leader
+ slog.DebugContext(ctx, "elected leader")
+ runLeader(ctx, lock, onLeader, o)
+ }
+
+ // Sleep for a bit before trying again
+ timer := time.NewTimer(o.lockTTL / 5)
+ select {
+ case <-ctx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return
+ case <-timer.C:
+ }
+ }
+}
+
+func runLeader(
+ ctx context.Context,
+ lock *redislock.Lock,
+ onLeader func(context.Context),
+ o leaderElectionOptions,
+) {
+ // A context that is canceled when the leader loses the lock
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Release the lock when done
+ defer func() {
+ // Create new context without cancel if the original context is already canceled
+ relCtx := ctx
+ if ctx.Err() != nil {
+ relCtx = context.WithoutCancel(ctx)
+ }
+
+ // Add a timeout to the release context
+ relCtx, cancel := context.WithTimeout(relCtx, o.lockTTL)
+ defer cancel()
+
+ if err := lock.Release(relCtx); err != nil {
+ slog.Error("failed to release lock", "error", err)
+ }
+ }()
+
+ // Run the leader code
+ go func(ctx context.Context) {
+ onLeader(ctx)
+
+ // If the leader code returns, cancel the context to release the lock
+ cancel()
+ }(ctx)
+
+ // Refresh the lock periodically
+ ticker := time.NewTicker(o.lockTTL / 10)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ err := lock.Refresh(ctx, o.lockTTL, nil)
+ if errors.Is(err, redislock.ErrNotObtained) || errors.Is(err, redislock.ErrLockNotHeld) {
+ slog.ErrorContext(ctx, "leadership lost", "error", err)
+ return
+ } else if err != nil {
+ slog.ErrorContext(ctx, "failed to refresh lock", "error", err)
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+type leaderElectionOptions struct {
+ lockKey string
+ lockTTL time.Duration
+}
+
+type LeaderElectionOption func(*leaderElectionOptions)
+
+func WithLockKey(key string) LeaderElectionOption {
+ return func(o *leaderElectionOptions) {
+ o.lockKey = key
+ }
+}
+
+func WithLockTTL(ttl time.Duration) LeaderElectionOption {
+ return func(o *leaderElectionOptions) {
+ o.lockTTL = ttl
+ }
+}
diff --git a/backend/internal/leader/manager/ibd/auth/auth.go b/backend/internal/leader/manager/ibd/auth/auth.go
new file mode 100644
index 0000000..9b5502d
--- /dev/null
+++ b/backend/internal/leader/manager/ibd/auth/auth.go
@@ -0,0 +1,111 @@
+package auth
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/robfig/cron/v3"
+)
+
+const (
+ Queue = "auth-queue"
+ QueueEncoding = taskqueue.EncodingJSON
+)
+
+// Manager is responsible for sending authentication tasks to the workers.
+type Manager struct {
+ queue taskqueue.TaskQueue[TaskInfo]
+ db database.Executor
+ schedule cron.Schedule
+}
+
+func New(
+ ctx context.Context,
+ db database.Executor,
+ rClient *redis.Client,
+ schedule cron.Schedule,
+) (*Manager, error) {
+ queue, err := taskqueue.New(
+ ctx,
+ rClient,
+ Queue,
+ "auth-manager",
+ taskqueue.WithEncoding[TaskInfo](QueueEncoding),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &Manager{
+ queue: queue,
+ db: db,
+ schedule: schedule,
+ }, nil
+}
+
+func (m *Manager) Run(ctx context.Context) {
+ for {
+ now := time.Now()
+ // Find the next time
+ nextTime := m.schedule.Next(now)
+ if nextTime.IsZero() {
+ // Sleep until the next day
+ time.Sleep(time.Until(now.AddDate(0, 0, 1)))
+ continue
+ }
+
+ timer := time.NewTimer(nextTime.Sub(now))
+ slog.DebugContext(ctx, "waiting for next Auth scrape", "next_exec", nextTime)
+
+ select {
+ case <-timer.C:
+ nextExec := m.schedule.Next(nextTime)
+ m.scrapeCookies(ctx, nextExec)
+ case <-ctx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return
+ }
+ }
+}
+
+// scrapeCookies scrapes the cookies for every user from the IBD website.
+//
+// This iterates through all users with IBD credentials and checks whether their cookies are still valid.
+// If the cookies are invalid or missing, it re-authenticates the user and updates the cookies in the database.
+func (m *Manager) scrapeCookies(ctx context.Context, deadline time.Time) {
+ ctx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+
+ // Get all users with IBD credentials
+ users, err := database.ListUsers(ctx, m.db, true)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to get users", "error", err)
+ return
+ }
+
+ // Create a new task for each user
+ for _, user := range users {
+ task := TaskInfo{
+ UserSubject: user.Subject,
+ }
+
+ // Enqueue the task
+ _, err := m.queue.Enqueue(ctx, task)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to enqueue task", "error", err)
+ }
+ }
+
+ slog.InfoContext(ctx, "enqueued tasks for all users")
+}
+
+type TaskInfo struct {
+ UserSubject string `json:"user_subject"`
+}
diff --git a/backend/internal/leader/manager/ibd/ibd.go b/backend/internal/leader/manager/ibd/ibd.go
new file mode 100644
index 0000000..e2d4fc0
--- /dev/null
+++ b/backend/internal/leader/manager/ibd/ibd.go
@@ -0,0 +1,8 @@
+package ibd
+
+type Schedules struct {
+ // Auth schedule
+ Auth string
+ // IBD50 schedule
+ IBD50 string
+}
diff --git a/backend/internal/leader/manager/ibd/scrape/scrape.go b/backend/internal/leader/manager/ibd/scrape/scrape.go
new file mode 100644
index 0000000..870ce5e
--- /dev/null
+++ b/backend/internal/leader/manager/ibd/scrape/scrape.go
@@ -0,0 +1,140 @@
+package scrape
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/robfig/cron/v3"
+)
+
+const (
+ Queue = "scrape-queue"
+ QueueEncoding = taskqueue.EncodingJSON
+ Channel = "scrape-channel"
+)
+
+// Manager is responsible for sending scraping tasks to the workers.
+type Manager struct {
+ client *ibd.Client
+ db database.Executor
+ queue taskqueue.TaskQueue[TaskInfo]
+ schedule cron.Schedule
+ pubsub *redis.PubSub
+}
+
+func New(
+ ctx context.Context,
+ client *ibd.Client,
+ db database.Executor,
+ redis *redis.Client,
+ schedule cron.Schedule,
+) (*Manager, error) {
+ queue, err := taskqueue.New(
+ ctx,
+ redis,
+ Queue,
+ "ibd-manager",
+ taskqueue.WithEncoding[TaskInfo](QueueEncoding),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &Manager{
+ client: client,
+ db: db,
+ queue: queue,
+ schedule: schedule,
+ pubsub: redis.Subscribe(ctx, Channel),
+ }, nil
+}
+
+func (m *Manager) Close() error {
+ return m.pubsub.Close()
+}
+
+func (m *Manager) Run(ctx context.Context) {
+ ch := m.pubsub.Channel()
+ for {
+ now := time.Now()
+ // Find the next time
+ nextTime := m.schedule.Next(now)
+ if nextTime.IsZero() {
+ // Sleep until the next day
+ time.Sleep(time.Until(now.AddDate(0, 0, 1)))
+ continue
+ }
+
+ timer := time.NewTimer(nextTime.Sub(now))
+ slog.DebugContext(ctx, "waiting for next IBD50 scrape", "next_exec", nextTime)
+
+ select {
+ case <-timer.C:
+ nextExec := m.schedule.Next(nextTime)
+ m.scrapeIBD50(ctx, nextExec)
+ case <-ch:
+ nextExec := m.schedule.Next(time.Now())
+ m.scrapeIBD50(ctx, nextExec)
+ case <-ctx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return
+ }
+ }
+}
+
+func (m *Manager) scrapeIBD50(ctx context.Context, deadline time.Time) {
+ ctx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+
+ stocks, err := m.client.GetIBD50(ctx)
+ if err != nil {
+ if errors.Is(err, ibd.ErrNoAvailableCookies) {
+ slog.WarnContext(ctx, "no available cookies", "error", err)
+ return
+ }
+ slog.ErrorContext(ctx, "failed to get IBD50", "error", err)
+ return
+ }
+
+ for _, stock := range stocks {
+ // Add stock to DB
+ err = database.AddStock(ctx, m.db, database.Stock{
+ Symbol: stock.Symbol,
+ Name: stock.Name,
+ IBDUrl: stock.QuoteURL.String(),
+ })
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to add stock", "error", err)
+ continue
+ }
+
+ // Add ranking to Db
+ err = database.AddRanking(ctx, m.db, stock.Symbol, int(stock.Rank), 0)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to add ranking", "error", err)
+ continue
+ }
+
+ // Add scrape task to queue
+ task := TaskInfo{Symbol: stock.Symbol}
+ taskID, err := m.queue.Enqueue(ctx, task)
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to enqueue task", "error", err)
+ }
+
+ slog.DebugContext(ctx, "enqueued scrape task", "task_id", taskID, "symbol", stock.Symbol)
+ }
+}
+
+type TaskInfo struct {
+ Symbol string `json:"symbol"`
+}
diff --git a/backend/internal/leader/manager/manager.go b/backend/internal/leader/manager/manager.go
new file mode 100644
index 0000000..61e27e0
--- /dev/null
+++ b/backend/internal/leader/manager/manager.go
@@ -0,0 +1,90 @@
+package manager
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sync"
+
+ "github.com/ansg191/ibd-trader-backend/internal/config"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/auth"
+ ibd2 "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/scrape"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/robfig/cron/v3"
+)
+
+type Manager struct {
+ db database.Database
+ Monitor *WorkerMonitor
+ Scraper *ibd2.Manager
+ Auth *auth.Manager
+}
+
+func New(
+ ctx context.Context,
+ cfg *config.Config,
+ client *redis.Client,
+ db database.Database,
+ ibd *ibd.Client,
+) (*Manager, error) {
+ scraperSchedule, err := cron.ParseStandard(cfg.IBD.Schedules.IBD50)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse IBD50 schedule: %w", err)
+ }
+ scraper, err := ibd2.New(ctx, ibd, db, client, scraperSchedule)
+ if err != nil {
+ return nil, err
+ }
+
+ authSchedule, err := cron.ParseStandard(cfg.IBD.Schedules.Auth)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse Auth schedule: %w", err)
+ }
+ authManager, err := auth.New(ctx, db, client, authSchedule)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Manager{
+ db: db,
+ Monitor: NewWorkerMonitor(client),
+ Scraper: scraper,
+ Auth: authManager,
+ }, nil
+}
+
+func (m *Manager) Run(ctx context.Context) error {
+ if err := m.db.Migrate(ctx); err != nil {
+ slog.ErrorContext(ctx, "Unable to migrate database", "error", err)
+ return err
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(4)
+
+ go func() {
+ defer wg.Done()
+ m.db.Maintenance(ctx)
+ }()
+
+ go func() {
+ defer wg.Done()
+ m.Monitor.Start(ctx)
+ }()
+
+ go func() {
+ defer wg.Done()
+ m.Scraper.Run(ctx)
+ }()
+
+ go func() {
+ defer wg.Done()
+ m.Auth.Run(ctx)
+ }()
+
+ wg.Wait()
+ return ctx.Err()
+}
diff --git a/backend/internal/leader/manager/monitor.go b/backend/internal/leader/manager/monitor.go
new file mode 100644
index 0000000..3b2e3ec
--- /dev/null
+++ b/backend/internal/leader/manager/monitor.go
@@ -0,0 +1,164 @@
+package manager
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/buraksezer/consistent"
+ "github.com/cespare/xxhash/v2"
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ MonitorInterval = 5 * time.Second
+ ActiveWorkersSet = "active-workers"
+)
+
+// WorkerMonitor is a struct that monitors workers and their heartbeats over redis.
+type WorkerMonitor struct {
+ client *redis.Client
+
+ // ring is a consistent hash ring that distributes partitions over detected workers.
+ ring *consistent.Consistent
+ // layoutChangeCh is a channel that others can listen to for layout changes to the ring.
+ layoutChangeCh chan struct{}
+}
+
+// NewWorkerMonitor creates a new WorkerMonitor.
+func NewWorkerMonitor(client *redis.Client) *WorkerMonitor {
+ var members []consistent.Member
+ return &WorkerMonitor{
+ client: client,
+ ring: consistent.New(members, consistent.Config{
+ Hasher: new(hasher),
+ PartitionCount: consistent.DefaultPartitionCount,
+ ReplicationFactor: consistent.DefaultReplicationFactor,
+ Load: consistent.DefaultLoad,
+ }),
+ layoutChangeCh: make(chan struct{}),
+ }
+}
+
+func (m *WorkerMonitor) Close() error {
+ close(m.layoutChangeCh)
+ return nil
+}
+
+func (m *WorkerMonitor) Changes() <-chan struct{} {
+ return m.layoutChangeCh
+}
+
+func (m *WorkerMonitor) Start(ctx context.Context) {
+ m.monitorWorkers(ctx)
+ ticker := time.NewTicker(MonitorInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ m.monitorWorkers(ctx)
+ }
+ }
+}
+
+func (m *WorkerMonitor) monitorWorkers(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, MonitorInterval)
+ defer cancel()
+
+ // Get all active workers.
+ workers, err := m.client.SMembers(ctx, ActiveWorkersSet).Result()
+ if err != nil {
+ slog.ErrorContext(ctx, "Unable to get active workers", "error", err)
+ return
+ }
+
+ // Get existing workers in the ring.
+ existingWorkers := m.ring.GetMembers()
+ ewMap := make(map[string]bool)
+ for _, worker := range existingWorkers {
+ ewMap[worker.String()] = false
+ }
+
+ // Check workers' heartbeats.
+ for _, worker := range workers {
+ exists, err := m.client.Exists(ctx, WorkerHeartbeatKey(worker)).Result()
+ if err != nil {
+ slog.ErrorContext(ctx, "Unable to check worker heartbeat", "worker", worker, "error", err)
+ continue
+ }
+
+ if exists == 0 {
+ slog.WarnContext(ctx, "Worker heartbeat not found", "worker", worker)
+
+ // Remove worker from active workers set.
+ if err = m.client.SRem(ctx, ActiveWorkersSet, worker).Err(); err != nil {
+ slog.ErrorContext(ctx, "Unable to remove worker from active workers set", "worker", worker, "error", err)
+ }
+
+ // Remove worker from the ring.
+ m.removeWorker(worker)
+ } else {
+ // Add worker to the ring if it doesn't exist.
+ if _, ok := ewMap[worker]; !ok {
+ slog.InfoContext(ctx, "New worker detected", "worker", worker)
+ m.addWorker(worker)
+ } else {
+ ewMap[worker] = true
+ }
+ }
+ }
+
+ // Check for workers that are not active anymore.
+ for worker, exists := range ewMap {
+ if !exists {
+ slog.WarnContext(ctx, "Worker is not active anymore", "worker", worker)
+ m.removeWorker(worker)
+ }
+ }
+}
+
+func (m *WorkerMonitor) addWorker(worker string) {
+ m.ring.Add(member{hostname: worker})
+
+ // Notify others about the layout change.
+ select {
+ case m.layoutChangeCh <- struct{}{}:
+ // Notify others.
+ default:
+ // No one is listening.
+ }
+}
+
+func (m *WorkerMonitor) removeWorker(worker string) {
+ m.ring.Remove(worker)
+
+ // Notify others about the layout change.
+ select {
+ case m.layoutChangeCh <- struct{}{}:
+ // Notify others.
+ default:
+ // No one is listening.
+ }
+}
+
+func WorkerHeartbeatKey(hostname string) string {
+ return fmt.Sprintf("worker:%s:heartbeat", hostname)
+}
+
+type hasher struct{}
+
+func (h *hasher) Sum64(data []byte) uint64 {
+ return xxhash.Sum64(data)
+}
+
+type member struct {
+ hostname string
+}
+
+func (m member) String() string {
+ return m.hostname
+}
diff --git a/backend/internal/redis/taskqueue/options.go b/backend/internal/redis/taskqueue/options.go
new file mode 100644
index 0000000..2d5a23f
--- /dev/null
+++ b/backend/internal/redis/taskqueue/options.go
@@ -0,0 +1,9 @@
+package taskqueue
+
+type Option[T any] func(*taskQueue[T])
+
+func WithEncoding[T any](encoding Encoding) Option[T] {
+ return func(o *taskQueue[T]) {
+ o.encoding = encoding
+ }
+}
diff --git a/backend/internal/redis/taskqueue/queue.go b/backend/internal/redis/taskqueue/queue.go
new file mode 100644
index 0000000..a4b799e
--- /dev/null
+++ b/backend/internal/redis/taskqueue/queue.go
@@ -0,0 +1,545 @@
+package taskqueue
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/gob"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+)
+
+type Encoding uint8
+
+const (
+ EncodingJSON Encoding = iota
+ EncodingGob
+
+ ResultKey = "result"
+ ErrorKey = "error"
+ NextAttemptKey = "next_attempt"
+)
+
+var MaxAttempts = 3
+var ErrTaskNotFound = errors.New("task not found")
+
+type TaskQueue[T any] interface {
+ // Enqueue adds a task to the queue.
+ // Returns the generated task ID.
+ Enqueue(ctx context.Context, data T) (TaskInfo[T], error)
+
+ // Dequeue removes a task from the queue and returns it.
+ // The task data is placed into dataOut.
+ //
+ // Dequeue blocks until a task is available, timeout, or the context is canceled.
+ // The returned task is placed in a pending state for lockTimeout duration.
+ // The task must be completed with Complete or extended with Extend before the lock expires.
+ // If the lock expires, the task is returned to the queue, where it may be picked up by another worker.
+ Dequeue(
+ ctx context.Context,
+ lockTimeout,
+ timeout time.Duration,
+ ) (*TaskInfo[T], error)
+
+ // Extend extends the lock on a task.
+ Extend(ctx context.Context, taskID TaskID) error
+
+ // Complete marks a task as complete. Optionally, an error can be provided to store additional information.
+ Complete(ctx context.Context, taskID TaskID, result string) error
+
+ // Data returns the info of a task.
+ Data(ctx context.Context, taskID TaskID) (TaskInfo[T], error)
+
+ // Return returns a task to the queue and returns the new task ID.
+ // Increments the attempt counter.
+ // Tasks with too many attempts (MaxAttempts) are considered failed and aren't returned to the queue.
+ Return(ctx context.Context, taskID TaskID, err error) (TaskID, error)
+
+ // List returns a list of task IDs in the queue.
+ // The list is ordered by the time the task was added to the queue. The most recent task is first.
+ // The count parameter limits the number of tasks returned.
+ // The start and end parameters limit the range of tasks returned.
+ // End is exclusive.
+ // Start must be before end.
+ List(ctx context.Context, start, end TaskID, count int64) ([]TaskInfo[T], error)
+}
+
+type TaskInfo[T any] struct {
+ // ID is the unique identifier of the task. Generated by redis.
+ ID TaskID
+ // Data is the task data. Stored in stream.
+ Data T
+ // Attempts is the number of times the task has been attempted. Stored in stream.
+ Attempts uint8
+ // Result is the result of the task. Stored in a hash.
+ Result isTaskResult
+}
+
+type isTaskResult interface {
+ isTaskResult()
+}
+
+type TaskResultSuccess struct {
+ Result string
+}
+
+type TaskResultError struct {
+ Error string
+ NextAttempt TaskID
+}
+
+func (*TaskResultSuccess) isTaskResult() {}
+func (*TaskResultError) isTaskResult() {}
+
+type TaskID struct {
+ timestamp time.Time
+ sequence uint64
+}
+
+func NewTaskID(timestamp time.Time, sequence uint64) TaskID {
+ return TaskID{timestamp, sequence}
+}
+
+func ParseTaskID(s string) (TaskID, error) {
+ tPart, sPart, ok := strings.Cut(s, "-")
+ if !ok {
+ return TaskID{}, errors.New("invalid task ID")
+ }
+
+ timestamp, err := strconv.ParseInt(tPart, 10, 64)
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ sequence, err := strconv.ParseUint(sPart, 10, 64)
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ return NewTaskID(time.UnixMilli(timestamp), sequence), nil
+}
+
+func (t TaskID) Timestamp() time.Time {
+ return t.timestamp
+}
+
+func (t TaskID) String() string {
+ tPart := strconv.FormatInt(t.timestamp.UnixMilli(), 10)
+ sPart := strconv.FormatUint(t.sequence, 10)
+ return tPart + "-" + sPart
+}
+
+type taskQueue[T any] struct {
+ rdb *redis.Client
+ encoding Encoding
+
+ streamKey string
+ groupName string
+
+ workerName string
+}
+
+func New[T any](ctx context.Context, rdb *redis.Client, name string, workerName string, opts ...Option[T]) (TaskQueue[T], error) {
+ tq := &taskQueue[T]{
+ rdb: rdb,
+ encoding: EncodingJSON,
+ streamKey: "taskqueue:" + name,
+ groupName: "default",
+ workerName: workerName,
+ }
+
+ for _, opt := range opts {
+ opt(tq)
+ }
+
+ // Create the stream if it doesn't exist
+ err := rdb.XGroupCreateMkStream(ctx, tq.streamKey, tq.groupName, "0").Err()
+ if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" {
+ return nil, err
+ }
+
+ return tq, nil
+}
+
+func (q *taskQueue[T]) Enqueue(ctx context.Context, data T) (TaskInfo[T], error) {
+ task := TaskInfo[T]{
+ Data: data,
+ Attempts: 0,
+ }
+
+ values, err := encode[T](task, q.encoding)
+ if err != nil {
+ return TaskInfo[T]{}, err
+ }
+
+ taskID, err := q.rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: q.streamKey,
+ Values: values,
+ }).Result()
+ if err != nil {
+ return TaskInfo[T]{}, err
+ }
+
+ id, err := ParseTaskID(taskID)
+ if err != nil {
+ return TaskInfo[T]{}, err
+ }
+ task.ID = id
+ return task, nil
+}
+
+func (q *taskQueue[T]) Dequeue(ctx context.Context, lockTimeout, timeout time.Duration) (*TaskInfo[T], error) {
+ // Try to recover a task
+ task, err := q.recover(ctx, lockTimeout)
+ if err != nil {
+ return nil, err
+ }
+ if task != nil {
+ return task, nil
+ }
+
+ // Check for new tasks
+ ids, err := q.rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: q.groupName,
+ Consumer: q.workerName,
+ Streams: []string{q.streamKey, ">"},
+ Count: 1,
+ Block: timeout,
+ }).Result()
+ if err != nil && !errors.Is(err, redis.Nil) {
+ return nil, err
+ }
+
+ if len(ids) == 0 || len(ids[0].Messages) == 0 || errors.Is(err, redis.Nil) {
+ return nil, nil
+ }
+
+ msg := ids[0].Messages[0]
+ task = new(TaskInfo[T])
+ *task, err = decode[T](&msg, q.encoding)
+ if err != nil {
+ return nil, err
+ }
+ return task, nil
+}
+
+func (q *taskQueue[T]) Extend(ctx context.Context, taskID TaskID) error {
+ _, err := q.rdb.XClaim(ctx, &redis.XClaimArgs{
+ Stream: q.streamKey,
+ Group: q.groupName,
+ Consumer: q.workerName,
+ MinIdle: 0,
+ Messages: []string{taskID.String()},
+ }).Result()
+ if err != nil && !errors.Is(err, redis.Nil) {
+ return err
+ }
+ return nil
+}
+
+func (q *taskQueue[T]) Data(ctx context.Context, taskID TaskID) (TaskInfo[T], error) {
+ msg, err := q.rdb.XRange(ctx, q.streamKey, taskID.String(), taskID.String()).Result()
+ if err != nil {
+ return TaskInfo[T]{}, err
+ }
+
+ if len(msg) == 0 {
+ return TaskInfo[T]{}, ErrTaskNotFound
+ }
+
+ t, err := decode[T](&msg[0], q.encoding)
+ if err != nil {
+ return TaskInfo[T]{}, err
+ }
+
+ t.Result, err = q.getResult(ctx, taskID)
+ if err != nil {
+ return TaskInfo[T]{}, nil
+ }
+ return t, nil
+}
+
+func (q *taskQueue[T]) Complete(ctx context.Context, taskID TaskID, result string) error {
+ return q.ack(ctx, taskID, false, result)
+}
+
+var retScript = redis.NewScript(`
+local stream_key = KEYS[1]
+local hash_key = KEYS[2]
+
+-- Re-add the task to the stream
+local task_id = redis.call('XADD', stream_key, '*', unpack(ARGV))
+
+-- Update the hash key to point to the new task
+redis.call('HSET', hash_key, 'next_attempt', task_id)
+
+return task_id
+`)
+
+func (q *taskQueue[T]) Return(ctx context.Context, taskID TaskID, err1 error) (TaskID, error) {
+ msgs, err := q.rdb.XRange(ctx, q.streamKey, taskID.String(), taskID.String()).Result()
+ if err != nil {
+ return TaskID{}, err
+ }
+ if len(msgs) == 0 {
+ return TaskID{}, ErrTaskNotFound
+ }
+
+ var ackMsg string
+ if err1 != nil {
+ ackMsg = err1.Error()
+ }
+
+ // Ack the task
+ err = q.ack(ctx, taskID, true, ackMsg)
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ msg := msgs[0]
+ task, err := decode[T](&msg, q.encoding)
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ task.Attempts++
+ if int(task.Attempts) >= MaxAttempts {
+ // Task has failed
+ slog.ErrorContext(ctx, "task failed completely",
+ "taskID", taskID,
+ "data", task.Data,
+ "attempts", task.Attempts,
+ "maxAttempts", MaxAttempts,
+ )
+ return TaskID{}, nil
+ }
+
+ valuesMap, err := encode[T](task, q.encoding)
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ values := make([]string, 0, len(valuesMap)*2)
+ for k, v := range valuesMap {
+ values = append(values, k, v)
+ }
+
+ keys := []string{
+ q.streamKey,
+ fmt.Sprintf("%s:%s", q.streamKey, taskID.String()),
+ }
+ newTaskId, err := retScript.Run(ctx, q.rdb, keys, values).Result()
+ if err != nil {
+ return TaskID{}, err
+ }
+
+ return ParseTaskID(newTaskId.(string))
+}
+
+func (q *taskQueue[T]) List(ctx context.Context, start, end TaskID, count int64) ([]TaskInfo[T], error) {
+ if !start.timestamp.IsZero() && !end.timestamp.IsZero() && start.timestamp.After(end.timestamp) {
+ return nil, errors.New("start must be before end")
+ }
+
+ var startStr, endStr string
+ if !start.timestamp.IsZero() {
+ startStr = start.String()
+ } else {
+ startStr = "-"
+ }
+ if !end.timestamp.IsZero() {
+ endStr = "(" + end.String()
+ } else {
+ endStr = "+"
+ }
+
+ msgs, err := q.rdb.XRevRangeN(ctx, q.streamKey, endStr, startStr, count).Result()
+ if err != nil {
+ return nil, err
+ }
+ if len(msgs) == 0 {
+ return []TaskInfo[T]{}, nil
+ }
+
+ tasks := make([]TaskInfo[T], len(msgs))
+ for i := range msgs {
+ tasks[i], err = decode[T](&msgs[i], q.encoding)
+ if err != nil {
+ return nil, err
+ }
+
+ tasks[i].Result, err = q.getResult(ctx, tasks[i].ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return tasks, nil
+}
+
+func (q *taskQueue[T]) getResult(ctx context.Context, taskID TaskID) (isTaskResult, error) {
+ key := fmt.Sprintf("%s:%s", q.streamKey, taskID.String())
+ results, err := q.rdb.HMGet(ctx, key, ResultKey, ErrorKey, NextAttemptKey).Result()
+ if err != nil {
+ return nil, err
+ }
+
+ var ret isTaskResult
+ if results[0] != nil {
+ ret = &TaskResultSuccess{Result: results[0].(string)}
+ } else if results[1] != nil {
+ ret = &TaskResultError{Error: results[1].(string)}
+ if results[2] != nil {
+ nextAttempt, err := ParseTaskID(results[2].(string))
+ if err != nil {
+ return nil, err
+ }
+ ret.(*TaskResultError).NextAttempt = nextAttempt
+ }
+ }
+ return ret, nil
+}
+
+func (q *taskQueue[T]) recover(ctx context.Context, idleTimeout time.Duration) (*TaskInfo[T], error) {
+ msgs, _, err := q.rdb.XAutoClaim(ctx, &redis.XAutoClaimArgs{
+ Stream: q.streamKey,
+ Group: q.groupName,
+ MinIdle: idleTimeout,
+ Start: "0",
+ Count: 1,
+ Consumer: q.workerName,
+ }).Result()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(msgs) == 0 {
+ return nil, nil
+ }
+
+ msg := msgs[0]
+ task, err := decode[T](&msg, q.encoding)
+ if err != nil {
+ return nil, err
+ }
+ return &task, nil
+}
+
+func (q *taskQueue[T]) ack(ctx context.Context, taskID TaskID, errored bool, msg string) error {
+ key := fmt.Sprintf("%s:%s", q.streamKey, taskID.String())
+ _, err := q.rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
+ pipe.XAck(ctx, q.streamKey, q.groupName, taskID.String())
+ if errored {
+ pipe.HSet(ctx, key, ErrorKey, msg)
+ } else {
+ pipe.HSet(ctx, key, ResultKey, msg)
+ }
+ return nil
+ })
+ return err
+}
+
+func decode[T any](msg *redis.XMessage, encoding Encoding) (task TaskInfo[T], err error) {
+ task.ID, err = ParseTaskID(msg.ID)
+ if err != nil {
+ return
+ }
+
+ err = getField(msg, "attempts", &task.Attempts)
+ if err != nil {
+ return
+ }
+
+ var data string
+ err = getField(msg, "data", &data)
+ if err != nil {
+ return
+ }
+
+ switch encoding {
+ case EncodingJSON:
+ err = json.Unmarshal([]byte(data), &task.Data)
+ case EncodingGob:
+ var decoded []byte
+ decoded, err = base64.StdEncoding.DecodeString(data)
+ if err != nil {
+ return
+ }
+ err = gob.NewDecoder(bytes.NewReader(decoded)).Decode(&task.Data)
+ default:
+ err = errors.New("unsupported encoding")
+ }
+ return
+}
+
+func getField(msg *redis.XMessage, field string, v any) error {
+ vVal, ok := msg.Values[field]
+ if !ok {
+ return errors.New("missing field")
+ }
+
+ vStr, ok := vVal.(string)
+ if !ok {
+ return errors.New("invalid field type")
+ }
+
+ value := reflect.ValueOf(v).Elem()
+ switch value.Kind() {
+ case reflect.String:
+ value.SetString(vStr)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ i, err := strconv.ParseInt(vStr, 10, 64)
+ if err != nil {
+ return err
+ }
+ value.SetInt(i)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ i, err := strconv.ParseUint(vStr, 10, 64)
+ if err != nil {
+ return err
+ }
+ value.SetUint(i)
+ case reflect.Bool:
+ b, err := strconv.ParseBool(vStr)
+ if err != nil {
+ return err
+ }
+ value.SetBool(b)
+ default:
+ return errors.New("unsupported field type")
+ }
+ return nil
+}
+
+func encode[T any](task TaskInfo[T], encoding Encoding) (ret map[string]string, err error) {
+ ret = make(map[string]string)
+ ret["attempts"] = strconv.FormatUint(uint64(task.Attempts), 10)
+
+ switch encoding {
+ case EncodingJSON:
+ var data []byte
+ data, err = json.Marshal(task.Data)
+ if err != nil {
+ return
+ }
+ ret["data"] = string(data)
+ case EncodingGob:
+ var data bytes.Buffer
+ err = gob.NewEncoder(&data).Encode(task.Data)
+ if err != nil {
+ return
+ }
+ ret["data"] = base64.StdEncoding.EncodeToString(data.Bytes())
+ default:
+ err = errors.New("unsupported encoding")
+ }
+ return
+}
diff --git a/backend/internal/redis/taskqueue/queue_test.go b/backend/internal/redis/taskqueue/queue_test.go
new file mode 100644
index 0000000..ee95d39
--- /dev/null
+++ b/backend/internal/redis/taskqueue/queue_test.go
@@ -0,0 +1,467 @@
+package taskqueue
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "testing"
+ "time"
+
+ "github.com/ory/dockertest/v3"
+ "github.com/ory/dockertest/v3/docker"
+ "github.com/redis/go-redis/v9"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var client *redis.Client
+
+func TestMain(m *testing.M) {
+ pool, err := dockertest.NewPool("")
+ if err != nil {
+ log.Fatalf("Could not create pool: %s", err)
+ }
+
+ err = pool.Client.Ping()
+ if err != nil {
+ log.Fatalf("Could not connect to Docker: %s", err)
+ }
+
+ //resource, err := pool.Run("redis", "7", nil)
+ resource, err := pool.RunWithOptions(&dockertest.RunOptions{
+ Repository: "redis",
+ Tag: "7",
+ }, func(config *docker.HostConfig) {
+ config.AutoRemove = true
+ config.RestartPolicy = docker.RestartPolicy{Name: "no"}
+ })
+ if err != nil {
+ log.Fatalf("Could not start resource: %s", err)
+ }
+
+ //_ = resource.Expire(60)
+
+ if err = pool.Retry(func() error {
+ client = redis.NewClient(&redis.Options{
+ Addr: fmt.Sprintf("localhost:%s", resource.GetPort("6379/tcp")),
+ })
+ return client.Ping(context.Background()).Err()
+ }); err != nil {
+ log.Fatalf("Could not connect to redis: %s", err)
+ }
+
+ defer func() {
+ if err = client.Close(); err != nil {
+ log.Printf("Could not close client: %s", err)
+ }
+ if err = pool.Purge(resource); err != nil {
+ log.Fatalf("Could not purge resource: %s", err)
+ }
+ }()
+
+ m.Run()
+}
+
+func TestTaskQueue(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+
+ lockTimeout := 100 * time.Millisecond
+
+ tests := []struct {
+ name string
+ f func(t *testing.T)
+ }{
+ {
+ name: "Create queue",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+ },
+ },
+ {
+ name: "enqueue & dequeue",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ taskId, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ require.Equal(t, "hello", task.Data)
+ },
+ },
+ {
+ name: "complex data",
+ f: func(t *testing.T) {
+ type foo struct {
+ A int
+ B string
+ }
+
+ q, err := New[foo](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ taskId, err := q.Enqueue(context.Background(), foo{A: 42, B: "hello"})
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ require.Equal(t, foo{A: 42, B: "hello"}, task.Data)
+ },
+ },
+ {
+ name: "different workers",
+ f: func(t *testing.T) {
+ q1, err := New[string](context.Background(), client, "test", "worker1")
+ require.NoError(t, err)
+ require.NotNil(t, q1)
+
+ q2, err := New[string](context.Background(), client, "test", "worker2")
+ require.NoError(t, err)
+ require.NotNil(t, q2)
+
+ taskId, err := q1.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ task, err := q2.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, "hello", task.Data)
+ },
+ },
+ {
+ name: "complete",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ // Enqueue a task
+ taskId, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ // Dequeue the task
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, "hello", task.Data)
+
+ // Complete the task
+ err = q.Complete(context.Background(), task.ID, "done")
+ require.NoError(t, err)
+
+ // Try to dequeue the task again
+ task, err = q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.Nil(t, task)
+ },
+ },
+ {
+ name: "timeout",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ // Enqueue a task
+ taskId, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ // Dequeue the task
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, "hello", task.Data)
+
+ // Wait for the lock to expire
+ time.Sleep(lockTimeout + 10*time.Millisecond)
+
+ // Try to dequeue the task again
+ task, err = q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, "hello", task.Data)
+ },
+ },
+ {
+ name: "extend",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ // Enqueue a task
+ taskId, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ require.NotEmpty(t, taskId)
+
+ // Dequeue the task
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+ assert.Equal(t, "hello", task.Data)
+
+ // Wait for the lock to expire
+ time.Sleep(lockTimeout + 10*time.Millisecond)
+
+ // Extend the lock
+ err = q.Extend(context.Background(), task.ID)
+ require.NoError(t, err)
+
+ // Try to dequeue the task again
+ task, err = q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.Nil(t, task)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := client.FlushDB(context.Background()).Err(); err != nil {
+ t.Fatal(err)
+ }
+
+ tt.f(t)
+ })
+ }
+
+ _ = client.FlushDB(context.Background())
+}
+
+func TestTaskQueue_List(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+
+ tests := []struct {
+ name string
+ f func(t *testing.T)
+ }{
+ {
+ name: "empty",
+ f: func(t *testing.T) {
+ q, err := New[any](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ tasks, err := q.List(context.Background(), TaskID{}, TaskID{}, 1)
+ require.NoError(t, err)
+ assert.Empty(t, tasks)
+ },
+ },
+ {
+ name: "single",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ taskID, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+
+ tasks, err := q.List(context.Background(), TaskID{}, TaskID{}, 1)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 1)
+ assert.Equal(t, taskID, tasks[0])
+ },
+ },
+ {
+ name: "multiple",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ taskID, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ taskID2, err := q.Enqueue(context.Background(), "world")
+ require.NoError(t, err)
+
+ tasks, err := q.List(context.Background(), TaskID{}, TaskID{}, 2)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 2)
+ assert.Equal(t, taskID, tasks[1])
+ assert.Equal(t, taskID2, tasks[0])
+ },
+ },
+ {
+ name: "multiple limited",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ _, err = q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ taskID2, err := q.Enqueue(context.Background(), "world")
+ require.NoError(t, err)
+
+ tasks, err := q.List(context.Background(), TaskID{}, TaskID{}, 1)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 1)
+ assert.Equal(t, taskID2, tasks[0])
+ },
+ },
+ {
+ name: "multiple time range",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ taskID, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ time.Sleep(10 * time.Millisecond)
+ taskID2, err := q.Enqueue(context.Background(), "world")
+ require.NoError(t, err)
+
+ tasks, err := q.List(context.Background(), TaskID{}, taskID2.ID, 100)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 1)
+ assert.Equal(t, taskID, tasks[0])
+
+ tasks, err = q.List(context.Background(), taskID2.ID, TaskID{}, 100)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 1)
+ assert.Equal(t, taskID2, tasks[0])
+ },
+ },
+ {
+ name: "completed tasks",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ task1, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+ task2, err := q.Enqueue(context.Background(), "world")
+ require.NoError(t, err)
+
+ err = q.Complete(context.Background(), task1.ID, "done")
+ require.NoError(t, err)
+
+ tasks, err := q.List(context.Background(), TaskID{}, TaskID{}, 100)
+ require.NoError(t, err)
+ assert.Len(t, tasks, 2)
+ assert.Equal(t, task2, tasks[0])
+
+ assert.Equal(t, "hello", tasks[1].Data)
+ require.IsType(t, &TaskResultSuccess{}, tasks[1].Result)
+ assert.Equal(t, "done", tasks[1].Result.(*TaskResultSuccess).Result)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := client.FlushDB(context.Background()).Err(); err != nil {
+ t.Fatal(err)
+ }
+
+ tt.f(t)
+ })
+ }
+
+ _ = client.FlushDB(context.Background())
+}
+
+func TestTaskQueue_Return(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+
+ lockTimeout := 100 * time.Millisecond
+
+ tests := []struct {
+ name string
+ f func(t *testing.T)
+ }{
+ {
+ name: "simple",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ task1, err := q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+
+ id := claimAndFail(t, q, lockTimeout)
+
+ task2, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task2)
+ assert.Equal(t, task2.ID, id)
+ assert.Equal(t, task1.Data, task2.Data)
+ assert.Equal(t, uint8(1), task2.Attempts)
+
+ task1Data, err := q.Data(context.Background(), task1.ID)
+ require.NoError(t, err)
+ assert.Equal(t, task1Data.ID, task1.ID)
+ assert.Equal(t, task1Data.Data, task1.Data)
+ assert.IsType(t, &TaskResultError{}, task1Data.Result)
+ assert.Equal(t, "failed", task1Data.Result.(*TaskResultError).Error)
+ assert.Equal(t, task2.ID, task1Data.Result.(*TaskResultError).NextAttempt)
+ },
+ },
+ {
+ name: "failure",
+ f: func(t *testing.T) {
+ q, err := New[string](context.Background(), client, "test", "worker")
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ _, err = q.Enqueue(context.Background(), "hello")
+ require.NoError(t, err)
+
+ claimAndFail(t, q, lockTimeout)
+ claimAndFail(t, q, lockTimeout)
+ claimAndFail(t, q, lockTimeout)
+
+ task3, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ assert.Nil(t, task3)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := client.FlushDB(context.Background()).Err(); err != nil {
+ t.Fatal(err)
+ }
+
+ tt.f(t)
+ })
+ }
+
+ _ = client.FlushDB(context.Background())
+}
+
+func claimAndFail[T any](t *testing.T, q TaskQueue[T], lockTimeout time.Duration) TaskID {
+ task, err := q.Dequeue(context.Background(), lockTimeout, 10*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, task)
+
+ id, err := q.Return(context.Background(), task.ID, errors.New("failed"))
+ require.NoError(t, err)
+ assert.NotEqual(t, task.ID, id)
+ return id
+}
diff --git a/backend/internal/server/idb/stock/v1/stock.go b/backend/internal/server/idb/stock/v1/stock.go
new file mode 100644
index 0000000..8afc2b1
--- /dev/null
+++ b/backend/internal/server/idb/stock/v1/stock.go
@@ -0,0 +1,64 @@
+package stock
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ pb "github.com/ansg191/ibd-trader-backend/api/gen/idb/stock/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/scrape"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+
+ "cloud.google.com/go/longrunning/autogen/longrunningpb"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/anypb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+const ScrapeOperationPrefix = "scrape"
+
+type Server struct {
+ pb.UnimplementedStockServiceServer
+
+ db database.Executor
+ queue taskqueue.TaskQueue[scrape.TaskInfo]
+}
+
+func New(db database.Executor, queue taskqueue.TaskQueue[scrape.TaskInfo]) *Server {
+ return &Server{db: db, queue: queue}
+}
+
+func (s *Server) CreateStock(ctx context.Context, request *pb.CreateStockRequest) (*pb.CreateStockResponse, error) {
+ task, err := s.queue.Enqueue(ctx, scrape.TaskInfo{Symbol: request.Symbol})
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to enqueue task", "err", err)
+ return nil, status.New(codes.Internal, "failed to enqueue task").Err()
+ }
+ op := &longrunningpb.Operation{
+ Name: fmt.Sprintf("%s/%s", ScrapeOperationPrefix, task.ID.String()),
+ Metadata: new(anypb.Any),
+ Done: false,
+ Result: nil,
+ }
+ err = op.Metadata.MarshalFrom(&pb.StockScrapeOperationMetadata{
+ Symbol: request.Symbol,
+ StartTime: timestamppb.New(task.ID.Timestamp()),
+ })
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to marshal metadata", "err", err)
+ return nil, status.New(codes.Internal, "failed to marshal metadata").Err()
+ }
+ return &pb.CreateStockResponse{Operation: op}, nil
+}
+
+func (s *Server) GetStock(ctx context.Context, request *pb.GetStockRequest) (*pb.GetStockResponse, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (s *Server) ListStocks(ctx context.Context, request *pb.ListStocksRequest) (*pb.ListStocksResponse, error) {
+ //TODO implement me
+ panic("implement me")
+}
diff --git a/backend/internal/server/idb/user/v1/user.go b/backend/internal/server/idb/user/v1/user.go
new file mode 100644
index 0000000..2f32e03
--- /dev/null
+++ b/backend/internal/server/idb/user/v1/user.go
@@ -0,0 +1,159 @@
+package user
+
+import (
+ "context"
+ "errors"
+
+ pb "github.com/ansg191/ibd-trader-backend/api/gen/idb/user/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+
+ "github.com/mennanov/fmutils"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
+)
+
+type Server struct {
+ pb.UnimplementedUserServiceServer
+
+ db database.TransactionExecutor
+ kms keys.KeyManagementService
+ keyName string
+ client *ibd.Client
+}
+
+func New(db database.TransactionExecutor, kms keys.KeyManagementService, keyName string, client *ibd.Client) *Server {
+ return &Server{
+ db: db,
+ kms: kms,
+ keyName: keyName,
+ client: client,
+ }
+}
+
+func (u *Server) CreateUser(ctx context.Context, request *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
+ err := database.AddUser(ctx, u.db, request.Subject)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to create user: %v", err)
+ }
+
+ user, err := database.GetUser(ctx, u.db, request.Subject)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to get user: %v", err)
+ }
+
+ return &pb.CreateUserResponse{
+ User: &pb.User{
+ Subject: user.Subject,
+ IbdUsername: user.IBDUsername,
+ IbdPassword: nil,
+ },
+ }, nil
+}
+
+func (u *Server) GetUser(ctx context.Context, request *pb.GetUserRequest) (*pb.GetUserResponse, error) {
+ user, err := database.GetUser(ctx, u.db, request.Subject)
+ if errors.Is(err, database.ErrUserNotFound) {
+ return nil, status.New(codes.NotFound, "user not found").Err()
+ }
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to get user: %v", err)
+ }
+
+ return &pb.GetUserResponse{
+ User: &pb.User{
+ Subject: user.Subject,
+ IbdUsername: user.IBDUsername,
+ IbdPassword: nil,
+ },
+ }, nil
+}
+
+func (u *Server) UpdateUser(ctx context.Context, request *pb.UpdateUserRequest) (*pb.UpdateUserResponse, error) {
+ request.UpdateMask.Normalize()
+ if !request.UpdateMask.IsValid(request.User) {
+ return nil, status.Errorf(codes.InvalidArgument, "invalid update mask")
+ }
+
+ existingUserRes, err := u.GetUser(ctx, &pb.GetUserRequest{Subject: request.User.Subject})
+ if err != nil {
+ return nil, err
+ }
+ existingUser := existingUserRes.User
+
+ newUser := proto.Clone(existingUser).(*pb.User)
+ fmutils.Overwrite(request.User, newUser, request.UpdateMask.Paths)
+
+ // if IDB creds are both set and are different, update them
+ if (newUser.IbdPassword != nil && newUser.IbdUsername != nil) &&
+ (newUser.IbdPassword != existingUser.IbdPassword ||
+ newUser.IbdUsername != existingUser.IbdUsername) {
+ // Update IBD creds
+ err = database.AddIBDCreds(ctx, u.db, u.kms, u.keyName, newUser.Subject, *newUser.IbdUsername, *newUser.IbdPassword)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to update user: %v", err)
+ }
+ }
+
+ newUser.IbdPassword = nil
+ return &pb.UpdateUserResponse{
+ User: newUser,
+ }, nil
+}
+
+func (u *Server) CheckIBDUsername(ctx context.Context, req *pb.CheckIBDUsernameRequest) (*pb.CheckIBDUsernameResponse, error) {
+ username := req.IbdUsername
+ if username == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "username cannot be empty")
+ }
+
+ // Check if the username exists
+ exists, err := u.client.CheckIBDUsername(ctx, username)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to check username: %v", err)
+ }
+
+ return &pb.CheckIBDUsernameResponse{
+ Exists: exists,
+ }, nil
+}
+
+func (u *Server) AuthenticateUser(ctx context.Context, req *pb.AuthenticateUserRequest) (*pb.AuthenticateUserResponse, error) {
+ // Check if user has cookies
+ cookies, err := database.GetCookies(ctx, u.db, u.kms, req.Subject, false)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to get cookies: %v", err)
+ }
+ if len(cookies) > 0 {
+ return &pb.AuthenticateUserResponse{
+ Authenticated: true,
+ }, nil
+ }
+
+ // Authenticate user
+ // Get IBD creds
+ username, password, err := database.GetIBDCreds(ctx, u.db, u.kms, req.Subject)
+ if errors.Is(err, database.ErrIBDCredsNotFound) {
+ return nil, status.New(codes.NotFound, "User has no IDB creds").Err()
+ }
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to get IBD creds: %v", err)
+ }
+
+ // Authenticate user
+ cookie, err := u.client.Authenticate(ctx, username, password)
+ if errors.Is(err, ibd.ErrBadCredentials) {
+ return &pb.AuthenticateUserResponse{
+ Authenticated: false,
+ }, nil
+ }
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "unable to authenticate user: %v", err)
+ }
+
+ return &pb.AuthenticateUserResponse{
+ Authenticated: cookie != nil,
+ }, nil
+}
diff --git a/backend/internal/server/operations.go b/backend/internal/server/operations.go
new file mode 100644
index 0000000..2487427
--- /dev/null
+++ b/backend/internal/server/operations.go
@@ -0,0 +1,142 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "cloud.google.com/go/longrunning/autogen/longrunningpb"
+ spb "github.com/ansg191/ibd-trader-backend/api/gen/idb/stock/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/scrape"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+ "github.com/ansg191/ibd-trader-backend/internal/server/idb/stock/v1"
+ epb "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/anypb"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+type operationServer struct {
+ longrunningpb.UnimplementedOperationsServer
+
+ scrape taskqueue.TaskQueue[scrape.TaskInfo]
+}
+
+func newOperationServer(scrapeQueue taskqueue.TaskQueue[scrape.TaskInfo]) *operationServer {
+ return &operationServer{scrape: scrapeQueue}
+}
+
+func (o *operationServer) ListOperations(
+ ctx context.Context,
+ req *longrunningpb.ListOperationsRequest,
+) (*longrunningpb.ListOperationsResponse, error) {
+ var end taskqueue.TaskID
+ if req.PageToken != "" {
+ var err error
+ end, err = taskqueue.ParseTaskID(req.PageToken)
+ if err != nil {
+ return nil, status.New(codes.InvalidArgument, err.Error()).Err()
+ }
+ } else {
+ end = taskqueue.TaskID{}
+ }
+
+ switch req.Name {
+ case stock.ScrapeOperationPrefix:
+ tasks, err := o.scrape.List(ctx, taskqueue.TaskID{}, end, int64(req.PageSize))
+ if err != nil {
+ return nil, status.New(codes.Internal, "unable to list IDs").Err()
+ }
+
+ ops := make([]*longrunningpb.Operation, len(tasks))
+ for i, task := range tasks {
+ ops[i] = &longrunningpb.Operation{
+ Name: fmt.Sprintf("%s/%s", stock.ScrapeOperationPrefix, task.ID.String()),
+ Metadata: new(anypb.Any),
+ Done: task.Result != nil,
+ Result: nil,
+ }
+ err = ops[i].Metadata.MarshalFrom(&spb.StockScrapeOperationMetadata{
+ Symbol: task.Data.Symbol,
+ StartTime: timestamppb.New(task.ID.Timestamp()),
+ })
+ if err != nil {
+ return nil, status.New(codes.Internal, "unable to marshal metadata").Err()
+ }
+
+ switch res := task.Result.(type) {
+ case *taskqueue.TaskResultSuccess:
+ return nil, status.New(codes.Unimplemented, "not implemented").Err()
+ case *taskqueue.TaskResultError:
+ s := status.New(codes.Unknown, res.Error)
+ s, err = s.WithDetails(
+ &epb.ErrorInfo{
+ Reason: "",
+ Domain: "",
+ Metadata: nil,
+ })
+ if err != nil {
+ return nil, status.New(codes.Internal, "unable to marshal error details").Err()
+ }
+ ops[i].Result = &longrunningpb.Operation_Error{Error: s.Proto()}
+ }
+ }
+
+ var nextPageToken string
+ if len(tasks) == int(req.PageSize) {
+ nextPageToken = tasks[len(tasks)-1].ID.String()
+ } else {
+ nextPageToken = ""
+ }
+
+ return &longrunningpb.ListOperationsResponse{
+ Operations: ops,
+ NextPageToken: nextPageToken,
+ }, nil
+ default:
+ return nil, status.New(codes.NotFound, "unknown operation type").Err()
+ }
+}
+
+func (o *operationServer) GetOperation(ctx context.Context, req *longrunningpb.GetOperationRequest) (*longrunningpb.Operation, error) {
+ prefix, id, ok := strings.Cut(req.Name, "/")
+ if !ok || prefix == "" || id == "" {
+ return nil, status.New(codes.InvalidArgument, "invalid operation name").Err()
+ }
+
+ taskID, err := taskqueue.ParseTaskID(id)
+ if err != nil {
+ return nil, status.New(codes.InvalidArgument, err.Error()).Err()
+ }
+
+ switch prefix {
+ case stock.ScrapeOperationPrefix:
+ task, err := o.scrape.Data(ctx, taskID)
+ if errors.Is(err, taskqueue.ErrTaskNotFound) {
+ return nil, status.New(codes.NotFound, "operation not found").Err()
+ }
+ if err != nil {
+ slog.ErrorContext(ctx, "unable to get operation", "error", err)
+ return nil, status.New(codes.Internal, "unable to get operation").Err()
+ }
+ op := &longrunningpb.Operation{
+ Name: req.Name,
+ Metadata: new(anypb.Any),
+ Done: task.Result != nil,
+ Result: nil,
+ }
+ err = op.Metadata.MarshalFrom(&spb.StockScrapeOperationMetadata{
+ Symbol: task.Data.Symbol,
+ StartTime: timestamppb.New(task.ID.Timestamp()),
+ })
+ if err != nil {
+ return nil, status.New(codes.Internal, "unable to marshal metadata").Err()
+ }
+ return op, nil
+ default:
+ return nil, status.New(codes.NotFound, "unknown operation type").Err()
+ }
+}
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
new file mode 100644
index 0000000..c525cfd
--- /dev/null
+++ b/backend/internal/server/server.go
@@ -0,0 +1,77 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+
+ "cloud.google.com/go/longrunning/autogen/longrunningpb"
+ spb "github.com/ansg191/ibd-trader-backend/api/gen/idb/stock/v1"
+ upb "github.com/ansg191/ibd-trader-backend/api/gen/idb/user/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/scrape"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+ "github.com/ansg191/ibd-trader-backend/internal/server/idb/stock/v1"
+ "github.com/ansg191/ibd-trader-backend/internal/server/idb/user/v1"
+ "github.com/redis/go-redis/v9"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/reflection"
+)
+
+//go:generate make -C ../../api/ generate
+
+type Server struct {
+ s *grpc.Server
+ port uint16
+}
+
+func New(
+ ctx context.Context,
+ port uint16,
+ db database.TransactionExecutor,
+ rClient *redis.Client,
+ client *ibd.Client,
+ kms keys.KeyManagementService,
+ keyName string,
+) (*Server, error) {
+ scrapeQueue, err := taskqueue.New(
+ ctx,
+ rClient,
+ scrape.Queue,
+ "grpc-server",
+ taskqueue.WithEncoding[scrape.TaskInfo](scrape.QueueEncoding))
+ if err != nil {
+ return nil, err
+ }
+
+ s := grpc.NewServer()
+ upb.RegisterUserServiceServer(s, user.New(db, kms, keyName, client))
+ spb.RegisterStockServiceServer(s, stock.New(db, scrapeQueue))
+ longrunningpb.RegisterOperationsServer(s, newOperationServer(scrapeQueue))
+ reflection.Register(s)
+ return &Server{s, port}, nil
+}
+
+func (s *Server) Serve(ctx context.Context) error {
+ lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
+ if err != nil {
+ return err
+ }
+
+ // Graceful shutdown
+ go func() {
+ <-ctx.Done()
+ slog.ErrorContext(ctx,
+ "Shutting down server",
+ "err", ctx.Err(),
+ "cause", context.Cause(ctx),
+ )
+ s.s.GracefulStop()
+ }()
+
+ slog.InfoContext(ctx, "Starting gRPC server", "port", s.port)
+ return s.s.Serve(lis)
+}
diff --git a/backend/internal/utils/money.go b/backend/internal/utils/money.go
new file mode 100644
index 0000000..2dc2286
--- /dev/null
+++ b/backend/internal/utils/money.go
@@ -0,0 +1,99 @@
+package utils
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/Rhymond/go-money"
+)
+
+// supported currencies
+var currencies = money.Currencies{
+ "USD": money.GetCurrency(money.USD),
+ "EUR": money.GetCurrency(money.EUR),
+ "GBP": money.GetCurrency(money.GBP),
+ "JPY": money.GetCurrency(money.JPY),
+ "CNY": money.GetCurrency(money.CNY),
+}
+
+func ParseMoney(s string) (*money.Money, error) {
+ for _, c := range currencies {
+ numPart, ok := isCurrency(s, c)
+ if !ok {
+ continue
+ }
+
+ // Parse the number part
+ num, err := strconv.ParseUint(numPart, 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse number: %w", err)
+ }
+
+ return money.New(int64(num), c.Code), nil
+ }
+ return nil, fmt.Errorf("matching currency not found")
+}
+
+func isCurrency(s string, c *money.Currency) (string, bool) {
+ var numPart string
+ for _, tp := range c.Template {
+ switch tp {
+ case '$':
+ // There should be a matching grapheme in the s at this position
+ remaining, ok := strings.CutPrefix(s, c.Grapheme)
+ if !ok {
+ return "", false
+ }
+ s = remaining
+ case '1':
+ // There should be a number, thousands, or decimal separator in the s at this position
+ // Number of expected decimal places
+ decimalFound := -1
+ // Read from string until a non-number, non-thousands, non-decimal, or EOF is found
+ for len(s) > 0 && (string(s[0]) == c.Thousand || string(s[0]) == c.Decimal || '0' <= s[0] && s[0] <= '9') {
+ // If the character is a number
+ if '0' <= s[0] && s[0] <= '9' {
+ // If we've hit decimal limit, break
+ if decimalFound == 0 {
+ break
+ }
+ // add the number to the numPart
+ numPart += string(s[0])
+ // Decrement the decimal count
+ // If the decimal has been found, `decimalFound` is positive
+ // If the decimal hasn't been found, `decimalFound` is negative, and decrementing it does nothing
+ decimalFound--
+ }
+ // If decimal has been found (>= 0) and the character is a thousand separator or decimal separator,
+ // then the number is invalid
+ if decimalFound >= 0 && (string(s[0]) == c.Thousand || string(s[0]) == c.Decimal) {
+ return "", false
+ }
+ // If the character is a decimal separator, set `decimalFound` to the number of
+ // expected decimal places for the currency
+ if string(s[0]) == c.Decimal {
+ decimalFound = c.Fraction
+ }
+ // Move to the next character
+ s = s[1:]
+ }
+ if decimalFound > 0 {
+ // If there should be more decimal places, add them
+ numPart += strings.Repeat("0", decimalFound)
+ } else if decimalFound < 0 {
+ // If no decimal was found, add the expected number of decimal places
+ numPart += strings.Repeat("0", c.Fraction)
+ }
+ case ' ':
+ // There should be a space in the s at this position
+ if len(s) == 0 || s[0] != ' ' {
+ return "", false
+ }
+ s = s[1:]
+ default:
+ panic(fmt.Sprintf("unsupported template character: %c", tp))
+ }
+ }
+ return numPart, true
+}
diff --git a/backend/internal/utils/money_test.go b/backend/internal/utils/money_test.go
new file mode 100644
index 0000000..27ace06
--- /dev/null
+++ b/backend/internal/utils/money_test.go
@@ -0,0 +1,106 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/Rhymond/go-money"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseMoney(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want *money.Money
+ }{
+ {
+ name: "en-US int no comma",
+ input: "$123",
+ want: money.New(12300, money.USD),
+ },
+ {
+ name: "en-US int comma",
+ input: "$1,123",
+ want: money.New(112300, money.USD),
+ },
+ {
+ name: "en-US decimal comma",
+ input: "$1,123.45",
+ want: money.New(112345, money.USD),
+ },
+ {
+ name: "zh-CN decimal comma",
+ input: "1,234.56 \u5143",
+ want: money.New(123456, money.CNY),
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m, err := ParseMoney(tt.input)
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, m)
+ })
+ }
+}
+
+func Test_isCurrency(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ currency string
+ numPart string
+ ok bool
+ }{
+ {
+ name: "en-US int no comma",
+ input: "$123",
+ currency: money.USD,
+ numPart: "12300",
+ ok: true,
+ },
+ {
+ name: "en-US int comma",
+ input: "$1,123",
+ currency: money.USD,
+ numPart: "112300",
+ ok: true,
+ },
+ {
+ name: "en-US decimal comma",
+ input: "$1,123.45",
+ currency: money.USD,
+ numPart: "112345",
+ ok: true,
+ },
+ {
+ name: "en-US 1 decimal comma",
+ input: "$1,123.5",
+ currency: money.USD,
+ numPart: "112350",
+ ok: true,
+ },
+ {
+ name: "en-US no grapheme",
+ input: "1,234.56",
+ currency: money.USD,
+ numPart: "",
+ ok: false,
+ },
+ {
+ name: "zh-CN decimal comma",
+ input: "1,234.56 \u5143",
+ currency: money.CNY,
+ numPart: "123456",
+ ok: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := money.GetCurrency(tt.currency)
+ numPart, ok := isCurrency(tt.input, c)
+ assert.Equal(t, tt.ok, ok)
+ assert.Equal(t, tt.numPart, numPart)
+ })
+ }
+}
diff --git a/backend/internal/worker/analyzer/analyzer.go b/backend/internal/worker/analyzer/analyzer.go
new file mode 100644
index 0000000..20621dd
--- /dev/null
+++ b/backend/internal/worker/analyzer/analyzer.go
@@ -0,0 +1,142 @@
+package analyzer
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/analyzer"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ Queue = "analyzer"
+ QueueEncoding = taskqueue.EncodingJSON
+
+ lockTimeout = 1 * time.Minute
+ dequeueTimeout = 5 * time.Second
+)
+
+func RunAnalyzer(
+ ctx context.Context,
+ redis *redis.Client,
+ analyzer analyzer.Analyzer,
+ db database.Executor,
+ name string,
+) error {
+ queue, err := taskqueue.New(
+ ctx,
+ redis,
+ Queue,
+ name,
+ taskqueue.WithEncoding[TaskInfo](QueueEncoding),
+ )
+ if err != nil {
+ return err
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ waitForTask(ctx, queue, analyzer, db)
+ }
+ }
+}
+
+func waitForTask(
+ ctx context.Context,
+ queue taskqueue.TaskQueue[TaskInfo],
+ analyzer analyzer.Analyzer,
+ db database.Executor,
+) {
+ task, err := queue.Dequeue(ctx, lockTimeout, dequeueTimeout)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to dequeue task", "error", err)
+ return
+ }
+ if task == nil {
+ // No task available.
+ return
+ }
+
+ errCh := make(chan error)
+ resCh := make(chan string)
+ defer close(errCh)
+ defer close(resCh)
+ go func() {
+ res, err := analyzeStock(ctx, analyzer, db, task.Data.ID)
+ if err != nil {
+ errCh <- err
+ return
+ }
+ resCh <- res
+ }()
+
+ ticker := time.NewTicker(lockTimeout / 5)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ // Context was canceled. Return early.
+ return
+ case <-ticker.C:
+ // Extend the lock periodically.
+ func() {
+ ctx, cancel := context.WithTimeout(ctx, lockTimeout/5)
+ defer cancel()
+
+ err := queue.Extend(ctx, task.ID)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to extend lock", "error", err)
+ }
+ }()
+ case err = <-errCh:
+ // analyzeStock has errored.
+ slog.ErrorContext(ctx, "Failed to analyze", "error", err)
+ _, err = queue.Return(ctx, task.ID, err)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to return task", "error", err)
+ return
+ }
+ return
+ case res := <-resCh:
+ // analyzeStock has completed successfully.
+ slog.DebugContext(ctx, "Analyzed ID", "id", task.Data.ID, "result", res)
+ err = queue.Complete(ctx, task.ID, res)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to complete task", "error", err)
+ return
+ }
+ return
+ }
+ }
+}
+
+func analyzeStock(ctx context.Context, a analyzer.Analyzer, db database.Executor, id string) (string, error) {
+ info, err := database.GetStockInfo(ctx, db, id)
+ if err != nil {
+ return "", err
+ }
+
+ analysis, err := a.Analyze(
+ ctx,
+ info.Symbol,
+ info.Price,
+ info.ChartAnalysis,
+ )
+ if err != nil {
+ return "", err
+ }
+
+ return database.AddAnalysis(ctx, db, id, analysis)
+}
+
+type TaskInfo struct {
+ ID string `json:"id"`
+}
diff --git a/backend/internal/worker/auth/auth.go b/backend/internal/worker/auth/auth.go
new file mode 100644
index 0000000..0daa112
--- /dev/null
+++ b/backend/internal/worker/auth/auth.go
@@ -0,0 +1,239 @@
+package auth
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/auth"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ lockTimeout = 1 * time.Minute
+ dequeueTimeout = 5 * time.Second
+)
+
+func RunAuthScraper(
+ ctx context.Context,
+ client *ibd.Client,
+ redis *redis.Client,
+ db database.TransactionExecutor,
+ kms keys.KeyManagementService,
+ name string,
+) error {
+ queue, err := taskqueue.New(
+ ctx,
+ redis,
+ auth.Queue,
+ name,
+ taskqueue.WithEncoding[auth.TaskInfo](auth.QueueEncoding),
+ )
+ if err != nil {
+ return err
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ waitForTask(ctx, queue, client, db, kms)
+ }
+ }
+}
+
+func waitForTask(
+ ctx context.Context,
+ queue taskqueue.TaskQueue[auth.TaskInfo],
+ client *ibd.Client,
+ db database.TransactionExecutor,
+ kms keys.KeyManagementService,
+) {
+ task, err := queue.Dequeue(ctx, lockTimeout, dequeueTimeout)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to dequeue task", "error", err)
+ return
+ }
+ if task == nil {
+ // No task available.
+ return
+ }
+ slog.DebugContext(ctx, "Picked up auth task", "task", task.ID, "user", task.Data.UserSubject)
+
+ ch := make(chan error)
+ defer close(ch)
+ go func() {
+ ch <- scrapeCookies(ctx, client, db, kms, task.Data.UserSubject)
+ }()
+
+ ticker := time.NewTicker(lockTimeout / 5)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ // The context was canceled. Return early.
+ return
+ case <-ticker.C:
+ // Extend the lock periodically.
+ func() {
+ ctx, cancel := context.WithTimeout(ctx, lockTimeout/5)
+ defer cancel()
+
+ err := queue.Extend(ctx, task.ID)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to extend lock", "error", err)
+ }
+ }()
+ case err = <-ch:
+ // scrapeCookies has completed.
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to scrape cookies", "error", err)
+ _, err = queue.Return(ctx, task.ID, err)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to return task", "error", err)
+ return
+ }
+ } else {
+ err = queue.Complete(ctx, task.ID, "")
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to complete task", "error", err)
+ return
+ }
+ slog.DebugContext(ctx, "Authenticated user", "user", task.Data.UserSubject)
+ }
+ return
+ }
+ }
+}
+
+func scrapeCookies(
+ ctx context.Context,
+ client *ibd.Client,
+ db database.TransactionExecutor,
+ kms keys.KeyManagementService,
+ user string,
+) error {
+ ctx, cancel := context.WithTimeout(ctx, lockTimeout)
+ defer cancel()
+
+ // Check if the user has valid cookies
+ done, err := hasValidCookies(ctx, db, user)
+ if err != nil {
+ return fmt.Errorf("failed to check cookies: %w", err)
+ }
+ if done {
+ return nil
+ }
+
+ // Health check degraded cookies
+ done, err = healthCheckDegradedCookies(ctx, client, db, kms, user)
+ if err != nil {
+ return fmt.Errorf("failed to health check cookies: %w", err)
+ }
+ if done {
+ return nil
+ }
+
+ // No cookies are valid, so scrape new cookies
+ return scrapeNewCookies(ctx, client, db, kms, user)
+}
+
+func hasValidCookies(ctx context.Context, db database.Executor, user string) (bool, error) {
+ // Check if the user has non-degraded cookies
+ row := db.QueryRowContext(ctx, `
+SELECT 1
+FROM ibd_tokens
+WHERE user_subject = $1
+ AND expires_at > NOW()
+ AND degraded = FALSE;`, user)
+
+ var exists bool
+ err := row.Scan(&exists)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("failed to get non-degraded cookies: %w", err)
+ }
+
+ return true, nil
+}
+
+func healthCheckDegradedCookies(
+ ctx context.Context,
+ client *ibd.Client,
+ db database.Executor,
+ kms keys.KeyManagementService,
+ user string,
+) (bool, error) {
+ // Check if the user has degraded cookies
+ cookies, err := database.GetCookies(ctx, db, kms, user, true)
+ if err != nil {
+ return false, fmt.Errorf("failed to get degraded cookies: %w", err)
+ }
+
+ valid := false
+ for _, cookie := range cookies {
+ slog.DebugContext(ctx, "Health checking cookie", "cookie", cookie.ID)
+
+ // Health check the cookie
+ up, err := client.UserInfo(ctx, cookie.ToHTTPCookie())
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to health check cookie", "error", err)
+ continue
+ }
+
+ if up.Status != ibd.UserStatusSubscriber {
+ continue
+ }
+
+ // Cookie is valid
+ valid = true
+
+ // Update the cookie
+ err = database.RepairCookie(ctx, db, cookie.ID)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to repair cookie", "error", err)
+ }
+ }
+
+ return valid, nil
+}
+
+func scrapeNewCookies(
+ ctx context.Context,
+ client *ibd.Client,
+ db database.TransactionExecutor,
+ kms keys.KeyManagementService,
+ user string,
+) error {
+ // Get the user's credentials
+ username, password, err := database.GetIBDCreds(ctx, db, kms, user)
+ if err != nil {
+ return fmt.Errorf("failed to get IBD credentials: %w", err)
+ }
+
+ // Scrape the user's cookies
+ cookie, err := client.Authenticate(ctx, username, password)
+ if err != nil {
+ return fmt.Errorf("failed to authenticate user: %w", err)
+ }
+
+ // Store the cookie
+ err = database.AddCookie(ctx, db, kms, user, cookie)
+ if err != nil {
+ return fmt.Errorf("failed to store cookie: %w", err)
+ }
+
+ return nil
+}
diff --git a/backend/internal/worker/scraper/scraper.go b/backend/internal/worker/scraper/scraper.go
new file mode 100644
index 0000000..c5c1b6c
--- /dev/null
+++ b/backend/internal/worker/scraper/scraper.go
@@ -0,0 +1,198 @@
+package scraper
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager/ibd/scrape"
+ "github.com/ansg191/ibd-trader-backend/internal/redis/taskqueue"
+ "github.com/ansg191/ibd-trader-backend/internal/worker/analyzer"
+
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ lockTimeout = 1 * time.Minute
+ dequeueTimeout = 5 * time.Second
+)
+
+func RunScraper(
+ ctx context.Context,
+ redis *redis.Client,
+ client *ibd.Client,
+ db database.TransactionExecutor,
+ name string,
+) error {
+ queue, err := taskqueue.New(
+ ctx,
+ redis,
+ scrape.Queue,
+ name,
+ taskqueue.WithEncoding[scrape.TaskInfo](scrape.QueueEncoding),
+ )
+ if err != nil {
+ return err
+ }
+
+ aQueue, err := taskqueue.New(
+ ctx,
+ redis,
+ analyzer.Queue,
+ name,
+ taskqueue.WithEncoding[analyzer.TaskInfo](analyzer.QueueEncoding),
+ )
+ if err != nil {
+ return err
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ waitForTask(ctx, queue, aQueue, client, db)
+ }
+ }
+}
+
+func waitForTask(
+ ctx context.Context,
+ queue taskqueue.TaskQueue[scrape.TaskInfo],
+ aQueue taskqueue.TaskQueue[analyzer.TaskInfo],
+ client *ibd.Client,
+ db database.TransactionExecutor,
+) {
+ task, err := queue.Dequeue(ctx, lockTimeout, dequeueTimeout)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to dequeue task", "error", err)
+ return
+ }
+ if task == nil {
+ // No task available.
+ return
+ }
+
+ errCh := make(chan error)
+ resCh := make(chan string)
+ defer close(errCh)
+ defer close(resCh)
+ go func() {
+ res, err := scrapeUrl(ctx, client, db, aQueue, task.Data.Symbol)
+ if err != nil {
+ errCh <- err
+ return
+ }
+ resCh <- res
+ }()
+
+ ticker := time.NewTicker(lockTimeout / 5)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ // Context was canceled. Return early.
+ return
+ case <-ticker.C:
+ // Extend the lock periodically.
+ func() {
+ ctx, cancel := context.WithTimeout(ctx, lockTimeout/5)
+ defer cancel()
+
+ err := queue.Extend(ctx, task.ID)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to extend lock", "error", err)
+ }
+ }()
+ case err = <-errCh:
+ // scrapeUrl has errored.
+ slog.ErrorContext(ctx, "Failed to scrape URL", "error", err)
+ _, err = queue.Return(ctx, task.ID, err)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to return task", "error", err)
+ return
+ }
+ return
+ case res := <-resCh:
+ // scrapeUrl has completed successfully.
+ slog.DebugContext(ctx, "Scraped URL", "symbol", task.Data.Symbol)
+ err = queue.Complete(ctx, task.ID, res)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to complete task", "error", err)
+ return
+ }
+ return
+ }
+ }
+}
+
+func scrapeUrl(
+ ctx context.Context,
+ client *ibd.Client,
+ db database.TransactionExecutor,
+ aQueue taskqueue.TaskQueue[analyzer.TaskInfo],
+ symbol string,
+) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, lockTimeout)
+ defer cancel()
+
+ stockUrl, err := getStockUrl(ctx, db, client, symbol)
+ if err != nil {
+ return "", fmt.Errorf("failed to get stock url: %w", err)
+ }
+
+ // Scrape the stock info.
+ info, err := client.StockInfo(ctx, stockUrl)
+ if err != nil {
+ return "", fmt.Errorf("failed to get stock info: %w", err)
+ }
+
+ // Add stock info to the database.
+ id, err := database.AddStockInfo(ctx, db, info)
+ if err != nil {
+ return "", fmt.Errorf("failed to add stock info: %w", err)
+ }
+
+ // Add the stock to the analyzer queue.
+ _, err = aQueue.Enqueue(ctx, analyzer.TaskInfo{ID: id})
+ if err != nil {
+ return "", fmt.Errorf("failed to enqueue analysis task: %w", err)
+ }
+
+ slog.DebugContext(ctx, "Added stock info", "id", id)
+
+ return id, nil
+}
+
+func getStockUrl(ctx context.Context, db database.TransactionExecutor, client *ibd.Client, symbol string) (string, error) {
+ // Get the stock from the database.
+ stock, err := database.GetStock(ctx, db, symbol)
+ if err == nil {
+ return stock.IBDUrl, nil
+ }
+ if !errors.Is(err, database.ErrStockNotFound) {
+ return "", fmt.Errorf("failed to get stock: %w", err)
+ }
+
+ // If stock isn't found in the database, get the stock from IBD.
+ stock, err = client.Search(ctx, symbol)
+ if errors.Is(err, ibd.ErrSymbolNotFound) {
+ return "", fmt.Errorf("symbol not found: %w", err)
+ }
+ if err != nil {
+ return "", fmt.Errorf("failed to search for symbol: %w", err)
+ }
+
+ // Add the stock to the database.
+ err = database.AddStock(ctx, db, stock)
+ if err != nil {
+ return "", fmt.Errorf("failed to add stock: %w", err)
+ }
+
+ return stock.IBDUrl, nil
+}
diff --git a/backend/internal/worker/worker.go b/backend/internal/worker/worker.go
new file mode 100644
index 0000000..6017fb7
--- /dev/null
+++ b/backend/internal/worker/worker.go
@@ -0,0 +1,151 @@
+package worker
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "io"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/ansg191/ibd-trader-backend/internal/analyzer"
+ "github.com/ansg191/ibd-trader-backend/internal/database"
+ "github.com/ansg191/ibd-trader-backend/internal/ibd"
+ "github.com/ansg191/ibd-trader-backend/internal/keys"
+ "github.com/ansg191/ibd-trader-backend/internal/leader/manager"
+ analyzer2 "github.com/ansg191/ibd-trader-backend/internal/worker/analyzer"
+ "github.com/ansg191/ibd-trader-backend/internal/worker/auth"
+ "github.com/ansg191/ibd-trader-backend/internal/worker/scraper"
+
+ "github.com/redis/go-redis/v9"
+ "golang.org/x/sync/errgroup"
+)
+
+const (
+ HeartbeatInterval = 5 * time.Second
+ HeartbeatTTL = 30 * time.Second
+)
+
+func StartWorker(
+ ctx context.Context,
+ ibdClient *ibd.Client,
+ client *redis.Client,
+ db database.TransactionExecutor,
+ kms keys.KeyManagementService,
+ a analyzer.Analyzer,
+) error {
+ // Get the worker name.
+ name, err := workerName()
+ if err != nil {
+ return err
+ }
+ slog.InfoContext(ctx, "Starting worker", "worker", name)
+
+ g, ctx := errgroup.WithContext(ctx)
+
+ g.Go(func() error {
+ return workerRegistrationLoop(ctx, client, name)
+ })
+ g.Go(func() error {
+ return scraper.RunScraper(ctx, client, ibdClient, db, name)
+ })
+ g.Go(func() error {
+ return auth.RunAuthScraper(ctx, ibdClient, client, db, kms, name)
+ })
+ g.Go(func() error {
+ return analyzer2.RunAnalyzer(ctx, client, a, db, name)
+ })
+
+ return g.Wait()
+}
+
+func workerRegistrationLoop(ctx context.Context, client *redis.Client, name string) error {
+ sendHeartbeat(ctx, client, name)
+
+ ticker := time.NewTicker(HeartbeatInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ sendHeartbeat(ctx, client, name)
+ case <-ctx.Done():
+ removeWorker(ctx, client, name)
+ return ctx.Err()
+ }
+ }
+}
+
+// sendHeartbeat sends a heartbeat for the worker.
+// It ensures that the worker is in the active workers set and its heartbeat exists.
+func sendHeartbeat(ctx context.Context, client *redis.Client, name string) {
+ ctx, cancel := context.WithTimeout(ctx, HeartbeatInterval)
+ defer cancel()
+
+ // Add the worker to the active workers set.
+ if err := client.SAdd(ctx, manager.ActiveWorkersSet, name).Err(); err != nil {
+ slog.ErrorContext(ctx,
+ "Unable to add worker to active workers set",
+ "worker", name,
+ "error", err,
+ )
+ return
+ }
+
+ // Set the worker's heartbeat.
+ heartbeatKey := manager.WorkerHeartbeatKey(name)
+ if err := client.Set(ctx, heartbeatKey, time.Now().Unix(), HeartbeatTTL).Err(); err != nil {
+ slog.ErrorContext(ctx,
+ "Unable to set worker heartbeat",
+ "worker", name,
+ "error", err,
+ )
+ return
+ }
+}
+
+// removeWorker removes the worker from the active workers set.
+func removeWorker(ctx context.Context, client *redis.Client, name string) {
+ if ctx.Err() != nil {
+ // If the context is canceled, create a new uncanceled context.
+ ctx = context.WithoutCancel(ctx)
+ }
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ // Remove the worker from the active workers set.
+ if err := client.SRem(ctx, manager.ActiveWorkersSet, name).Err(); err != nil {
+ slog.ErrorContext(ctx,
+ "Unable to remove worker from active workers set",
+ "worker", name,
+ "error", err,
+ )
+ return
+ }
+
+ // Remove the worker's heartbeat.
+ heartbeatKey := manager.WorkerHeartbeatKey(name)
+ if err := client.Del(ctx, heartbeatKey).Err(); err != nil {
+ slog.ErrorContext(ctx,
+ "Unable to remove worker heartbeat",
+ "worker", name,
+ "error", err,
+ )
+ return
+ }
+}
+
+func workerName() (string, error) {
+ hostname, err := os.Hostname()
+ if err != nil {
+ return "", err
+ }
+
+ bytes := make([]byte, 12)
+ if _, err = io.ReadFull(rand.Reader, bytes); err != nil {
+ return "", err
+ }
+
+ return hostname + "-" + base64.URLEncoding.EncodeToString(bytes), nil
+}