feat: server bones and initial github apps setup support

This commit is contained in:
Derrick Hammer 2024-02-10 23:50:46 -05:00
parent 99727e8614
commit 4456b54550
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
17 changed files with 872 additions and 1 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 LumeWeb
Copyright (c) 2024 Hammer Technologies LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

72
api/app.go Normal file
View File

@ -0,0 +1,72 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"gorm.io/gorm"
)
type manifest struct {
Name string `json:"name"`
Url string `json:"url"`
HookAttributes hookAttributes `json:"hook_attributes"`
Public bool `json:"public"`
RedirectURL string `json:"redirect_url"`
Version string `json:"version"`
DefaultPermissions permissions `json:"default_permissions"`
}
type hookAttributes struct {
URL string `json:"url"`
}
type permissions struct {
Issues string `json:"issues"`
Metadata string `json:"metadata"`
PullRequests string `json:"pull_requests"`
}
type settingsApi struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
type appSecrets struct {
PrivateKey string `json:"private_key"`
Code string `json:"code"`
}
func newApp() appSecrets {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
// Marshal the private key into PKCS#1 ASN.1 DER encoded form
pkcs1Bytes := x509.MarshalPKCS1PrivateKey(privateKey)
// Create a PEM block with the private key
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: pkcs1Bytes,
}
return appSecrets{
PrivateKey: string(pem.EncodeToMemory(privateKeyPEM)),
Code: generateTempCode(),
}
}
func generateTempCode() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return hex.EncodeToString(bytes)
}

43
api/cookie.go Normal file
View File

@ -0,0 +1,43 @@
package api
import (
"net/http"
"time"
)
func deleteCookie(w http.ResponseWriter, name string) {
cookie := http.Cookie{
Name: name,
Path: "/",
MaxAge: -1,
}
http.SetCookie(w, &cookie)
}
func setAuthCookie(jwt string, domain string, w http.ResponseWriter) {
setCookie(w, AuthCookieName, domain, jwt, int(time.Hour.Seconds()), http.SameSiteNoneMode)
}
func setCookie(w http.ResponseWriter, name string, domain string, value string, maxAge int, sameSite http.SameSite) {
cookie := http.Cookie{
Name: name,
Domain: domain,
Value: value,
Path: "/",
HttpOnly: true,
MaxAge: maxAge,
Secure: true,
SameSite: sameSite,
}
http.SetCookie(w, &cookie)
}
func getCookie(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
return ""
}
return cookie.Value
}

114
api/middleware.go Normal file
View File

@ -0,0 +1,114 @@
package api
import (
"code.gitea.io/sdk/gitea"
"context"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/zap"
"net/http"
"strings"
)
const AUTHED_CONTEXT_KEY = "authed"
const REDIRECT_AFTER_AUTH = "redirect-after-auth"
const AuthCookieName = "auth-token"
func findAuthToken(r *http.Request) string {
authHeader := parseAuthTokenHeader(r.Header)
if authHeader != "" {
return authHeader
}
cookie := getCookie(r, AuthCookieName)
if cookie != "" {
return cookie
}
return r.FormValue(AuthCookieName)
}
func giteaOauthVerifyMiddleware(cfg *config.Config) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := findAuthToken(r)
if token == "" {
addAuthStatusToRequestServ(false, r, w, next)
return
}
client, err := getClient(ClientParams{
Config: cfg,
AuthToken: token,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _, err = client.AdminListUsers(gitea.AdminListUsersOptions{})
if err != nil {
addAuthStatusToRequestServ(false, r, w, next)
return
}
addAuthStatusToRequestServ(true, r, w, next)
})
}
}
func requireAuthMiddleware(cfg *config.Config) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := getAuthedStatusFromRequest(r)
if !status {
setCookie(w, REDIRECT_AFTER_AUTH, cfg.Domain, r.Referer(), 0, http.SameSiteLaxMode)
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
}
func loggingMiddleware(logger *zap.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do stuff here
logger.Debug("Request", zap.String("method", r.Method), zap.String("url", r.RequestURI))
// Call the next handler, which can be another middleware in the chain, or the final handler.
next.ServeHTTP(w, r)
})
}
}
func addAuthStatusToRequestServ(status bool, r *http.Request, w http.ResponseWriter, next http.Handler) {
ctx := context.WithValue(r.Context(), AUTHED_CONTEXT_KEY, status)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
func parseAuthTokenHeader(headers http.Header) string {
authHeader := headers.Get("Authorization")
if authHeader == "" {
return ""
}
authHeader = strings.TrimPrefix(authHeader, "Bearer ")
return authHeader
}
func getAuthedStatusFromRequest(r *http.Request) bool {
authed, ok := r.Context().Value(AUTHED_CONTEXT_KEY).(bool)
if !ok {
return false
}
return authed
}

34
api/oauth.go Normal file
View File

@ -0,0 +1,34 @@
package api
import (
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
type oauth struct {
cfg *config.Config
logger *zap.Logger
}
func newOauth(cfg *config.Config, logger *zap.Logger) *oauth {
return &oauth{cfg: cfg, logger: logger}
}
func (o oauth) config() *oauth2.Config {
return &oauth2.Config{
ClientID: o.cfg.Oauth.ClientId,
ClientSecret: o.cfg.Oauth.ClientSecret,
Scopes: []string{"admin"},
RedirectURL: fmt.Sprintf("https://%s/setup/callback", o.cfg.Domain),
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", o.cfg.GiteaUrl),
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", o.cfg.GiteaUrl),
},
}
}
func (o oauth) authUrl() string {
return o.config().AuthCodeURL("state")
}

82
api/route_manifests.go Normal file
View File

@ -0,0 +1,82 @@
package api
import (
"encoding/json"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
)
type manifests struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
type createdApp struct {
Id uint `json:"id"`
PEM string `json:"pem"`
WebhookSecret string `json:"webhook_secret"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
HTMLUrl string `json:"html_url"`
}
func newManifests(config *config.Config, db *gorm.DB, logger *zap.Logger) *manifests {
return &manifests{
config: config,
db: db,
logger: logger,
}
}
func (m *manifests) handlerConversion(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
code := vars["code"]
if len(code) == 0 {
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
appRecord := &model.Apps{Code: code}
if err := m.db.First(appRecord).Error; err != nil {
m.logger.Error("App not found", zap.Error(err))
http.Error(w, "App not found", http.StatusNotFound)
return
}
app := createdApp{
Id: appRecord.ID,
PEM: appRecord.PrivateKey,
ClientId: "",
ClientSecret: "",
WebhookSecret: appRecord.WebhookSecret,
HTMLUrl: fmt.Sprintf("https://%s/apps/%s", m.config.Domain, appRecord.Name),
}
appRecord.Code = generateTempCode()
tx := m.db.Save(appRecord)
if tx.Error != nil {
m.logger.Error("Error updating app", zap.Error(tx.Error))
http.Error(w, "Error updating app", http.StatusInternalServerError)
return
}
appData, err := json.Marshal(app)
if err != nil {
m.logger.Error("Error marshalling app", zap.Error(err))
http.Error(w, "Error marshalling app", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write(appData)
}

18
api/router.go Normal file
View File

@ -0,0 +1,18 @@
package api
import (
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/fx"
)
type RouterParams struct {
fx.In
Config *config.Config
}
func NewRouter(params RouterParams) *mux.Router {
r := mux.NewRouter()
return r
}

62
api/routes.go Normal file
View File

@ -0,0 +1,62 @@
package api
import (
"code.gitea.io/sdk/gitea"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(r *mux.Router, cfg *config.Config, db *gorm.DB, logger *zap.Logger) {
r.Use(loggingMiddleware(logger))
setupRouter := r.PathPrefix("/setup").Subrouter()
setupRouter.Use(giteaOauthVerifyMiddleware(cfg))
setupApi := newSetupApi(cfg, logger, newOauth(cfg, logger))
setupRouter.HandleFunc("", setupApi.setupHandler).Methods("GET")
setupRouter.HandleFunc("/callback", setupApi.callbackHandler).Methods("GET")
settingsRouter := r.PathPrefix("/settings").Subrouter()
settingsRouter.Use(giteaOauthVerifyMiddleware(cfg))
settingsRouter.Use(requireAuthMiddleware(cfg))
settingsApi := newSettingsApi(cfg, db, logger)
settingsRouter.HandleFunc("/apps/new", settingsApi.handlerNewApp).Methods("POST")
manifestApi := newManifests(cfg, db, logger)
manifestsRouter := r.PathPrefix("/api/v3/app-manifests").Subrouter()
manifestsRouter.HandleFunc("/{code}/conversions", manifestApi.handlerConversion).Methods("POST")
appApi := newAppApi(cfg, logger)
appRouter := r.PathPrefix("/apps").Subrouter()
appRouter.Use(giteaOauthVerifyMiddleware(cfg))
appRouter.Use(requireAuthMiddleware(cfg))
appRouter.HandleFunc("/{app}/installations/new", appApi.handlerNewAppInstall).Methods("GET")
}
type ClientParams struct {
Config *config.Config
AuthToken string
}
func getClient(params ClientParams) (*gitea.Client, error) {
options := make([]gitea.ClientOption, 0)
if len(params.AuthToken) > 0 {
options = append(options, gitea.SetToken(params.AuthToken))
}
client, err := gitea.NewClient(params.Config.GiteaUrl, options...)
if err != nil {
return nil, err
}
return client, nil
}

22
api/routes_app.go Normal file
View File

@ -0,0 +1,22 @@
package api
import (
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"net/http"
)
type appApi struct {
config *config.Config
logger *zap.Logger
}
func newAppApi(cfg *config.Config, logger *zap.Logger) *appApi {
return &appApi{config: cfg, logger: logger}
}
func (a *appApi) handlerNewAppInstall(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("App installations are not needed on this proxy. All webhooks are broadcasted to all registered apps."))
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "text/plain")
}

71
api/routes_settings.go Normal file
View File

@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
"net/url"
)
func newSettingsApi(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *settingsApi {
return &settingsApi{config: cfg, db: db, logger: logger}
}
func (s settingsApi) handlerNewApp(w http.ResponseWriter, r *http.Request) {
manifestData := r.FormValue("manifest")
var manifestObj manifest
err := json.Unmarshal([]byte(manifestData), &manifestObj)
if err != nil {
http.Error(w, "Failed to parse manifest", http.StatusBadRequest)
return
}
appData := newApp()
appRecord := &model.Apps{
Name: manifestObj.Name,
Url: manifestObj.Url,
WebhookUrl: manifestObj.HookAttributes.URL,
Code: generateTempCode(),
WebhookSecret: generateTempCode(),
PrivateKey: appData.PrivateKey,
}
tx := s.db.Create(appRecord)
if tx.Error != nil {
s.logger.Error("Error creating app", zap.Error(tx.Error))
http.Error(w, "Error creating app", http.StatusInternalServerError)
return
}
if len(manifestObj.RedirectURL) == 0 {
s.logger.Error("Redirect URL is required")
http.Error(w, "Redirect URL is required", http.StatusBadRequest)
return
}
redirectUrl, err := url.Parse(manifestObj.RedirectURL)
if err != nil {
s.logger.Error("Error parsing redirect URL", zap.Error(err))
http.Error(w, "Error parsing redirect URL", http.StatusInternalServerError)
return
}
query := redirectUrl.Query()
query.Add("code", appRecord.Code)
if r.URL.Query().Get("state") != "" {
query.Add("state", r.URL.Query().Get("state"))
}
redirectUrl.RawQuery = query.Encode()
http.Redirect(w, r, redirectUrl.String(), http.StatusFound)
}

59
api/routes_setup.go Normal file
View File

@ -0,0 +1,59 @@
package api
import (
"errors"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"net/http"
)
type setupApi struct {
config *config.Config
logger *zap.Logger
oauth *oauth
}
func newSetupApi(config *config.Config, logger *zap.Logger, oauth *oauth) *setupApi {
return &setupApi{config: config, logger: logger, oauth: oauth}
}
func (s setupApi) setupHandler(w http.ResponseWriter, r *http.Request) {
status := getAuthedStatusFromRequest(r)
if status {
redirectCookie := getCookie(r, REDIRECT_AFTER_AUTH)
if redirectCookie != "" {
deleteCookie(w, REDIRECT_AFTER_AUTH)
http.Redirect(w, r, redirectCookie, http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Setup is complete, you are authorized to use the proxy."))
return
}
http.Redirect(w, r, s.oauth.authUrl(), http.StatusFound)
}
func (s setupApi) callbackHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
http.Error(w, errors.Join(errors.New("Error authorizing with Gitea: "), errors.New(r.URL.Query().Get("error"))).Error(), http.StatusBadRequest)
}
code := r.URL.Query().Get("code")
if len(code) == 0 {
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
token, err := s.oauth.config().Exchange(r.Context(), code)
if err != nil {
http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
setAuthCookie(token.AccessToken, s.config.Domain, w)
http.Redirect(w, r, "/setup", http.StatusFound)
}

32
cmd/proxy/main.go Normal file
View File

@ -0,0 +1,32 @@
package main
import (
"git.lumeweb.com/LumeWeb/gitea-github-proxy/api"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"net/http"
)
func main() {
logger, _ := zap.NewDevelopment()
fx.New(
fx.Supply(logger),
fx.WithLogger(func(logger *zap.Logger) fxevent.Logger {
log := &fxevent.ZapLogger{Logger: logger}
log.UseLogLevel(zapcore.InfoLevel)
log.UseErrorLevel(zapcore.ErrorLevel)
return log
}),
config.Module,
db.Module,
fx.Provide(api.NewRouter),
fx.Provide(NewServer),
fx.Invoke(api.SetupRoutes),
fx.Invoke(func(*http.Server) {}),
).Run()
}

48
cmd/proxy/server.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"context"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/fx"
"go.uber.org/zap"
"net"
"net/http"
)
type ServerParams struct {
fx.In
Logger *zap.Logger
Router *mux.Router
Config *config.Config
}
func NewServer(lc fx.Lifecycle, params ServerParams) (*http.Server, error) {
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", params.Config.Host, params.Config.Port),
Handler: params.Router,
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}
go func() {
err := srv.Serve(ln)
if err != nil {
params.Logger.Fatal("Failed to serve", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv, nil
}

104
config/config.go Normal file
View File

@ -0,0 +1,104 @@
package config
import (
"encoding"
"encoding/base64"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"go.uber.org/fx"
"go.uber.org/zap"
)
import (
"crypto/ed25519"
)
type PrivateKey ed25519.PrivateKey
var (
_ encoding.TextUnmarshaler = (*PrivateKey)(nil)
)
func (p *PrivateKey) UnmarshalText(text []byte) error {
dec, err := base64.StdEncoding.DecodeString(string(text))
if err != nil {
return err
}
*p = dec
return nil
}
type Config struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
GiteaUrl string `mapstructure:"gitea_url"`
DbPath string `mapstructure:"db_path"`
Oauth OauthConfig
JwtPrivateKey PrivateKey
Domain string `mapstructure:"domain"`
}
type OauthConfig struct {
Authorization string `mapstructure:"authorization"`
Token string `mapstructure:"token"`
ClientId string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
RefreshToken string `mapstructure:"refresh_token"`
}
var Module = fx.Module("config",
fx.Options(
fx.Provide(NewConfig),
),
)
func NewConfig(logger *zap.Logger) *Config {
c := &Config{}
cfg := viper.New()
_, sk, err := ed25519.GenerateKey(nil)
if err != nil {
logger.Fatal("Error generating private key", zap.Error(err))
}
// Set the default values
cfg.SetDefault("host", "localhost")
cfg.SetDefault("port", 8080)
cfg.SetDefault("gitea_url", "")
cfg.SetDefault("db_path", "data.db")
cfg.SetDefault("jwt_private_key", base64.StdEncoding.EncodeToString(sk))
cfg.SetDefault("oauth.client_id", "")
cfg.SetDefault("oauth.client_secret", "")
cfg.AddConfigPath(".")
cfg.AddConfigPath("/etc/gitea-github-proxy")
cfg.SetConfigName("config")
cfg.SetConfigType("yaml")
err = cfg.ReadInConfig()
if err != nil {
err = cfg.SafeWriteConfig()
if err != nil {
logger.Fatal("Error writing config file", zap.Error(err))
}
}
err = cfg.Unmarshal(c)
if err != nil {
logger.Fatal("Error unmarshalling config", zap.Error(err))
}
err = cfg.UnmarshalKey("jwt_private_key", &c.JwtPrivateKey, viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()))
if err != nil {
logger.Fatal("Error unmarshalling jwtPrivateKey", zap.Error(err))
}
if len(c.GiteaUrl) == 0 {
logger.Fatal("Gitea URL is required")
}
return c
}

47
db/db.go Normal file
View File

@ -0,0 +1,47 @@
package db
import (
"context"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"go.uber.org/fx"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type DatabaseParams struct {
fx.In
Config *config.Config
}
var Module = fx.Module("db",
fx.Options(
fx.Provide(NewDatabase),
),
)
func NewDatabase(lc fx.Lifecycle, params DatabaseParams) *gorm.DB {
db, err := gorm.Open(sqlite.Open(params.Config.DbPath), &gorm.Config{})
if err != nil {
panic(err)
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return db.AutoMigrate(
&model.Apps{},
)
},
OnStop: func(ctx context.Context) error {
sqlDb, err := db.DB()
if err != nil {
return err
}
return sqlDb.Close()
},
})
return db
}

14
db/model/apps.go Normal file
View File

@ -0,0 +1,14 @@
package model
import "gorm.io/gorm"
type Apps struct {
gorm.Model
Name string
Url string
WebhookUrl string
WebhookSecret string
Code string `gorm:"uniqueIndex"`
PrivateKey string
}

49
go.mod Normal file
View File

@ -0,0 +1,49 @@
module git.lumeweb.com/LumeWeb/gitea-github-proxy
go 1.21
require (
code.gitea.io/sdk/gitea v0.17.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/gorilla/mux v1.8.1
github.com/spf13/viper v1.18.2
go.uber.org/fx v1.20.1
go.uber.org/zap v1.23.0
golang.org/x/oauth2 v0.17.0
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.7
)
require (
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // 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
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)