diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 475814e..f155964 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,28 +7,30 @@ on: - master jobs: + scan: + name: Secret scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: TruffleHog OSS + uses: trufflesecurity/trufflehog@main + with: + extra_args: --debug --only-verified + ci-backend: name: Backend runs-on: ubuntu-latest timeout-minutes: 20 - container: golang:1.21-bookworm + container: golang:1-bookworm defaults: run: working-directory: ./backend services: - db: - image: postgres:15-bookworm - ports: - - 5432:5432 - env: - POSTGRES_PASSWORD: password - POSTGRES_USER: postgres - POSTGRES_DB: conf - options: >- - --health-cmd "pg_isready -U postgres -d conf" - --health-interval 10s - --health-timeout 5s - --health-retries 5 smtp: image: marlonb/mailcrab:latest ports: @@ -36,16 +38,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: TruffleHog OSS - uses: trufflesecurity/trufflehog@main - with: - path: ./ - base: ${{ github.event.repository.default_branch }} - head: HEAD - extra_args: --debug --only-verified - name: Build run: go build -buildvcs=false . @@ -74,16 +66,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: TruffleHog OSS - uses: trufflesecurity/trufflehog@main - with: - path: ./ - base: ${{ github.event.repository.default_branch }} - head: HEAD - extra_args: --debug --only-verified - name: Setup pnpm uses: pnpm/action-setup@v2 diff --git a/backend/Dockerfile b/backend/Dockerfile index 6ba7495..aed5685 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,9 +10,7 @@ FROM debian:bookworm-slim AS runtime WORKDIR /app -RUN apt-get update && \ - apt-get install -y curl && \ - apt-get clean && \ +RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /app/csv && \ mkdir -p /data @@ -22,7 +20,7 @@ ARG PORT=8080 COPY --from=build /app/ . HEALTHCHECK --interval=60s --timeout=40s \ - CMD curl -f http://localhost:8080/ping || exit 1 + CMD /app/conf-backend --port ${PORT} EXPOSE ${PORT} diff --git a/backend/administrator/administrator.go b/backend/administrator/administrator.go new file mode 100644 index 0000000..2b519d7 --- /dev/null +++ b/backend/administrator/administrator.go @@ -0,0 +1,60 @@ +package administrator + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "fmt" + + "conf/administrator/jwt" + "github.com/pquerna/otp/totp" +) + +type Administrator struct { + Username string `yaml:"username"` + HashedPassword string `yaml:"hashed_password"` + TotpSecret string `yaml:"totp_secret"` +} + +func GenerateSecret(username string) (secret string, url string, err error) { + generate, err := totp.Generate(totp.GenerateOpts{Issuer: "teknumconf", AccountName: username, Rand: rand.Reader}) + if err != nil { + return "", "", err + } + + return generate.Secret(), generate.URL(), nil +} + +type AdministratorDomain struct { + jwt *jwt.JsonWebToken + administrators []Administrator +} + +func NewAdministratorDomain(administrators []Administrator) (*AdministratorDomain, error) { + // Generate ed25519 key pairs for access and refresh tokens + accessPublicKey, accessPrivateKey, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, fmt.Errorf("generating fresh access key pair: %w", err) + } + + refreshPublicKey, refreshPrivateKey, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, fmt.Errorf("generating fresh refresh key pair: %w", err) + } + + var randomIssuer = make([]byte, 18) + _, _ = rand.Read(randomIssuer) + + var randomSubject = make([]byte, 16) + _, _ = rand.Read(randomSubject) + + var randomAudience = make([]byte, 32) + _, _ = rand.Read(randomAudience) + + authJwt := jwt.NewJwt(accessPrivateKey, accessPublicKey, refreshPrivateKey, refreshPublicKey, hex.EncodeToString(randomIssuer), hex.EncodeToString(randomSubject), hex.EncodeToString(randomAudience)) + + return &AdministratorDomain{ + jwt: authJwt, + administrators: administrators, + }, nil +} diff --git a/backend/administrator/authenticate.go b/backend/administrator/authenticate.go new file mode 100644 index 0000000..d3387e2 --- /dev/null +++ b/backend/administrator/authenticate.go @@ -0,0 +1,55 @@ +package administrator + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + + "github.com/getsentry/sentry-go" + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" +) + +func (a *AdministratorDomain) Authenticate(ctx context.Context, username string, plainPassword string, otpCode string) (string, bool, error) { + span := sentry.StartSpan(ctx, "administrator.authenticate", sentry.WithTransactionName("Authenticate")) + defer span.Finish() + + var administrator Administrator + for _, adm := range a.administrators { + if adm.Username == username { + administrator = adm + break + } + } + + if administrator.Username == "" { + return "", false, nil + } + + hashedPassword, err := hex.DecodeString(administrator.HashedPassword) + if err != nil { + return "", false, fmt.Errorf("invalid hex string") + } + + err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(plainPassword)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return "", false, nil + } + + return "", false, fmt.Errorf("password: %w", err) + } + + ok := totp.Validate(otpCode, administrator.TotpSecret) + if !ok { + return "", false, nil + } + + token, err := a.jwt.Sign(username) + if err != nil { + return "", false, fmt.Errorf("signing token: %w", err) + } + + return token, true, nil +} diff --git a/backend/administrator/jwt/jwt.go b/backend/administrator/jwt/jwt.go new file mode 100644 index 0000000..4685bac --- /dev/null +++ b/backend/administrator/jwt/jwt.go @@ -0,0 +1,137 @@ +package jwt + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +type JsonWebToken struct { + accessPrivateKey ed25519.PrivateKey + accessPublicKey ed25519.PublicKey + refreshPrivateKey ed25519.PrivateKey + refreshPublicKey ed25519.PublicKey + issuer string + subject string + audience string +} + +func NewJwt(accessPrivateKey []byte, accessPublicKey []byte, refreshPrivateKey []byte, refreshPublicKey []byte, issuer string, subject string, audience string) *JsonWebToken { + return &JsonWebToken{ + accessPrivateKey: accessPrivateKey, + accessPublicKey: accessPublicKey, + refreshPrivateKey: refreshPrivateKey, + refreshPublicKey: refreshPublicKey, + issuer: issuer, + subject: subject, + audience: audience, + } +} + +func (j *JsonWebToken) Sign(userId string) (accessToken string, err error) { + accessRandId := make([]byte, 32) + _, _ = rand.Read(accessRandId) + + accessClaims := jwt.MapClaims{ + "iss": j.issuer, + "sub": j.subject, + "aud": j.audience, + "exp": time.Now().Add(time.Hour * 1).Unix(), + "nbf": time.Now().Unix(), + "iat": time.Now().Unix(), + "jti": string(accessRandId), + "uid": userId, + } + + accessToken, err = jwt.NewWithClaims(jwt.SigningMethodEdDSA, accessClaims).SignedString(j.accessPrivateKey) + if err != nil { + return "", fmt.Errorf("failed to sign access token: %w", err) + } + + return accessToken, nil +} + +var ErrInvalidSigningMethod = errors.New("invalid signing method") +var ErrExpired = errors.New("token expired") +var ErrInvalid = errors.New("token invalid") +var ErrClaims = errors.New("token claims invalid") + +func (j *JsonWebToken) VerifyAccessToken(token string) (userId string, err error) { + if token == "" { + return "", ErrInvalid + } + + parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + _, ok := t.Method.(*jwt.SigningMethodEd25519) + if !ok { + return nil, ErrInvalidSigningMethod + } + return j.accessPublicKey, nil + }) + if err != nil { + if parsedToken != nil && !parsedToken.Valid { + // Check if the error is a type of jwt.ValidationError + validationError, ok := err.(*jwt.ValidationError) + if ok { + if validationError.Errors&jwt.ValidationErrorExpired != 0 { + return "", ErrExpired + } + + if validationError.Errors&jwt.ValidationErrorSignatureInvalid != 0 { + return "", ErrInvalid + } + + if validationError.Errors&jwt.ValidationErrorClaimsInvalid != 0 { + return "", ErrClaims + } + + return "", fmt.Errorf("failed to parse access token: %w", err) + } + + return "", fmt.Errorf("non-validation error during parsing token: %w", err) + } + + return "", fmt.Errorf("token is valid or parsedToken is not nil: %w", err) + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok { + return "", ErrClaims + } + + if !claims.VerifyAudience(j.audience, true) { + return "", ErrInvalid + } + + if !claims.VerifyExpiresAt(time.Now().Unix(), true) { + return "", ErrExpired + } + + if !claims.VerifyIssuer(j.issuer, true) { + return "", ErrInvalid + } + + if !claims.VerifyNotBefore(time.Now().Unix(), true) { + return "", ErrInvalid + } + + jwtId, ok := claims["jti"].(string) + if !ok { + return "", ErrClaims + } + + if jwtId == "" { + return "", ErrClaims + } + + userId, ok = claims["uid"].(string) + if !ok { + return "", ErrClaims + } + + return userId, nil +} diff --git a/backend/administrator/jwt/jwt_test.go b/backend/administrator/jwt/jwt_test.go new file mode 100644 index 0000000..db867e9 --- /dev/null +++ b/backend/administrator/jwt/jwt_test.go @@ -0,0 +1,74 @@ +package jwt_test + +import ( + "crypto/ed25519" + "errors" + "log" + "os" + "testing" + + "conf/administrator/jwt" +) + +var authJwt *jwt.JsonWebToken + +func TestMain(m *testing.M) { + // Generate ed25519 key pairs for access and refresh tokens + accessPublicKey, accessPrivateKey, err := ed25519.GenerateKey(nil) + if err != nil { + log.Fatalf("failed to generate access key pair: %v", err) + } + + refreshPublicKey, refreshPrivateKey, err := ed25519.GenerateKey(nil) + if err != nil { + log.Fatalf("failed to generate refresh key pair: %v", err) + } + + authJwt = jwt.NewJwt(accessPrivateKey, accessPublicKey, refreshPrivateKey, refreshPublicKey, "kodiiing", "user", "kodiiing") + + exitCode := m.Run() + + os.Exit(exitCode) +} + +func TestSign(t *testing.T) { + accessToken, err := authJwt.Sign("john") + if err != nil { + t.Errorf("failed to sign access token: %v", err) + } + + if accessToken == "" { + t.Error("access token is empty") + } +} + +func TestVerify(t *testing.T) { + accessToken, err := authJwt.Sign("john") + if err != nil { + t.Errorf("failed to sign access token: %v", err) + } + + if accessToken == "" { + t.Error("access token is empty") + } + + accessId, err := authJwt.VerifyAccessToken(accessToken) + if err != nil { + t.Errorf("failed to verify access token: %v", err) + } + + if accessId != "john" { + t.Errorf("access id is not 'john': %v", accessId) + } +} + +func TestVerifyEmpty(t *testing.T) { + accessId, err := authJwt.VerifyAccessToken("") + if err == nil { + t.Errorf("access token is valid: %v", accessId) + } + + if !errors.Is(err, jwt.ErrInvalid) { + t.Errorf("error is not ErrInvalid: %v", err) + } +} diff --git a/backend/administrator/validate.go b/backend/administrator/validate.go new file mode 100644 index 0000000..440d038 --- /dev/null +++ b/backend/administrator/validate.go @@ -0,0 +1,31 @@ +package administrator + +import ( + "context" + + "github.com/getsentry/sentry-go" +) + +func (a *AdministratorDomain) Validate(ctx context.Context, token string) (Administrator, bool, error) { + span := sentry.StartSpan(ctx, "administrator.validate", sentry.WithTransactionName("Validate")) + defer span.Finish() + + if token == "" { + return Administrator{}, false, nil + } + + username, err := a.jwt.VerifyAccessToken(token) + if err != nil { + return Administrator{}, false, nil + } + + var administrator Administrator + for _, adm := range a.administrators { + if adm.Username == username { + administrator = adm + break + } + } + + return administrator, true, nil +} diff --git a/backend/blast_mail_handler_action.go b/backend/blast_mail_handler_action.go new file mode 100644 index 0000000..8177e85 --- /dev/null +++ b/backend/blast_mail_handler_action.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "os" + + "conf/mailer" + "conf/user" + "github.com/flowchartsman/handlebars/v3" + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" +) + +func BlastMailHandlerAction(cCtx *cli.Context) error { + config, err := GetConfig(cCtx.String("config-file-path")) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + err = sentry.Init(sentry.ClientOptions{ + Dsn: "", + Debug: config.Environment != "production", + AttachStacktrace: true, + SampleRate: 1.0, + Release: version, + Environment: config.Environment, + DebugWriter: log.Logger, + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + if config.Environment != "production" { + log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) + } + + return event + }, + }) + if err != nil { + return fmt.Errorf("initializing Sentry: %w", err) + } + + subject := cCtx.String("subject") + plaintext := cCtx.String("plaintext-body") + htmlBody := cCtx.String("html-body") + mailCsv := cCtx.String("recipients") + singleRecipient := cCtx.String("single-recipient") + + if subject == "" { + log.Fatal().Msg("Subject is required") + } + if plaintext == "" { + log.Fatal().Msg("Plaintext template is required") + } + if htmlBody == "" { + log.Fatal().Msg("Html template is required") + } + if mailCsv == "" && singleRecipient == "" { + log.Fatal().Msg("Recipient is required") + } + + plaintextContent, err := os.ReadFile(plaintext) + if err != nil { + log.Fatal().Err(err).Msg("failed to read plaintext template") + } + + plaintextTemplate, err := handlebars.Parse(string(plaintextContent)) + if err != nil { + log.Fatal().Err(err).Msg("failed to parse plaintext template") + } + + htmlContent, err := os.ReadFile(htmlBody) + if err != nil { + log.Fatal().Err(err).Msg("failed to read html template") + } + + htmlTemplate, err := handlebars.Parse(string(htmlContent)) + if err != nil { + log.Fatal().Err(err).Msg("failed to parse html template") + } + + var userList []user.User + + if mailCsv != "" { + emailList, err := os.ReadFile(mailCsv) + if err != nil { + log.Fatal().Err(err).Msg("failed to read email list") + } + + userList, err = csvReader(string(emailList), true) + if err != nil { + log.Fatal().Err(err).Msg("failed to parse email list") + } + } else { + userList = append(userList, user.User{ + Email: singleRecipient, + }) + } + + mailSender := mailer.NewMailSender(&mailer.MailConfiguration{ + SmtpHostname: config.Mailer.Hostname, + SmtpPort: config.Mailer.Port, + SmtpFrom: config.Mailer.From, + SmtpPassword: config.Mailer.Password, + }) + + for _, userItem := range userList { + mail := &mailer.Mail{ + RecipientName: userItem.Name, + RecipientEmail: userItem.Email, + Subject: subject, + PlainTextBody: string(plaintextContent), + HtmlBody: string(htmlContent), + } + + // Parse email template information + emailTemplate := map[string]any{ + "ticketPrice": config.EmailTemplate.TicketPrice, + "ticketStudentCollegePrice": config.EmailTemplate.TicketStudentCollegePrice, + "ticketStudentHighSchoolPrice": config.EmailTemplate.TicketStudentHighSchoolPrice, + "ticketStudentCollegeDiscount": config.EmailTemplate.TicketStudentCollegeDiscount, + "ticketStudentHighSchoolDiscount": config.EmailTemplate.TicketStudentHighSchoolDiscount, + "percentageStudentCollegeDiscount": config.EmailTemplate.PercentageStudentCollegeDiscount, + "percentageStudentHighSchoolDiscount": config.EmailTemplate.PercentageStudentHighSchoolDiscount, + "conferenceEmail": config.EmailTemplate.ConferenceEmail, + "bankAccounts": config.EmailTemplate.BankAccounts, + } + // Execute handlebars template only if userItem.Name is not empty + if userItem.Name != "" { + emailTemplate["name"] = userItem.Name + } + + mail.PlainTextBody = plaintextTemplate.MustExec(emailTemplate) + mail.HtmlBody = htmlTemplate.MustExec(emailTemplate) + + err := mailSender.Send(cCtx.Context, mail) + if err != nil { + log.Error().Err(err).Msgf("failed to send email to %s", userItem.Email) + continue + } + + log.Info().Msgf("Sending email to %s", userItem.Email) + } + log.Info().Msg("Blasting email done") + return nil +} diff --git a/backend/config.go b/backend/config.go index 580114b..7e79d14 100644 --- a/backend/config.go +++ b/backend/config.go @@ -3,6 +3,8 @@ package main import ( "os" + "conf/administrator" + "conf/features" "dario.cat/mergo" "github.com/kelseyhightower/envconfig" "github.com/rs/zerolog/log" @@ -10,15 +12,12 @@ import ( ) type Config struct { - FeatureFlags struct { - RegistrationClosed bool `yaml:"registration_closed" envconfig:"FEATURE_REGISTRATION_CLOSED" default:"false"` - } `yaml:"feature_flags"` - Database struct { - Host string `yaml:"host" envconfig:"DB_HOST" default:"localhost"` - Port uint16 `yaml:"port" envconfig:"DB_PORT" default:"5432"` - User string `yaml:"user" envconfig:"DB_USER" default:"conference"` - Password string `yaml:"password" envconfig:"DB_PASSWORD" default:"VeryStrongPassword"` - Name string `yaml:"database" envconfig:"DB_NAME" default:"conference"` + FeatureFlags features.FeatureFlag `yaml:"feature_flags"` + Database struct { + NocoDbBaseUrl string `yaml:"nocodb_base_url" envconfig:"NOCODB_BASE_URL" default:"http://localhost:8080"` + NocoDbApiKey string `yaml:"nocodb_api_key" envconfig:"NOCODB_API_KEY" default:""` + TicketingTableId string `yaml:"ticketing_table_id" envconfig:"TICKETING_TABLE_ID"` + UserTableId string `yaml:"user_table_id" envconfig:"USER_TABLE_ID"` } `yaml:"database"` Environment string `yaml:"environment" envconfig:"ENVIRONMENT" default:"local"` Port string `yaml:"port" envconfig:"PORT" default:"8080"` @@ -46,7 +45,8 @@ type Config struct { ConferenceEmail string `yaml:"conference_email" envconfig:"EMAIL_TEMPLATE_CONFERENCE_EMAIL"` BankAccounts string `yaml:"bank_accounts" envconfig:"EMAIL_TEMPLATE_BANK_ACCOUNTS"` // List of bank accounts for payments in HTML format } `yaml:"email_template"` - ValidateTicketKey string `yaml:"validate_payment_key" envconfig:"VALIDATE_PAYMENT_KEY"` + ValidateTicketKey string `yaml:"validate_payment_key" envconfig:"VALIDATE_PAYMENT_KEY"` + AdministratorUserMapping []administrator.Administrator `yaml:"administrator_user_mapping"` } func GetConfig(configurationFile string) (Config, error) { diff --git a/backend/configuration.example.yml b/backend/configuration.example.yml index 9d5fe3f..88c296f 100644 --- a/backend/configuration.example.yml +++ b/backend/configuration.example.yml @@ -1,5 +1,8 @@ feature_flags: - registration_closed: false + enable_registration: false + enable_payment_proof_upload: false + enable_call_for_proposal_submission: false + enable_administrator_mode: false environment: local diff --git a/backend/csv_reader.go b/backend/csv_reader.go index c213a76..e4ce391 100644 --- a/backend/csv_reader.go +++ b/backend/csv_reader.go @@ -5,9 +5,11 @@ import ( "errors" "io" "strings" + + "conf/user" ) -func csvReader(file string, mandatoryNameField bool) (users []User, err error) { +func csvReader(file string, mandatoryNameField bool) (users []user.User, err error) { r := csv.NewReader(strings.NewReader(file)) header, err := r.Read() if err != nil { @@ -31,13 +33,13 @@ func csvReader(file string, mandatoryNameField bool) (users []User, err error) { } name = m["name"] } - + if m["email"] == "" { err = errors.New("email is required") return nil, err } email = m["email"] - users = append(users, User{ + users = append(users, user.User{ Name: name, Email: email, }) diff --git a/backend/errors.go b/backend/errors.go index 9f8051f..c9ecbf5 100644 --- a/backend/errors.go +++ b/backend/errors.go @@ -1,11 +1,2 @@ package main -import "strings" - -type ValidationError struct { - Errors []string -} - -func (v ValidationError) Error() string { - return strings.Join(v.Errors, ", ") -} diff --git a/backend/features/features.go b/backend/features/features.go new file mode 100644 index 0000000..ce410f3 --- /dev/null +++ b/backend/features/features.go @@ -0,0 +1,8 @@ +package features + +type FeatureFlag struct { + EnableRegistration bool `yaml:"enable_registration" envconfig:"FEATURE_ENABLE_REGISTRATION" default:"false"` + EnablePaymentProofUpload bool `yaml:"enable_payment_proof_upload" envconfig:"FEATURE_ENABLE_PAYMENT_PROOF_UPLOAD" default:"false"` + EnableCallForProposalSubmission bool `yaml:"enable_call_for_proposal_submission" envconfig:"FEATURE_ENABLE_CALL_FOR_PROPOSAL_SUBMISSION" default:"false"` + EnableAdministratorMode bool `yaml:"enable_administrator_mode" envconfig:"FEATURE_ENABLE_ADMINISTRATOR_MODE" default:"false"` +} diff --git a/backend/go.mod b/backend/go.mod index 7294bb0..c15b807 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,69 +5,68 @@ go 1.21 require ( dario.cat/mergo v1.0.0 github.com/flowchartsman/handlebars/v3 v3.0.1 - github.com/getsentry/sentry-go v0.24.1 - github.com/google/uuid v1.3.1 - github.com/jackc/pgx/v5 v5.4.3 + github.com/getsentry/sentry-go v0.27.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.5.5 github.com/kelseyhightower/envconfig v1.4.0 - github.com/labstack/echo/v4 v4.11.1 - github.com/pressly/goose/v3 v3.15.0 - github.com/rs/zerolog v1.31.0 + github.com/pquerna/otp v1.4.0 + github.com/rs/cors v1.10.1 + github.com/rs/zerolog v1.32.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/urfave/cli/v2 v2.25.7 - gocloud.dev v0.34.0 + github.com/urfave/cli/v2 v2.27.1 + gocloud.dev v0.36.0 + golang.org/x/crypto v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/aws/aws-sdk-go v1.44.314 // indirect - github.com/aws/aws-sdk-go-v2 v1.20.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.18.32 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.13.31 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.76 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.38.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 // indirect - github.com/aws/smithy-go v1.14.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/aws/aws-sdk-go v1.49.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/wire v0.5.0 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kr/text v0.2.0 // indirect - github.com/labstack/gommon v0.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.13.0 // indirect - golang.org/x/net v0.15.0 // indirect - golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/time v0.3.0 // indirect - golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/api v0.134.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // indirect - google.golang.org/grpc v1.57.0 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/api v0.151.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect + google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.31.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index c14558e..60193d3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,84 +1,84 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o= -cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= -cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= -cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= +cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic= +cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= +cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y= -cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= -cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI= -cloud.google.com/go/storage v1.31.0/go.mod h1:81ams1PrhW16L4kF7qg+4mTq7SRs5HsbDTM0bWvrwJ0= +cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI= +cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8= +cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= +cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/aws/aws-sdk-go v1.44.314 h1:d/5Jyk/Fb+PBd/4nzQg0JuC2W4A0knrDIzBgK/ggAow= -github.com/aws/aws-sdk-go v1.44.314/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= -github.com/aws/aws-sdk-go-v2 v1.20.0 h1:INUDpYLt4oiPOJl0XwZDK2OVAVf0Rzo+MGVTv9f+gy8= -github.com/aws/aws-sdk-go-v2 v1.20.0/go.mod h1:uWOr0m0jDsiWw8nnXiqZ+YG6LdvAlGYDLLf2NmHZoy4= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11 h1:/MS8AzqYNAhhRNalOmxUvYs8VEbNGifTnzhPFdcRQkQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11/go.mod h1:va22++AdXht4ccO3kH2SHkHHYvZ2G9Utz+CXKmm2CaU= -github.com/aws/aws-sdk-go-v2/config v1.18.32 h1:tqEOvkbTxwEV7hToRcJ1xZRjcATqwDVsWbAscgRKyNI= -github.com/aws/aws-sdk-go-v2/config v1.18.32/go.mod h1:U3ZF0fQRRA4gnbn9GGvOWLoT2EzzZfAWeKwnVrm1rDc= -github.com/aws/aws-sdk-go-v2/credentials v1.13.31 h1:vJyON3lG7R8VOErpJJBclBADiWTwzcwdkQpTKx8D2sk= -github.com/aws/aws-sdk-go-v2/credentials v1.13.31/go.mod h1:T4sESjBtY2lNxLgkIASmeP57b5j7hTQqCbqG0tWnxC4= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 h1:X3H6+SU21x+76LRglk21dFRgMTJMa5QcpW+SqUf5BBg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7/go.mod h1:3we0V09SwcJBzNlnyovrR2wWJhWmVdqAsmVs4uronv8= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.76 h1:DJ1kHj0GI9BbX+XhF0kHxlzOVjcncmDUXmCvXdbfdAE= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.76/go.mod h1:/AZCdswMSgwpB2yMSFfY5H4pVeBLnCuPehdmO/r3xSM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 h1:zr/gxAZkMcvP71ZhQOcvdm8ReLjFgIXnIn0fw5AM7mo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37/go.mod h1:Pdn4j43v49Kk6+82spO3Tu5gSeQXRsxo56ePPQAvFiA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 h1:0HCMIkAkVY9KMgueD8tf4bRTUanzEYvhw7KkPXIMpO0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31/go.mod h1:fTJDMe8LOFYtqiFFFeHA+SVMAwqLhoq0kcInYoLa9Js= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 h1:+i1DOFrW3YZ3apE45tCal9+aDKK6kNEbW6Ib7e1nFxE= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38/go.mod h1:1/jLp0OgOaWIetycOmycW+vYTYgTZFPttJQRgsI1PoU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0 h1:U5yySdwt2HPo/pnQec04DImLzWORbeWML1fJiLkKruI= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0/go.mod h1:EhC/83j8/hL/UB1WmExo3gkElaja/KlmZM/gl1rTfjM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12 h1:uAiiHnWihGP2rVp64fHwzLDrswGjEjsPszwRYMiYQPU= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12/go.mod h1:fUTHpOXqRQpXvEpDPSa3zxCc2fnpW6YnBoba+eQr+Bg= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32 h1:kvN1jPHr9UffqqG3bSgZ8tx4+1zKVHz/Ktw/BwW6hX8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32/go.mod h1:QmMEM7es84EUkbYWcpnkx8i5EW2uERPfrTFeOch128Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 h1:auGDJ0aLZahF5SPvkJ6WcUuX7iQ7kyl2MamV7Tm8QBk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31/go.mod h1:3+lloe3sZuBQw1aBc5MyndvodzQlyqCZ7x1QPDHaWP4= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0 h1:Wgjft9X4W5pMeuqgPCHIQtbZ87wsgom7S5F8obreg+c= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0/go.mod h1:FWNzS4+zcWAP05IF7TDYTY1ysZAzIvogxWaDT9p8fsA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.38.1 h1:mTgFVlfQT8gikc5+/HwD8UL9jnUro5MGv8n/VEYF12I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.38.1/go.mod h1:6SOWLiobcZZshbmECRTADIRYliPL0etqFSigauQEeT0= -github.com/aws/aws-sdk-go-v2/service/sso v1.13.1 h1:DSNpSbfEgFXRV+IfEcKE5kTbqxm+MeF5WgyeRlsLnHY= -github.com/aws/aws-sdk-go-v2/service/sso v1.13.1/go.mod h1:TC9BubuFMVScIU+TLKamO6VZiYTkYoEHqlSQwAe2omw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1 h1:hd0SKLMdOL/Sl6Z0np1PX9LeH2gqNtBe0MhTedA8MGI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1/go.mod h1:XO/VcyoQ8nKyKfFW/3DMsRQXsfh/052tHTWmg3xBXRg= -github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 h1:pAOJj+80tC8sPVgSDHzMYD6KLWsaLQ1kZw31PTeORbs= -github.com/aws/aws-sdk-go-v2/service/sts v1.21.1/go.mod h1:G8SbvL0rFk4WOJroU8tKBczhsbhj2p/YY7qeJezJ3CI= -github.com/aws/smithy-go v1.14.0 h1:+X90sB94fizKjDmwb4vyl2cTTPXTE5E2G/1mjByb0io= -github.com/aws/smithy-go v1.14.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= +github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= +github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= +github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= +github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= +github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 h1:FnLf60PtjXp8ZOzQfhJVsqF0OtYKQZWQfqOLshh8YXg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7/go.mod h1:tDVvl8hyU6E9B8TrnNrZQEVkQlB8hjJwcgpPhgtlnNg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/flowchartsman/handlebars/v3 v3.0.1 h1:16YAX2vJ2X2wgliz4QoUNyOJDLmnZ7Uldg/5w49/z3A= github.com/flowchartsman/handlebars/v3 v3.0.1/go.mod h1:HGaRKxnZS8F2cteR3rusKeY+lI9fbnqwwesT+A3a18I= -github.com/getsentry/sentry-go v0.23.0 h1:dn+QRCeJv4pPt9OjVXiMcGIBIefaTJPw/h0bZWO05nE= -github.com/getsentry/sentry-go v0.23.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= -github.com/getsentry/sentry-go v0.24.1 h1:W6/0GyTy8J6ge6lVCc94WB6Gx2ZuLrgopnn9w8Hiwuk= -github.com/getsentry/sentry-go v0.24.1/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -103,79 +103,66 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -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/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE= github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= -github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= -github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM= -github.com/googleapis/enterprise-certificate-proxy v0.2.5/go.mod h1:RxW0N9901Cko1VOCW3SXCpWP+mlIEkk2tP7jnHy9a3w= +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.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= -github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= -github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= -github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= -github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= -github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.15.0 h1:6tY5aDqFknY6VZkorFGgZtWygodZQxfmmEF4rqyJW9k= -github.com/pressly/goose/v3 v3.15.0/go.mod h1:LlIo3zGccjb/YUgG+Svdb9Er14vefRdlDI7URCDrwYo= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= +github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= -github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= -github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= -github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= @@ -188,126 +175,86 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= -github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= +github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -gocloud.dev v0.34.0 h1:LzlQY+4l2cMtuNfwT2ht4+fiXwWf/NmPTnXUlLmGif4= -gocloud.dev v0.34.0/go.mod h1:psKOachbnvY3DAOPbsFVmLIErwsbWPUG2H5i65D38vE= +gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus= +gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY= -golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= -golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= +golang.org/x/time v0.4.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-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= -golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.134.0 h1:ktL4Goua+UBgoP1eL1/60LwZJqa1sIzkLmvoR3hR6Gw= -google.golang.org/api v0.134.0/go.mod h1:sjRL3UnjTx5UqNQS9EWr9N8p7xbHpy1k0XGRLCf3Spk= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.151.0 h1:FhfXLO/NFdJIzQtCqjpysWwqKk8AzGWBUhMIx67cVDU= +google.golang.org/api v0.151.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg= 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.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf h1:v5Cf4E9+6tawYrs/grq1q1hFpGtzlGFzgWHqwt6NFiU= -google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= -google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf h1:xkVZ5FdZJF4U82Q/JS+DcZA83s/GRVL+QrFMlexk9Yo= -google.golang.org/genproto/googleapis/api v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:5DZzOUPCLYL3mNkQ0ms0F3EuUNZ7py1Bqeq6sxzI7/Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf h1:guOdSPaeFgN+jEJwTo1dQ71hdBm+yKSCCKuTRkJzcVo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I= +google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg= +google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= -google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 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= @@ -328,28 +275,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= -lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= -modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= -modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ= -modernc.org/ccgo/v3 v3.16.14/go.mod h1:mPDSujUIaTNWQSG4eqKw+atqLOEbma6Ncsa94WbC9zo= -modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= -modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= -modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA= -modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/healthcheck_handler_action.go b/backend/healthcheck_handler_action.go new file mode 100644 index 0000000..8888be7 --- /dev/null +++ b/backend/healthcheck_handler_action.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/urfave/cli/v2" +) + +func HealthcheckHandlerAction(c *cli.Context) error { + port := c.String("port") + timeout := c.Duration("timeout") + if timeout == 0 { + timeout = time.Minute + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:"+port+"/api/public/ping", nil) + if err != nil { + return err + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return err + } + + if response.StatusCode >= 200 && response.StatusCode < 400 { + return nil + } + + return fmt.Errorf("not healthy") +} diff --git a/backend/httpclient_tracer.go b/backend/httpclient_tracer.go new file mode 100644 index 0000000..b69896d --- /dev/null +++ b/backend/httpclient_tracer.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/getsentry/sentry-go" +) + +func NewSentryRoundTripper(originalRoundTripper http.RoundTripper, tracePropagationTargets []string) http.RoundTripper { + if originalRoundTripper == nil { + originalRoundTripper = http.DefaultTransport + } + + return &SentryRoundTripper{ + originalRoundTripper: originalRoundTripper, + tracePropagationTargets: tracePropagationTargets, + } +} + +type SentryRoundTripper struct { + originalRoundTripper http.RoundTripper + tracePropagationTargets []string +} + +func (s *SentryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + // Respect trace propagation targets + if len(s.tracePropagationTargets) > 0 { + requestUrlString := request.URL.String() + for _, t := range s.tracePropagationTargets { + if strings.Contains(requestUrlString, t) { + continue + } + + return s.originalRoundTripper.RoundTrip(request) + } + } + + // Start Sentry trace + ctx := request.Context() + cleanRequestURL := request.URL.Path + + span := sentry.StartSpan(ctx, "http.client", sentry.WithTransactionName(fmt.Sprintf("%s %s", request.Method, cleanRequestURL))) + defer span.Finish() + + span.SetData("http.query", request.URL.Query().Encode()) + span.SetData("http.fragment", request.URL.Fragment) + span.SetData("http.request.method", request.Method) + + request.Header.Add("Baggage", span.ToBaggage()) + request.Header.Add("Sentry-Trace", span.ToSentryTrace()) + + response, err := s.originalRoundTripper.RoundTrip(request) + + if response != nil { + span.Status = sentry.HTTPtoSpanStatus(response.StatusCode) + span.SetData("http.response.status_code", response.Status) + span.SetData("http.response_content_length", strconv.FormatInt(response.ContentLength, 10)) + } + + return response, err +} diff --git a/backend/mailer.go b/backend/mailer/mailer.go similarity index 99% rename from backend/mailer.go rename to backend/mailer/mailer.go index 51b63e0..7881a4f 100644 --- a/backend/mailer.go +++ b/backend/mailer/mailer.go @@ -1,4 +1,4 @@ -package main +package mailer import ( "bytes" diff --git a/backend/mailer_test.go b/backend/mailer/mailer_test.go similarity index 75% rename from backend/mailer_test.go rename to backend/mailer/mailer_test.go index 5d16cff..b5e6c18 100644 --- a/backend/mailer_test.go +++ b/backend/mailer/mailer_test.go @@ -1,16 +1,49 @@ -package main_test +package mailer_test import ( "context" + "os" "testing" - main "conf" + "conf/mailer" "github.com/getsentry/sentry-go" ) +var mailSender *mailer.Mailer + +func TestMain(m *testing.M) { + smtpHostname, ok := os.LookupEnv("SMTP_HOSTNAME") + if !ok { + smtpHostname = "localhost" + } + smtpPort, ok := os.LookupEnv("SMTP_PORT") + if !ok { + smtpPort = "1025" + } + smtpFrom, ok := os.LookupEnv("SMTP_FROM") + if !ok { + smtpFrom = "" + } + smtpPassword, ok := os.LookupEnv("SMTP_PASSWORD") + if !ok { + smtpPassword = "" + } + + mailSender = mailer.NewMailSender(&mailer.MailConfiguration{ + SmtpHostname: smtpHostname, + SmtpPort: smtpPort, + SmtpFrom: smtpFrom, + SmtpPassword: smtpPassword, + }) + + exitCode := m.Run() + + os.Exit(exitCode) +} + func TestMailSender(t *testing.T) { t.Run("Happy Scenario", func(t *testing.T) { - mail := &main.Mail{ + mail := &mailer.Mail{ RecipientName: "John Doe", RecipientEmail: "johndoe@example.com", Subject: "Welcome to TeknumConf, you are on the waiting list", diff --git a/backend/main.go b/backend/main.go index 723ccbd..75eba2d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,27 +1,13 @@ package main import ( - "context" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "net" - "net/http" "os" - "os/signal" - "strings" - "text/tabwriter" "time" - "github.com/flowchartsman/handlebars/v3" "github.com/urfave/cli/v2" - "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" "github.com/rs/zerolog/log" - "gocloud.dev/blob" _ "gocloud.dev/blob/fileblob" _ "gocloud.dev/blob/s3blob" ) @@ -42,246 +28,22 @@ func App() *cli.App { }, Commands: []*cli.Command{ { - Name: "server", - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - EnableTracing: true, - TracesSampler: func(ctx sentry.SamplingContext) float64 { - if ctx.Span.Name == "GET /ping" { - return 0 - } - - return 0.2 - }, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - defer sentry.Flush(time.Minute) - - pgxRawConfig, err := pgxpool.ParseConfig(fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - )) - if err != nil { - log.Fatal().Err(err).Msg("Parsing connection string configuration") - } - - pgxConfig := pgxRawConfig.Copy() - - pgxConfig.ConnConfig.Tracer = &PGXTracer{} - - conn, err := pgxpool.NewWithConfig(cCtx.Context, pgxConfig) - if err != nil { - log.Fatal().Err(err).Msg("failed to connect to database") - } - defer conn.Close() - - bucket, err := blob.OpenBucket(context.Background(), config.BlobUrl) - if err != nil { - return fmt.Errorf("opening bucket: %w", err) - } - defer func() { - err := bucket.Close() - if err != nil { - log.Warn().Err(err).Msg("Closing bucket") - } - }() - - signaturePrivateKey, err := hex.DecodeString(config.Signature.PrivateKey) - if err != nil { - return fmt.Errorf("invalid signature private key: %w", err) - } - - signaturePublicKey, err := hex.DecodeString(config.Signature.PublicKey) - if err != nil { - return fmt.Errorf("invalid signature public key: %w", err) - } - - mailer := NewMailSender(&MailConfiguration{ - SmtpHostname: config.Mailer.Hostname, - SmtpPort: config.Mailer.Port, - SmtpFrom: config.Mailer.From, - SmtpPassword: config.Mailer.Password, - }) - - ticketDomain, err := NewTicketDomain(conn, bucket, signaturePrivateKey, signaturePublicKey, mailer) - if err != nil { - return fmt.Errorf("creating ticket domain: %w", err) - } - - httpServer := NewServer(&ServerConfig{ - UserDomain: NewUserDomain(conn), - TicketDomain: ticketDomain, - Environment: config.Environment, - FeatureRegistrationClosed: config.FeatureFlags.RegistrationClosed, - ValidateTicketKey: config.ValidateTicketKey, - }) - - exitSig := make(chan os.Signal, 1) - signal.Notify(exitSig, os.Interrupt, os.Kill) - - go func() { - <-exitSig - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - if err := httpServer.Shutdown(ctx); err != nil { - log.Error().Err(err).Msg("failed to shutdown server") - } - }() - - if err := httpServer.Start(net.JoinHostPort("", config.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatal().Err(err).Msg("failed to start server") - } - - return nil - }, + Name: "server", + Action: ServerHandlerAction, }, { - Name: "migrate", - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - - conn, err := sql.Open( - "pgx", - fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - )) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - defer func() { - err := conn.Close() - if err != nil { - log.Warn().Err(err).Msg("Closing database") - } - }() - - migration, err := NewMigration(conn) - if err != nil { - return fmt.Errorf("failed to create migration: %w", err) - } - - switch cCtx.Args().First() { - case "down": - err := migration.Down(cCtx.Context) - if err != nil { - return fmt.Errorf("executing down migration: %w", err) - } - case "up": - fallthrough - default: - err := migration.Up(cCtx.Context) - if err != nil { - return fmt.Errorf("executing up migration: %w", err) - } - } - - log.Info().Msg("Migration succeed") - - return nil - }, - }, - { - Name: "dump-attendees", - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - - conn, err := pgxpool.New( - context.Background(), - fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - ), - ) - if err != nil { - return fmt.Errorf("failed connect to database: %w", err) - } - defer conn.Close() - - userDomain := NewUserDomain(conn) - - return userDomain.ExportUnprocessedUsersAsCSV(cCtx.Context) + Name: "healthcheck", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "port", + Value: "8080", + }, + &cli.DurationFlag{ + Name: "timeout", + Value: time.Second * 15, + }, }, + Action: HealthcheckHandlerAction, }, { Name: "blast-email", @@ -318,447 +80,7 @@ func App() *cli.App { }, Usage: "blast-email [subject] [template-plaintext] [template-html-body] [csv-file list destination of emails]", ArgsUsage: "[subject] [template-plaintext] [template-html-body] [path-csv-file]", - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - - subject := cCtx.String("subject") - plaintext := cCtx.String("plaintext-body") - htmlBody := cCtx.String("html-body") - mailCsv := cCtx.String("recipients") - singleRecipient := cCtx.String("single-recipient") - - if subject == "" { - log.Fatal().Msg("Subject is required") - } - if plaintext == "" { - log.Fatal().Msg("Plaintext template is required") - } - if htmlBody == "" { - log.Fatal().Msg("Html template is required") - } - if mailCsv == "" && singleRecipient == "" { - log.Fatal().Msg("Recipient is required") - } - - plaintextContent, err := os.ReadFile(plaintext) - if err != nil { - log.Fatal().Err(err).Msg("failed to read plaintext template") - } - - plaintextTemplate, err := handlebars.Parse(string(plaintextContent)) - if err != nil { - log.Fatal().Err(err).Msg("failed to parse plaintext template") - } - - htmlContent, err := os.ReadFile(htmlBody) - if err != nil { - log.Fatal().Err(err).Msg("failed to read html template") - } - - htmlTemplate, err := handlebars.Parse(string(htmlContent)) - if err != nil { - log.Fatal().Err(err).Msg("failed to parse html template") - } - - var userList []User - - if mailCsv != "" { - emailList, err := os.ReadFile(mailCsv) - if err != nil { - log.Fatal().Err(err).Msg("failed to read email list") - } - - userList, err = csvReader(string(emailList), true) - if err != nil { - log.Fatal().Err(err).Msg("failed to parse email list") - } - } else { - userList = append(userList, User{ - Email: singleRecipient, - }) - } - - mailSender := NewMailSender(&MailConfiguration{ - SmtpHostname: config.Mailer.Hostname, - SmtpPort: config.Mailer.Port, - SmtpFrom: config.Mailer.From, - SmtpPassword: config.Mailer.Password, - }) - - for _, user := range userList { - mail := &Mail{ - RecipientName: user.Name, - RecipientEmail: user.Email, - Subject: subject, - PlainTextBody: string(plaintextContent), - HtmlBody: string(htmlContent), - } - - // Parse email template information - emailTemplate := map[string]any{ - "ticketPrice": config.EmailTemplate.TicketPrice, - "ticketStudentCollegePrice": config.EmailTemplate.TicketStudentCollegePrice, - "ticketStudentHighSchoolPrice": config.EmailTemplate.TicketStudentHighSchoolPrice, - "ticketStudentCollegeDiscount": config.EmailTemplate.TicketStudentCollegeDiscount, - "ticketStudentHighSchoolDiscount": config.EmailTemplate.TicketStudentHighSchoolDiscount, - "percentageStudentCollegeDiscount": config.EmailTemplate.PercentageStudentCollegeDiscount, - "percentageStudentHighSchoolDiscount": config.EmailTemplate.PercentageStudentHighSchoolDiscount, - "conferenceEmail": config.EmailTemplate.ConferenceEmail, - "bankAccounts": config.EmailTemplate.BankAccounts, - } - // Execute handlebars template only if user.Name is not empty - if user.Name != "" { - emailTemplate["name"] = user.Name - } - - mail.PlainTextBody = plaintextTemplate.MustExec(emailTemplate) - mail.HtmlBody = htmlTemplate.MustExec(emailTemplate) - - err := mailSender.Send(cCtx.Context, mail) - if err != nil { - log.Error().Err(err).Msgf("failed to send email to %s", user.Email) - continue - } - - log.Info().Msgf("Sending email to %s", user.Email) - } - log.Info().Msg("Blasting email done") - return nil - }, - }, - { - Name: "participants", - Usage: "participants [is_processed]", - ArgsUsage: "[is_processed]", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "is_processed", - Value: false, - Usage: "Is processed", - }, - }, - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - - isProcessedStr := cCtx.Bool("is_processed") - - conn, err := pgxpool.New( - cCtx.Context, - fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - ), - ) - if err != nil { - return err - } - defer conn.Close() - - userDomain := NewUserDomain(conn) - users, err := userDomain.GetUsers(cCtx.Context, UserFilterRequest{Type: TypeParticipant, IsProcessed: isProcessedStr}) - if err != nil { - return err - } - - w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', tabwriter.TabIndent) - w.Write([]byte("Name\tEmail\tRegistered At\t")) - for _, user := range users { - w.Write([]byte(fmt.Sprintf( - "%s\t%s\t%s\t", - user.Name, - user.Email, - user.CreatedAt.In(time.FixedZone("WIB", 7*60*60)).Format(time.Stamp), - ))) - } - - return w.Flush() - }, - }, - { - Name: "student-verification", - Usage: "student-verification [path-csv-file]", - ArgsUsage: "[path-csv-file]", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "bulk-verification", - Value: "", - Required: false, - }, - &cli.StringFlag{ - Name: "single-verification", - Value: "", - Required: false, - }, - }, - Action: func(cCtx *cli.Context) error { - config, err := GetConfig(cCtx.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - - bulkVerification := cCtx.String("bulk-verification") - singleVerification := cCtx.String("single-verification") - - if bulkVerification == "" && singleVerification == "" { - return fmt.Errorf("requires `--bulk-verification` or `--single-verification` flag") - } - - var students []User - if bulkVerification != "" { - emailList, err := os.ReadFile(bulkVerification) - if err != nil { - log.Fatal().Err(err).Msg("failed to read email list") - } - - students, err = csvReader(string(emailList), false) - if err != nil { - log.Fatal().Err(err).Msg("failed to parse email list") - } - } else { - students = append(students, User{ - Email: singleVerification, - }) - } - - bucket, err := blob.OpenBucket(context.Background(), config.BlobUrl) - if err != nil { - return fmt.Errorf("opening bucket: %w", err) - } - defer func() { - err := bucket.Close() - if err != nil { - log.Warn().Err(err).Msg("Closing bucket") - } - }() - - signaturePrivateKey, err := hex.DecodeString(config.Signature.PrivateKey) - if err != nil { - return fmt.Errorf("invalid signature private key: %w", err) - } - - signaturePublicKey, err := hex.DecodeString(config.Signature.PublicKey) - if err != nil { - return fmt.Errorf("invalid signature public key: %w", err) - } - - mailer := NewMailSender(&MailConfiguration{ - SmtpHostname: config.Mailer.Hostname, - SmtpPort: config.Mailer.Port, - SmtpFrom: config.Mailer.From, - SmtpPassword: config.Mailer.Password, - }) - - conn, err := pgxpool.New( - cCtx.Context, - fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - ), - ) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - - ticketDomain, err := NewTicketDomain(conn, bucket, signaturePrivateKey, signaturePublicKey, mailer) - if err != nil { - return fmt.Errorf("creating a ticket domain instance: %s", err.Error()) - } - - for _, student := range students { - err := ticketDomain.VerifyIsStudent(cCtx.Context, student.Email) - if err != nil { - log.Error().Err(err).Msgf("failed to verify student %s", student.Email) - continue - } - - log.Info().Msgf("Verified student %s", student.Email) - } - - return nil - }, - }, - { - Name: "verify-payment", - Usage: "verify-payment --email johndoe@example.com", - Description: "Verifies a payment by a certain email. This will send an email containing a QR code ticket for the attendee.", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "email", - Usage: "Specifies the email for the manually payment-verified attendee. Should be a comma separated emails.", - Required: true, - }, - }, - Action: func(c *cli.Context) error { - emails := strings.Split(c.String("email"), ",") - if len(emails) == 0 { - return fmt.Errorf("--email flag is required and must not be left empty") - } - - config, err := GetConfig(c.String("config-file-path")) - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - Release: version, - Environment: config.Environment, - DebugWriter: log.Logger, - BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { - if config.Environment != "production" { - log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) - } - - return event - }, - }) - if err != nil { - return fmt.Errorf("initializing Sentry: %w", err) - } - defer sentry.Flush(time.Second * 10) - - c.Context = sentry.SetHubOnContext(c.Context, sentry.CurrentHub().Clone()) - - bucket, err := blob.OpenBucket(context.Background(), config.BlobUrl) - if err != nil { - return fmt.Errorf("opening bucket: %w", err) - } - defer func() { - err := bucket.Close() - if err != nil { - log.Warn().Err(err).Msg("Closing bucket") - } - }() - - signaturePrivateKey, err := hex.DecodeString(config.Signature.PrivateKey) - if err != nil { - return fmt.Errorf("invalid signature private key: %w", err) - } - - signaturePublicKey, err := hex.DecodeString(config.Signature.PublicKey) - if err != nil { - return fmt.Errorf("invalid signature public key: %w", err) - } - - mailer := NewMailSender(&MailConfiguration{ - SmtpHostname: config.Mailer.Hostname, - SmtpPort: config.Mailer.Port, - SmtpFrom: config.Mailer.From, - SmtpPassword: config.Mailer.Password, - }) - - conn, err := pgxpool.New( - c.Context, - fmt.Sprintf( - "user=%s password=%s host=%s port=%d dbname=%s sslmode=disable", - config.Database.User, - config.Database.Password, - config.Database.Host, - config.Database.Port, - config.Database.Name, - ), - ) - if err != nil { - return fmt.Errorf("failed to connect to database: %w", err) - } - defer conn.Close() - - ticketDomain, err := NewTicketDomain(conn, bucket, signaturePrivateKey, signaturePublicKey, mailer) - if err != nil { - return fmt.Errorf("creating a ticket domain instance: %s", err.Error()) - } - - for _, email := range emails { - _, err = ticketDomain.ValidatePaymentReceipt(c.Context, email) - if err != nil { - sentry.GetHubFromContext(c.Context).CaptureException(err) - log.Error().Err(err).Str("email", email).Msg("Validating payment receipt") - continue - } - - log.Info().Str("email", email).Msg("Validating payment receipt") - } - - log.Info().Msg("Finished verifying payments") - return nil - }, + Action: BlastMailHandlerAction, }, }, Copyright: ` Copyright 2023 Teknologi Umum diff --git a/backend/main_test.go b/backend/main_test.go deleted file mode 100644 index 0afc3d4..0000000 --- a/backend/main_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package main_test - -import ( - "context" - "log" - "os" - "testing" - - "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5/pgxpool" - "gocloud.dev/blob" - _ "gocloud.dev/blob/fileblob" - - main "conf" -) - -var database *pgxpool.Pool -var bucket *blob.Bucket -var mailSender *main.Mailer - -func TestMain(m *testing.M) { - databaseUrl, ok := os.LookupEnv("DATABASE_URL") - if !ok { - databaseUrl = "postgres://postgres:password@localhost:5432/conf?sslmode=disable" - } - - tempDir, err := os.MkdirTemp(os.TempDir(), "teknologi-umum-conference") - if err != nil { - log.Fatalf("creating temporary directory: %s", err.Error()) - return - } - - blobUrl, ok := os.LookupEnv("BLOB_URL") - if !ok { - blobUrl = "file://" + tempDir - } - - smtpHostname, ok := os.LookupEnv("SMTP_HOSTNAME") - if !ok { - smtpHostname = "localhost" - } - smtpPort, ok := os.LookupEnv("SMTP_PORT") - if !ok { - smtpPort = "1025" - } - smtpFrom, ok := os.LookupEnv("SMTP_FROM") - if !ok { - smtpFrom = "" - } - smtpPassword, ok := os.LookupEnv("SMTP_PASSWORD") - if !ok { - smtpPassword = "" - } - - _ = sentry.Init(sentry.ClientOptions{}) - - database, err = pgxpool.New(context.Background(), databaseUrl) - if err != nil { - log.Fatalf("creating pgx pool instance: %s", err.Error()) - return - } - - bucket, err = blob.OpenBucket(context.Background(), blobUrl) - if err != nil { - log.Fatalf("creating bucket instance: %s", err.Error()) - } - - mailSender = main.NewMailSender(&main.MailConfiguration{ - SmtpHostname: smtpHostname, - SmtpPort: smtpPort, - SmtpFrom: smtpFrom, - SmtpPassword: smtpPassword, - }) - - // Migrate database - if err := main.App().Run([]string{"teknum-conf", "migrate", "up"}); err != nil { - log.Fatalf("migrating database: %s", err.Error()) - return - } - - exitCode := m.Run() - - // Migrate database - if err := main.App().Run([]string{"teknum-conf", "migrate", "down"}); err != nil { - log.Fatalf("migrating database: %s", err.Error()) - return - } - - os.RemoveAll(tempDir) - bucket.Close() - database.Close() - - os.Exit(exitCode) -} diff --git a/backend/migration.go b/backend/migration.go deleted file mode 100644 index 04cd340..0000000 --- a/backend/migration.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "embed" - "errors" - - "github.com/pressly/goose/v3" -) - -//go:embed migrations/*.sql -var embedMigrations embed.FS - -type Migration struct { - db *sql.DB -} - -func NewMigration(db *sql.DB) (*Migration, error) { - if db == nil { - return &Migration{}, errors.New("db is nil") - } - - goose.SetBaseFS(embedMigrations) - - goose.SetLogger(&conformedLogger{}) - - if err := goose.SetDialect("postgres"); err != nil { - return &Migration{}, err - } - - return &Migration{db: db}, nil -} - -func (m *Migration) Up(ctx context.Context) (err error) { - return goose.UpContext(ctx, m.db, "migrations") -} - -func (m *Migration) Down(ctx context.Context) error { - return goose.DownContext(ctx, m.db, "migrations") -} diff --git a/backend/nocodb/create_table_records.go b/backend/nocodb/create_table_records.go new file mode 100644 index 0000000..e0da7ee --- /dev/null +++ b/backend/nocodb/create_table_records.go @@ -0,0 +1,65 @@ +package nocodb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// CreateTableRecords allows the creation of new records within a specified table. Records to be inserted are input as +// an array of key-value pair objects, where each key corresponds to a field name. Ensure that all the required fields +// are included in the payload, with exceptions for fields designated as auto-increment or those having default values. +// +// When dealing with 'Links' or 'Link To Another Record' field types, you should utilize the 'Create Link' API to insert +// relevant data. +// +// Certain read-only field types will be disregarded if included in the request. These field types include 'Look Up,' +// 'Roll Up,' 'Formula,' 'Auto Number,' 'Created By,' 'Updated By,' 'Created At,' 'Updated At,' 'Barcode,' and 'QR Code.' +func (c *Client) CreateTableRecords(ctx context.Context, tableId string, records []any) error { + requestUrl, err := url.Parse(c.baseUrl + "/api/v2/tables/" + tableId + "/records") + if err != nil { + return fmt.Errorf("parsing url: %w", err) + } + + requestBody, err := json.Marshal(records) + if err != nil { + return fmt.Errorf("marshaling records: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, requestUrl.String(), bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + request.Header.Add("xc-auth", c.apiToken) + request.Header.Add("Content-Type", "application/json") + + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("executing http request: %w", err) + } + defer func() { + if response.Body != nil { + err := response.Body.Close() + if err != nil { + if c.logger != nil { + _, _ = c.logger.Write([]byte("Closing response body: " + err.Error())) + } + } + } + }() + + if response.StatusCode == 400 { + var badRequestError BadRequestError + err = json.NewDecoder(response.Body).Decode(&badRequestError) + if err != nil { + return fmt.Errorf("unmarshaling bad request error: %w", err) + } + return badRequestError + } + + return nil +} diff --git a/backend/nocodb/errors.go b/backend/nocodb/errors.go new file mode 100644 index 0000000..923a94e --- /dev/null +++ b/backend/nocodb/errors.go @@ -0,0 +1,9 @@ +package nocodb + +type BadRequestError struct { + Message string `json:"msg"` +} + +func (b BadRequestError) Error() string { + return b.Message +} diff --git a/backend/nocodb/list_table_records.go b/backend/nocodb/list_table_records.go new file mode 100644 index 0000000..480d218 --- /dev/null +++ b/backend/nocodb/list_table_records.go @@ -0,0 +1,164 @@ +package nocodb + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +type ListTableRecordOptions struct { + // Fields llows you to specify the fields that you wish to include in your API response. + // By default, all the fields are included in the response. + Fields []string + // Sort llows you to specify the fields by which you want to sort the records in your API response. + // By default, sorting is done in ascending order for the designated fields + Sort []Sort + // Where enables you to define specific conditions for filtering records in your API response. + // Multiple conditions can be combined using logical operators such as 'and' and 'or'. + // Each condition consists of three parts: a field name, a comparison operator, and a value. + // + // Example: where=(field1,eq,value1)~and(field2,eq,value2) will filter records where 'field1' is equal to 'value1' + // AND 'field2' is equal to 'value2'. + // + // You can also use other comparison operators like 'ne' (not equal), 'gt' (greater than), 'lt' (less than), + // and more, to create complex filtering rules. + // + // If ViewId parameter is also included, then the filters included here will be applied over the filtering + // configuration defined in the view. + // + // Please remember to maintain the specified format, and do not include spaces between the different + // condition components + // + // SDK implementation note: I am too lazy to create a proper struct that can make the where clause + // easy, so I'll leave this to the users. + Where string + // Offset enables you to control the pagination of your API response by specifying the number of records you + // want to skip from the beginning of the result set. The default value for this parameter is set to 0, meaning + // no records are skipped by default. + // + // Example: offset=25 will skip the first 25 records in your API response, allowing you to access records starting + // from the 26th position. + // + // Please note that the 'offset' value represents the number of records to exclude, not an index value, so an + // offset of 25 will skip the first 25 records. + Offset int64 + // Limit enables you to set a limit on the number of records you want to retrieve in your API response. + // By default, your response includes all the available records, but by using this parameter, you can control + // the quantity you receive. + Limit int64 + // ViewId View Identifier. Allows you to fetch records that are currently visible within a specific view. + // API retrieves records in the order they are displayed if the SORT option is enabled within that view. + // + // Additionally, if you specify a sort query parameter, it will take precedence over any sorting configuration + // defined in the view. If you specify a where query parameter, it will be applied over the filtering configuration + // defined in the view. + // + // By default, all fields, including those that are disabled within the view, are included in the response. + // To explicitly specify which fields to include or exclude, you can use the fields query parameter to customize + // the output according to your requirements. + ViewId string +} + +type listTableRecordsResponse struct { + List any `json:"list"` + PageInfo PageInfo `json:"pageInfo"` +} + +// ListTableRecords allows you to retrieve records from a specified table. You can customize the response by applying +// various query parameters for filtering, sorting, and formatting. +// +// Pagination: The response is paginated by default, with the first page being returned initially. The response includes +// the following additional information in the pageInfo JSON block. +// +// Note: `out` parameter MUST BE a pointer to a struct array. +func (c *Client) ListTableRecords(ctx context.Context, tableId string, out any, options ListTableRecordOptions) (PageInfo, error) { + queryParams := &url.Values{} + if len(options.Fields) > 0 { + queryParams.Set("fields", strings.Join(options.Fields, ",")) + } + + if len(options.Sort) > 0 { + var sortStrings []string + for _, sort := range options.Sort { + sortStrings = append(sortStrings, sort.parse()) + } + queryParams.Set("sort", strings.Join(sortStrings, ",")) + } + + if options.Where != "" { + queryParams.Set("where", options.Where) + } + + if options.Offset > 0 { + queryParams.Set("offset", strconv.FormatInt(options.Offset, 10)) + } + + if options.Limit > 0 { + queryParams.Set("limit", strconv.FormatInt(options.Offset, 10)) + } + + if options.ViewId != "" { + queryParams.Set("viewId", options.ViewId) + } + + requestUrl, err := url.Parse(c.baseUrl + "/api/v2/tables/" + tableId + "/records") + if err != nil { + return PageInfo{}, fmt.Errorf("parsing url: %w", err) + } + + requestUrl.RawQuery = queryParams.Encode() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestUrl.String(), nil) + if err != nil { + return PageInfo{}, fmt.Errorf("creating request: %w", err) + } + + request.Header.Add("xc-auth", c.apiToken) + + response, err := c.httpClient.Do(request) + if err != nil { + return PageInfo{}, fmt.Errorf("executing http request: %w", err) + } + defer func() { + if response.Body != nil { + err := response.Body.Close() + if err != nil { + if c.logger != nil { + _, _ = c.logger.Write([]byte("Closing response body: " + err.Error())) + } + } + } + }() + + if response.StatusCode == 400 { + var badRequestError BadRequestError + err = json.NewDecoder(response.Body).Decode(&badRequestError) + if err != nil { + return PageInfo{}, fmt.Errorf("unmarshaling bad request error: %w", err) + } + return PageInfo{}, badRequestError + } + + var responseBody listTableRecordsResponse + err = json.NewDecoder(response.Body).Decode(&responseBody) + if err != nil { + return PageInfo{}, fmt.Errorf("deserializing json: %w", err) + } + + // Re-marshal list response, unmarshal to request output + marshalledList, err := json.Marshal(responseBody.List) + if err != nil { + return PageInfo{}, fmt.Errorf("re-marshalling list: %w", err) + } + + err = json.Unmarshal(marshalledList, out) + if err != nil { + return PageInfo{}, fmt.Errorf("unmarshalling list: %w", err) + } + + return responseBody.PageInfo, nil +} diff --git a/backend/nocodb/nocodb.go b/backend/nocodb/nocodb.go new file mode 100644 index 0000000..5801ff4 --- /dev/null +++ b/backend/nocodb/nocodb.go @@ -0,0 +1,71 @@ +package nocodb + +import ( + "io" + "net/http" +) + +type Client struct { + apiToken string + baseUrl string + httpClient *http.Client + logger io.Writer +} + +type ClientOptions struct { + ApiToken string + BaseUrl string + HttpClient *http.Client + Logger io.Writer +} + +func NewClient(options ClientOptions) (*Client, error) { + if options.HttpClient == nil { + options.HttpClient = http.DefaultClient + } + + return &Client{ + apiToken: options.ApiToken, + baseUrl: options.BaseUrl, + httpClient: options.HttpClient, + logger: options.Logger, + }, nil +} + +// PageInfo attributes are particularly valuable when dealing with large datasets that are divided into multiple pages. +// They enable you to determine whether additional pages of records are available for retrieval or if you've reached +// the end of the dataset. +type PageInfo struct { + // TotalRows indicates the total number of rows available for the specified conditions (if any). + TotalRows int64 `json:"totalRows"` + // Page specifies the current page number. + Page int64 `json:"page"` + // PageSize defaults to 25 and defines the number of records on each page. + PageSize int64 `json:"pageSize"` + // IsFirstPage is a boolean value that indicates whether the current page is the first page of records in the dataset. + IsFirstPage bool `json:"isFirstPage"` + // IsLastPage is a boolean value that indicates whether the current page is the last page of records in the dataset. + IsLastPage bool `json:"isLastPage"` +} + +type Sort interface { + // parse is a private function to make the Sort interface can't be implemented outside of this package. + parse() string +} + +// sortImpl is an implementation of Sort interface. +type sortImpl struct { + value string +} + +func (s *sortImpl) parse() string { + return s.value +} + +func SortAscending(fieldName string) Sort { + return &sortImpl{value: fieldName} +} + +func SortDescending(fieldName string) Sort { + return &sortImpl{value: "-" + fieldName} +} diff --git a/backend/nocodb/nocodb_test.go b/backend/nocodb/nocodb_test.go new file mode 100644 index 0000000..2a29b8c --- /dev/null +++ b/backend/nocodb/nocodb_test.go @@ -0,0 +1,128 @@ +package nocodb_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" + + "conf/nocodb" + "conf/nocodb/nocodbmock" + "github.com/rs/zerolog/log" +) + +var client *nocodb.Client +var tableId string + +func TestMain(m *testing.M) { + baseUrl := os.Getenv("NOCODB_BASE_URL") + apiToken := os.Getenv("NOCODB_API_KEY") + tableId = os.Getenv("NOCODB_TABLE_ID") + if tableId == "" { + tableId = "aabbcc" + } + + var err error = nil + var mockServer *httptest.Server = nil + if baseUrl != "" && apiToken != "" { + client, err = nocodb.NewClient(nocodb.ClientOptions{ + ApiToken: apiToken, + BaseUrl: baseUrl, + }) + if err != nil { + log.Fatal().Err(err).Msg("creating nocodb client") + return + } + } else { + mockServer, err = nocodbmock.NewNocoDBMockServer() + if err != nil { + mockServer.Close() + log.Fatal().Err(err).Msg("creating mock server") + return + } + + client, err = nocodb.NewClient(nocodb.ClientOptions{ + ApiToken: "testing", + BaseUrl: mockServer.URL, + HttpClient: mockServer.Client(), + }) + if err != nil { + log.Fatal().Err(err).Msg("creating nocodb client") + return + } + } + + exitCode := m.Run() + + if mockServer != nil { + mockServer.Close() + } + + os.Exit(exitCode) +} + +type testBody struct { + Id int64 `json:"Id,omitempty"` + Title string + Age int + RandomText string +} + +func TestIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + randomBytes := make([]byte, 16) + rand.Read(randomBytes) + randomText := base64.StdEncoding.EncodeToString(randomBytes) + payload := testBody{ + Title: "John Doe", + Age: 49, + RandomText: randomText, + } + + err := client.CreateTableRecords(ctx, tableId, []any{payload}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + var outPayload []testBody + pageInfo, err := client.ListTableRecords(ctx, tableId, &outPayload, nocodb.ListTableRecordOptions{}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + found := false + foundPayload := testBody{} + for _, out := range outPayload { + if out.RandomText == randomText { + found = true + foundPayload = out + } + } + if !found { + t.Errorf("expecting just inserted entry to be found, got not found") + } + if pageInfo.TotalRows <= 0 { + t.Errorf("expecting pageInfo.TotalRows to be a positive number greater than one, got %d", pageInfo.TotalRows) + } + + err = client.UpdateTableRecords(ctx, tableId, []any{map[string]any{"Id": foundPayload.Id, "Age": 320}}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + var anotherOutPayload testBody + err = client.ReadTableRecords(ctx, tableId, strconv.FormatInt(foundPayload.Id, 10), &anotherOutPayload, nocodb.ReadTableRecordsOptions{}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + if anotherOutPayload.Age != 320 { + t.Errorf("expecting Age to be updated to 320, got %d", anotherOutPayload.Age) + } +} diff --git a/backend/nocodb/nocodbmock/inmemory_storage.go b/backend/nocodb/nocodbmock/inmemory_storage.go new file mode 100644 index 0000000..52b6db4 --- /dev/null +++ b/backend/nocodb/nocodbmock/inmemory_storage.go @@ -0,0 +1,131 @@ +package nocodbmock + +import ( + "errors" + "sync" +) + +var errNotFound = errors.New("not found") +var errInvalidType = errors.New("invalid type") + +type storage struct { + m *sync.Map +} + +func newInMemoryStorage() *storage { + return &storage{m: &sync.Map{}} +} + +func (s *storage) GetByTableId(tableId string) (records []map[string]any, err error) { + value, ok := s.m.Load(tableId) + if !ok { + return nil, errNotFound + } + + v, ok := value.([]map[string]any) + if !ok { + return nil, errInvalidType + } + + return v, nil +} + +func (s *storage) GetByRecordId(tableId string, recordId int64) (record map[string]any, err error) { + records, err := s.GetByTableId(tableId) + if err != nil { + return nil, err + } + + for _, record := range records { + var id int64 + switch v := record["Id"].(type) { + case float64: + id = int64(v) + break + case float32: + id = int64(v) + break + case int: + id = int64(v) + break + case int64: + id = v + break + } + if recordId == id { + return record, nil + } + } + + return nil, errNotFound +} + +func (s *storage) Insert(tableId string, records []map[string]any) (ids []int64, err error) { + var lastRecordId int64 = 0 + if oldRecords, err := s.GetByTableId(tableId); err == nil { + lastIndex := len(oldRecords) - 1 + if v, ok := oldRecords[lastIndex]["Id"]; ok { + if i, ok := v.(int64); ok { + lastRecordId = i + } + } + } + + var recordIds []int64 + for i := 0; i < len(records); i++ { + id := lastRecordId + 1 + records[i]["Id"] = id + recordIds = append(recordIds, id) + lastRecordId++ + } + + s.m.Store(tableId, records) + return recordIds, nil +} + +func (s *storage) Update(tableId string, records []map[string]any) (ids []int64, err error) { + oldRecords, err := s.GetByTableId(tableId) + if err != nil { + return nil, err + } + + var recordIds []int64 + + for i := 0; i < len(oldRecords); i++ { + // I know that this is O(n^2) but because this is a mock, I don't really care + for _, record := range records { + var recordId int64 + switch v := record["Id"].(type) { + case float64: + recordId = int64(v) + break + case float32: + recordId = int64(v) + break + case int: + recordId = int64(v) + break + case int64: + recordId = v + break + } + + if oldRecords[i]["Id"] == recordId { + // Found one + for key, value := range record { + oldRecords[i][key] = value + } + + id, ok := record["Id"].(int64) + if ok { + recordIds = append(recordIds, id) + } + + break + } + } + } + + s.m.Store(tableId, oldRecords) + return recordIds, nil +} diff --git a/backend/nocodb/nocodbmock/nocodbmock.go b/backend/nocodb/nocodbmock/nocodbmock.go new file mode 100644 index 0000000..9cdc717 --- /dev/null +++ b/backend/nocodb/nocodbmock/nocodbmock.go @@ -0,0 +1,180 @@ +package nocodbmock + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + + "conf/nocodb" + "github.com/go-chi/chi/v5" +) + +type errorResponse struct { + Message string `json:"msg"` +} + +type listTableResponse struct { + List []map[string]any `json:"list"` + PageInfo nocodb.PageInfo `json:"pageInfo"` +} + +type creationSuccessfulResponse struct { + ID int64 `json:"Id"` +} + +func NewNocoDBMockServer() (*httptest.Server, error) { + documentStorage := newInMemoryStorage() + + r := chi.NewRouter() + + r.Get("/api/v2/tables/{tableId}/records", func(w http.ResponseWriter, r *http.Request) { + tableId := chi.URLParam(r, "tableId") + if tableId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: tableId is empty"}) + return + } + + records, err := documentStorage.GetByTableId(tableId) + if err != nil && !errors.Is(err, errNotFound) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: " + err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(listTableResponse{ + List: records, + PageInfo: nocodb.PageInfo{ + TotalRows: int64(len(records)), + Page: 1, + PageSize: 1, + IsFirstPage: true, + IsLastPage: true, + }, + }) + return + }) + + r.Post("/api/v2/tables/{tableId}/records", func(w http.ResponseWriter, r *http.Request) { + tableId := chi.URLParam(r, "tableId") + if tableId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: tableId is empty"}) + return + } + + var records []map[string]any + err := json.NewDecoder(r.Body).Decode(&records) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: Invalid request body"}) + return + } + + recordIds, err := documentStorage.Insert(tableId, records) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorResponse{Message: err.Error()}) + return + } + + var response []creationSuccessfulResponse + for _, id := range recordIds { + response = append(response, creationSuccessfulResponse{ID: id}) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + return + }) + + r.Patch("/api/v2/tables/{tableId}/records", func(w http.ResponseWriter, r *http.Request) { + tableId := chi.URLParam(r, "tableId") + if tableId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: tableId is empty"}) + return + } + + var records []map[string]any + err := json.NewDecoder(r.Body).Decode(&records) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: Invalid request body"}) + return + } + + recordIds, err := documentStorage.Update(tableId, records) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(errorResponse{Message: err.Error()}) + return + } + + var response []creationSuccessfulResponse + for _, id := range recordIds { + response = append(response, creationSuccessfulResponse{ID: id}) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + return + }) + + r.Get("/api/v2/tables/{tableId}/records/{recordId}", func(w http.ResponseWriter, r *http.Request) { + tableId := chi.URLParam(r, "tableId") + if tableId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: tableId is empty"}) + return + } + + recordId := chi.URLParam(r, "recordId") + if recordId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: recordId is empty"}) + return + } + + recordIdAsInt64, err := strconv.ParseInt(recordId, 10, 64) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: invalid recordId value"}) + return + } + + record, err := documentStorage.GetByRecordId(tableId, recordIdAsInt64) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(errorResponse{Message: "BadRequest [ERROR]: " + err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(record) + return + }) + + server := httptest.NewServer(r) + + return server, nil +} diff --git a/backend/nocodb/read_table_records.go b/backend/nocodb/read_table_records.go new file mode 100644 index 0000000..ecc43ad --- /dev/null +++ b/backend/nocodb/read_table_records.go @@ -0,0 +1,71 @@ +package nocodb + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +type ReadTableRecordsOptions struct { + // Fields a llows you to specify the fields that you wish to include in your API response. By default, all the fields are included in the response. + Fields []string +} + +// ReadTableRecords allows you to retrieve a single record identified by Record-ID, serving as unique identifier for +// the record from a specified table. +// +// Note: `out` parameter MUST BE a pointer to a struct. +func (c *Client) ReadTableRecords(ctx context.Context, tableId string, recordId string, out any, options ReadTableRecordsOptions) error { + queryParams := &url.Values{} + if len(options.Fields) > 0 { + queryParams.Set("fields", strings.Join(options.Fields, ",")) + } + + requestUrl, err := url.Parse(c.baseUrl + "/api/v2/tables/" + tableId + "/records/" + recordId) + if err != nil { + return fmt.Errorf("parsing url: %w", err) + } + + requestUrl.RawQuery = queryParams.Encode() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestUrl.String(), nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + request.Header.Add("xc-auth", c.apiToken) + + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("executing http request: %w", err) + } + defer func() { + if response.Body != nil { + err := response.Body.Close() + if err != nil { + if c.logger != nil { + _, _ = c.logger.Write([]byte("Closing response body: " + err.Error())) + } + } + } + }() + + if response.StatusCode == 400 { + var badRequestError BadRequestError + err = json.NewDecoder(response.Body).Decode(&badRequestError) + if err != nil { + return fmt.Errorf("unmarshaling bad request error: %w", err) + } + return badRequestError + } + + err = json.NewDecoder(response.Body).Decode(&out) + if err != nil { + return fmt.Errorf("decoding response body: %w", err) + } + + return nil +} diff --git a/backend/nocodb/update_table_records.go b/backend/nocodb/update_table_records.go new file mode 100644 index 0000000..3d0371d --- /dev/null +++ b/backend/nocodb/update_table_records.go @@ -0,0 +1,69 @@ +package nocodb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// UpdateTableRecords allows updating existing records within a specified table identified by an array of Record-IDs, +// serving as unique identifier for the record. Records to be updated are input as an array of key-value pair objects, +// where each key corresponds to a field name. Ensure that all the required fields are included in the payload, with +// exceptions for fields designated as auto-increment or those having default values. +// +// When dealing with 'Links' or 'Link To Another Record' field types, you should utilize the 'Create Link' API to +// insert relevant data. +// +// Certain read-only field types will be disregarded if included in the request. These field types include 'Look Up,' +// 'Roll Up,' 'Formula,' 'Auto Number,' 'Created By,' 'Updated By,' 'Created At,' 'Updated At,' 'Barcode,' and 'QR Code.' +// +// Note that a PATCH request only updates the specified fields while leaving other fields unaffected. Currently, +// PUT requests are not supported by this endpoint. +func (c *Client) UpdateTableRecords(ctx context.Context, tableId string, records []any) error { + requestUrl, err := url.Parse(c.baseUrl + "/api/v2/tables/" + tableId + "/records") + if err != nil { + return fmt.Errorf("parsing url: %w", err) + } + + requestBody, err := json.Marshal(records) + if err != nil { + return fmt.Errorf("marshaling records: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPatch, requestUrl.String(), bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + request.Header.Add("xc-auth", c.apiToken) + request.Header.Add("Content-Type", "application/json") + + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("executing http request: %w", err) + } + defer func() { + if response.Body != nil { + err := response.Body.Close() + if err != nil { + if c.logger != nil { + _, _ = c.logger.Write([]byte("Closing response body: " + err.Error())) + } + } + } + }() + + if response.StatusCode == 400 { + var badRequestError BadRequestError + err = json.NewDecoder(response.Body).Decode(&badRequestError) + if err != nil { + return fmt.Errorf("unmarshaling bad request error: %w", err) + } + return badRequestError + } + + return nil +} diff --git a/backend/pgx_tracer.go b/backend/pgx_tracer.go deleted file mode 100644 index e585de4..0000000 --- a/backend/pgx_tracer.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "context" - - "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5" -) - -type PGXTracer struct{} - -func (t PGXTracer) TraceQueryStart(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryStartData) context.Context { - span := sentry.StartSpan(ctx, "pgx.query", sentry.WithTransactionName("PGX TraceQuery")) - if span == nil { - return ctx - } - - span.SetContext("data", sentry.Context{ - "sql": data.SQL, - "args": data.Args, - }) - - return span.Context() -} - -func (t PGXTracer) TraceQueryEnd(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryEndData) { - span := sentry.SpanFromContext(ctx) - if span == nil { - return - } - - span.SetData("command_tag", data.CommandTag.String()) - - if data.Err != nil { - span.Status = sentry.SpanStatusInternalError - span.SetData("error", data.Err.Error()) - } - - span.Finish() -} diff --git a/backend/server.go b/backend/server.go deleted file mode 100644 index 77a7fa9..0000000 --- a/backend/server.go +++ /dev/null @@ -1,312 +0,0 @@ -package main - -import ( - "encoding/hex" - "errors" - "mime" - "net/http" - "path" - "slices" - - "github.com/getsentry/sentry-go" - sentryecho "github.com/getsentry/sentry-go/echo" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/rs/zerolog/log" - "golang.org/x/crypto/bcrypt" -) - -type ServerConfig struct { - UserDomain *UserDomain - TicketDomain *TicketDomain - Environment string - FeatureRegistrationClosed bool - ValidateTicketKey string -} - -type ServerDependency struct { - userDomain *UserDomain - ticketDomain *TicketDomain - registrationClosed bool - validateTicketKey string -} - -func NewServer(config *ServerConfig) *echo.Echo { - if config.UserDomain == nil || config.TicketDomain == nil { - // For production backend application, please don't do what I just did. - // Do a proper nil check and validation for each of your config and dependencies. - // NEVER call panic(), just return error. - // I'm in a hackathon (basically in a rush), so I'm doing this. - // Let me remind you again: don't do what I just did. - panic("one of the domain dependency is nil") - } - - dependencies := &ServerDependency{ - userDomain: config.UserDomain, - ticketDomain: config.TicketDomain, - registrationClosed: config.FeatureRegistrationClosed, - validateTicketKey: config.ValidateTicketKey, - } - - e := echo.New() - - sentryMiddleware := sentryecho.New(sentryecho.Options{Repanic: false}) - e.Use(sentryMiddleware) - - // NOTE: Only need to handle CORS, everything else is being handled by the API gateway - corsAllowedOrigins := []string{"https://conference.teknologiumum.com"} - if config.Environment != "production" { - corsAllowedOrigins = append(corsAllowedOrigins, "http://localhost:3000") - } - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: corsAllowedOrigins, - AllowMethods: []string{http.MethodPost}, - AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, - AllowCredentials: false, - MaxAge: 3600, // 1 day - })) - - e.Use(middleware.RequestID()) - - e.GET("/ping", func(c echo.Context) error { - return c.NoContent(http.StatusOK) - }) - - e.POST("/users", dependencies.RegisterUser) - e.POST("/bukti-transfer", dependencies.UploadBuktiTransfer) - e.POST("/scan-tiket", dependencies.DayTicketScan) - return e -} - -type RegisterUserRequest struct { - Name string `json:"name"` - Email string `json:"email"` -} - -func (s *ServerDependency) RegisterUser(c echo.Context) error { - requestId := c.Response().Header().Get(echo.HeaderXRequestID) - sentryHub := sentryecho.GetHubFromContext(c) - sentryHub.Scope().SetTag("request-id", requestId) - - span := sentry.StartSpan(c.Request().Context(), "http.server", sentry.WithTransactionName("POST /users"), sentry.WithTransactionSource(sentry.SourceRoute)) - defer span.Finish() - - if s.registrationClosed { - return c.JSON(http.StatusNotAcceptable, echo.Map{ - "message": "Registration is closed", - "request_id": requestId, - }) - } - - p := RegisterUserRequest{} - if err := c.Bind(&p); err != nil { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Invalid request body", - "errors": err.Error(), - "request_id": requestId, - }) - } - - err := s.userDomain.CreateParticipant( - span.Context(), - CreateParticipantRequest{ - Name: p.Name, - Email: p.Email, - }, - ) - if err != nil { - var validationError *ValidationError - if errors.As(err, &validationError) { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Validation error", - "errors": validationError.Errors, - "request_id": requestId, - }) - } - - sentryHub.CaptureException(err) - return c.JSON(http.StatusInternalServerError, echo.Map{ - "message": "Internal server error", - "errors": "Internal server error", - "request_id": requestId, - }) - } - - return c.NoContent(http.StatusCreated) -} - -func (s *ServerDependency) UploadBuktiTransfer(c echo.Context) error { - requestId := c.Response().Header().Get(echo.HeaderXRequestID) - sentryHub := sentryecho.GetHubFromContext(c) - sentryHub.Scope().SetTag("request-id", requestId) - - span := sentry.StartSpan(c.Request().Context(), "http.server", sentry.WithTransactionName("POST /bukti-transfer"), sentry.WithTransactionSource(sentry.SourceRoute)) - defer span.Finish() - - if err := c.Request().ParseMultipartForm(32 << 10); err != nil { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Parsing error", - "errors": err.Error(), - "request_id": requestId, - }) - } - - email := c.Request().FormValue("email") - if email == "" { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Validation error", - "errors": "Email field is required", - "request_id": requestId, - }) - } - - photoFile, photoFormHeader, err := c.Request().FormFile("photo") - if err != nil { - if errors.Is(err, http.ErrMissingFile) { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Validation error", - "errors": "Photo field is required", - "request_id": requestId, - }) - } - - sentryHub.CaptureException(err) - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Reading form file", - "errors": err.Error(), - "request_id": requestId, - }) - } - defer func() { - err := photoFile.Close() - if err != nil { - log.Error().Err(err).Str("request_id", requestId).Msg("Closing photo file") - } - }() - - photoExtension := path.Ext(photoFormHeader.Filename) - // Guard the content type, the only content type allowed is images. - if !slices.Contains([]string{".gif", ".jpeg", ".jpg", ".png", ".webp"}, photoExtension) { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Unknown photo file type", - "errors": "Unknown photo file type", - "request_id": requestId, - }) - } - - photoContentType := mime.TypeByExtension(photoExtension) - - err = s.ticketDomain.StorePaymentReceipt(span.Context(), email, photoFile, photoContentType) - if err != nil { - var validationError *ValidationError - if errors.As(err, &validationError) { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Validation error", - "errors": validationError.Error(), - "request_id": requestId, - }) - } - - if errors.Is(err, ErrUserEmailNotFound) { - return c.JSON(http.StatusPreconditionFailed, echo.Map{ - "message": "User not found", - "errors": err.Error(), - "request_id": requestId, - }) - } - - sentryHub.CaptureException(err) - return c.JSON(http.StatusInternalServerError, echo.Map{ - "message": "Internal server error", - "errors": "Internal server error", - "request_id": requestId, - }) - } - - return c.NoContent(http.StatusCreated) -} - -type DayTicketScanRequest struct { - Code string `json:"code"` - Key string `json:"key"` -} - -func (s *ServerDependency) DayTicketScan(c echo.Context) error { - requestId := c.Response().Header().Get(echo.HeaderXRequestID) - sentryHub := sentryecho.GetHubFromContext(c) - sentryHub.Scope().SetTag("request-id", requestId) - - span := sentry.StartSpan(c.Request().Context(), "http.server", sentry.WithTransactionName("POST /scan-tiket"), sentry.WithTransactionSource(sentry.SourceRoute)) - defer span.Finish() - - var requestBody DayTicketScanRequest - if err := c.Bind(&requestBody); err != nil { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Invalid request body", - "errors": err.Error(), - "request_id": requestId, - }) - } - - // Validate key - decodedPassphrase, err := hex.DecodeString(s.validateTicketKey) - if err != nil { - sentryHub.CaptureException(err) - return c.JSON(http.StatusInternalServerError, echo.Map{ - "message": "Internal server error", - "errors": "Internal server error", - "request_id": requestId, - }) - } - - if err := bcrypt.CompareHashAndPassword(decodedPassphrase, []byte(requestBody.Key)); err != nil { - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { - return c.JSON(http.StatusForbidden, echo.Map{ - "message": "Wrong passphrase", - "errors": "", - "request_id": requestId, - }) - } - - sentryHub.CaptureException(err) - return c.JSON(http.StatusInternalServerError, echo.Map{ - "message": "Internal server error", - "errors": "Internal server error", - "request_id": requestId, - }) - } - - email, name, student, err := s.ticketDomain.VerifyTicket(span.Context(), []byte(requestBody.Code)) - if err != nil { - var validationError *ValidationError - if errors.As(err, &validationError) { - return c.JSON(http.StatusBadRequest, echo.Map{ - "message": "Validation error", - "errors": validationError.Error(), - "request_id": requestId, - }) - } - - if errors.Is(err, ErrInvalidTicket) { - return c.JSON(http.StatusNotAcceptable, echo.Map{ - "message": "Invalid ticket", - "errors": err.Error(), - "request_id": requestId, - }) - } - - sentryHub.CaptureException(err) - return c.JSON(http.StatusInternalServerError, echo.Map{ - "message": "Internal server error", - "errors": "Internal server error", - "request_id": requestId, - }) - } - - return c.JSON(http.StatusOK, echo.Map{ - "message": "Ticket confirmed", - "student": student, - "name": name, - "email": email, - }) -} diff --git a/backend/server/administrator_login.go b/backend/server/administrator_login.go new file mode 100644 index 0000000..66dd536 --- /dev/null +++ b/backend/server/administrator_login.go @@ -0,0 +1,69 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5/middleware" +) + +type AdministratorLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + OtpCode string `json:"otp"` +} + +func (s *ServerDependency) AdministratorLogin(w http.ResponseWriter, r *http.Request) { + requestId := middleware.GetReqID(r.Context()) + sentry.GetHubFromContext(r.Context()).Scope().SetTag("request-id", requestId) + + if !s.featureFlag.EnableAdministratorMode { + w.WriteHeader(http.StatusNotFound) + return + } + + var requestBody AdministratorLoginRequest + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid request body", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + token, ok, err := s.administratorDomain.Authenticate(r.Context(), requestBody.Username, requestBody.Password, requestBody.OtpCode) + if err != nil { + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid authentication", + "request_id": requestId, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "token": token, + "request_id": requestId, + }) + return +} diff --git a/backend/server/administrator_mail_blast.go b/backend/server/administrator_mail_blast.go new file mode 100644 index 0000000..29138f6 --- /dev/null +++ b/backend/server/administrator_mail_blast.go @@ -0,0 +1,97 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + + "conf/mailer" + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5/middleware" +) + +type AdministratorMailBlastRequest struct { + Subject string `json:"subject"` + PlaintextBody string `json:"plaintextBody"` + HtmlBody string `json:"htmlBody"` + Recipients []AdministratorMailBlastRecipientRequest `json:"recipients"` +} + +type AdministratorMailBlastRecipientRequest struct { + Email string `json:"email"` + Name string `json:"name"` +} + +func (s *ServerDependency) AdministratorMailBlast(w http.ResponseWriter, r *http.Request) { + requestId := middleware.GetReqID(r.Context()) + sentry.GetHubFromContext(r.Context()).Scope().SetTag("request-id", requestId) + + if !s.featureFlag.EnableAdministratorMode { + w.WriteHeader(http.StatusNotFound) + return + } + + var requestBody AdministratorMailBlastRequest + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid request body", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + _, ok, err := s.administratorDomain.Validate(r.Context(), token) + if err != nil { + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid authentication", + "request_id": requestId, + }) + return + } + + var unsuccessfulDestinations []string + for _, recipient := range requestBody.Recipients { + mail := &mailer.Mail{ + RecipientName: recipient.Name, + RecipientEmail: recipient.Email, + Subject: requestBody.Subject, + PlainTextBody: strings.ReplaceAll(requestBody.PlaintextBody, "___REPLACE_WITH_NAME___", recipient.Name), + HtmlBody: strings.ReplaceAll(requestBody.HtmlBody, "___REPLACE_WITH_NAME___", recipient.Name), + } + + err := s.mailSender.Send(r.Context(), mail) + if err != nil { + unsuccessfulDestinations = append(unsuccessfulDestinations, recipient.Email) + sentry.GetHubFromContext(r.Context()).CaptureException(err) + continue + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Done", + "unsuccessful_destinations": unsuccessfulDestinations, + "request_id": requestId, + }) + return +} diff --git a/backend/server/day_ticket_scan.go b/backend/server/day_ticket_scan.go new file mode 100644 index 0000000..207cb30 --- /dev/null +++ b/backend/server/day_ticket_scan.go @@ -0,0 +1,132 @@ +package server + +import ( + "encoding/hex" + "encoding/json" + "errors" + "net/http" + + "conf/ticketing" + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5/middleware" + "golang.org/x/crypto/bcrypt" +) + +type DayTicketScanRequest struct { + Code string `json:"code"` + Key string `json:"key"` +} + +func (s *ServerDependency) DayTicketScan(w http.ResponseWriter, r *http.Request) { + requestId := middleware.GetReqID(r.Context()) + sentry.GetHubFromContext(r.Context()).Scope().SetTag("request-id", requestId) + + var requestBody DayTicketScanRequest + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid request body", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + // Validate key + decodedPassphrase, err := hex.DecodeString(s.validateTicketKey) + if err != nil { + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + if err := bcrypt.CompareHashAndPassword(decodedPassphrase, []byte(requestBody.Key)); err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Wrong passphrase", + "errors": "", + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + verifiedTicket, err := s.ticketDomain.VerifyTicket(r.Context(), []byte(requestBody.Code)) + if err != nil { + var validationError *ticketing.ValidationError + if errors.As(err, &validationError) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Validation error", + "errors": validationError.Error(), + "request_id": requestId, + }) + return + } + + if errors.Is(err, ticketing.ErrInvalidTicket) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid ticket", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + userEntry, err := s.userDomain.GetUserByEmail(r.Context(), verifiedTicket.Email) + if err != nil { + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Ticket confirmed", + "student": verifiedTicket.Student, + "name": userEntry.Name, + "type": userEntry.Type, + "email": verifiedTicket.Email, + }) + return +} diff --git a/backend/server/payment_proof.go b/backend/server/payment_proof.go new file mode 100644 index 0000000..a47413c --- /dev/null +++ b/backend/server/payment_proof.go @@ -0,0 +1,146 @@ +package server + +import ( + "encoding/json" + "errors" + "mime" + "net/http" + "path" + "slices" + + "conf/ticketing" + "conf/user" + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog/log" +) + +func (s *ServerDependency) UploadPaymentProof(w http.ResponseWriter, r *http.Request) { + requestId := middleware.GetReqID(r.Context()) + sentry.GetHubFromContext(r.Context()).Scope().SetTag("request-id", requestId) + + if !s.featureFlag.EnablePaymentProofUpload { + w.WriteHeader(http.StatusNotFound) + return + } + + if err := r.ParseMultipartForm(32 << 10); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Parsing error", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + email := r.FormValue("email") + if email == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Validation error", + "errors": "Email field is required", + "request_id": requestId, + }) + return + } + + photoFile, photoFormHeader, err := r.FormFile("photo") + if err != nil { + if errors.Is(err, http.ErrMissingFile) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Validation error", + "errors": "Photo field is required", + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Reading form file", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + defer func() { + err := photoFile.Close() + if err != nil { + log.Error().Err(err).Str("request_id", requestId).Msg("Closing photo file") + } + }() + + photoExtension := path.Ext(photoFormHeader.Filename) + // Guard the content type, the only content type allowed is images. + if !slices.Contains([]string{".gif", ".jpeg", ".jpg", ".png", ".webp"}, photoExtension) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Unknown photo file type", + "errors": "Unknown photo file type", + "request_id": requestId, + }) + return + } + + photoContentType := mime.TypeByExtension(photoExtension) + + userEntry, err := s.userDomain.GetUserByEmail(r.Context(), email) + if err != nil { + if errors.Is(err, user.ErrUserEmailNotFound) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "User not found", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + err = s.ticketDomain.StorePaymentReceipt(r.Context(), userEntry, photoFile, photoContentType) + if err != nil { + var validationError *ticketing.ValidationError + if errors.As(err, &validationError) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Validation error", + "errors": validationError.Error(), + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + w.WriteHeader(http.StatusCreated) + return +} diff --git a/backend/server/register_user.go b/backend/server/register_user.go new file mode 100644 index 0000000..ec130ab --- /dev/null +++ b/backend/server/register_user.go @@ -0,0 +1,77 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "conf/user" + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5/middleware" +) + +type RegisterUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` +} + +func (s *ServerDependency) RegisterUser(w http.ResponseWriter, r *http.Request) { + requestId := middleware.GetReqID(r.Context()) + sentry.GetHubFromContext(r.Context()).Scope().SetTag("request-id", requestId) + + if !s.featureFlag.EnableRegistration { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Registration is closed", + "request_id": requestId, + }) + return + } + + requestBody := RegisterUserRequest{} + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Invalid request body", + "errors": err.Error(), + "request_id": requestId, + }) + return + } + + err := s.userDomain.CreateParticipant( + r.Context(), + user.CreateParticipantRequest{ + Name: requestBody.Name, + Email: requestBody.Email, + }, + ) + if err != nil { + var validationError *user.ValidationError + if errors.As(err, &validationError) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "message": "Validation error", + "errors": validationError.Errors, + "request_id": requestId, + }) + return + } + + sentry.GetHubFromContext(r.Context()).CaptureException(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Internal server error", + "errors": "Internal server error", + "request_id": requestId, + }) + return + } + + w.WriteHeader(http.StatusCreated) + return +} diff --git a/backend/server/server.go b/backend/server/server.go new file mode 100644 index 0000000..5a612bb --- /dev/null +++ b/backend/server/server.go @@ -0,0 +1,109 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "time" + + "conf/administrator" + "conf/features" + "conf/mailer" + "conf/ticketing" + "conf/user" + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/cors" +) + +type ServerConfig struct { + UserDomain *user.UserDomain + TicketDomain *ticketing.TicketDomain + AdministratorDomain *administrator.AdministratorDomain + FeatureFlag *features.FeatureFlag + MailSender *mailer.Mailer + Environment string + ValidateTicketKey string + Hostname string + Port string +} + +type ServerDependency struct { + userDomain *user.UserDomain + ticketDomain *ticketing.TicketDomain + administratorDomain *administrator.AdministratorDomain + featureFlag *features.FeatureFlag + mailSender *mailer.Mailer + validateTicketKey string +} + +func NewServer(config *ServerConfig) (*http.Server, error) { + if config.UserDomain == nil { + return nil, fmt.Errorf("nil UserDomain") + } + + if config.TicketDomain == nil { + return nil, fmt.Errorf("nil TicketDomain") + } + + if config.AdministratorDomain == nil { + return nil, fmt.Errorf("nil AdministratorDomain") + } + + if config.FeatureFlag == nil { + return nil, fmt.Errorf("nil FeatureFlag") + } + + if config.MailSender == nil { + return nil, fmt.Errorf("nil MailSender") + } + + if config.ValidateTicketKey == "" { + return nil, fmt.Errorf("nil ValidateTicketKey") + } + + dependencies := &ServerDependency{ + userDomain: config.UserDomain, + ticketDomain: config.TicketDomain, + administratorDomain: config.AdministratorDomain, + featureFlag: config.FeatureFlag, + mailSender: config.MailSender, + validateTicketKey: config.ValidateTicketKey, + } + + r := chi.NewRouter() + + r.Use(sentryhttp.New(sentryhttp.Options{Repanic: false}).Handle) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + // NOTE: Only need to handle CORS, everything else is being handled by the API gateway + corsAllowedOrigins := []string{"https://conference.teknologiumum.com", "https://conf.teknologiumum.com"} + if config.Environment != "production" { + corsAllowedOrigins = append(corsAllowedOrigins, "http://localhost:3000") + } + r.Use(cors.New(cors.Options{ + AllowedOrigins: corsAllowedOrigins, + AllowedMethods: []string{http.MethodPost}, + AllowedHeaders: []string{"Authorization"}, + AllowCredentials: true, + MaxAge: 3600, // 1 day + }).Handler) + + r.Use(middleware.Heartbeat("/api/public/ping")) + + r.Post("/api/public/register-user", dependencies.RegisterUser) + r.Post("/api/public/upload-payment-proof", dependencies.UploadPaymentProof) + r.Post("/api/public/scan-ticket", dependencies.DayTicketScan) + + r.Post("/api/administrator/login", dependencies.AdministratorLogin) + + return &http.Server{ + Addr: net.JoinHostPort(config.Hostname, config.Port), + Handler: r, + ReadTimeout: time.Minute * 3, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Hour, + IdleTimeout: time.Hour, + }, nil +} diff --git a/backend/server_handler_action.go b/backend/server_handler_action.go new file mode 100644 index 0000000..f77ecfb --- /dev/null +++ b/backend/server_handler_action.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "time" + + "conf/administrator" + "conf/mailer" + "conf/nocodb" + "conf/server" + "conf/ticketing" + "conf/user" + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" + "gocloud.dev/blob" +) + +func ServerHandlerAction(ctx *cli.Context) error { + config, err := GetConfig(ctx.String("config-file-path")) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + err = sentry.Init(sentry.ClientOptions{ + Dsn: "", + Debug: config.Environment != "production", + SampleRate: 1.0, + EnableTracing: true, + TracesSampler: func(ctx sentry.SamplingContext) float64 { + if ctx.Span.Name == "GET /api/public/ping" { + return 0 + } + + return 0.2 + }, + Release: version, + Environment: config.Environment, + DebugWriter: log.Logger, + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + if config.Environment != "production" { + log.Debug().Interface("exceptions", event.Exception).Msg(event.Message) + } + + return event + }, + }) + if err != nil { + return fmt.Errorf("initializing Sentry: %w", err) + } + defer sentry.Flush(time.Minute) + + database, err := nocodb.NewClient(nocodb.ClientOptions{ + ApiToken: config.Database.NocoDbApiKey, + BaseUrl: config.Database.NocoDbBaseUrl, + HttpClient: &http.Client{Transport: NewSentryRoundTripper(http.DefaultTransport, nil)}, + Logger: log.Logger, + }) + if err != nil { + return fmt.Errorf("creating database client instance: %w", err) + } + + bucket, err := blob.OpenBucket(context.Background(), config.BlobUrl) + if err != nil { + return fmt.Errorf("opening bucket: %w", err) + } + defer func() { + err := bucket.Close() + if err != nil { + log.Warn().Err(err).Msg("Closing bucket") + } + }() + + signaturePrivateKey, err := hex.DecodeString(config.Signature.PrivateKey) + if err != nil { + return fmt.Errorf("invalid signature private key: %w", err) + } + + signaturePublicKey, err := hex.DecodeString(config.Signature.PublicKey) + if err != nil { + return fmt.Errorf("invalid signature public key: %w", err) + } + + mailSender := mailer.NewMailSender(&mailer.MailConfiguration{ + SmtpHostname: config.Mailer.Hostname, + SmtpPort: config.Mailer.Port, + SmtpFrom: config.Mailer.From, + SmtpPassword: config.Mailer.Password, + }) + + ticketDomain, err := ticketing.NewTicketDomain(database, bucket, signaturePrivateKey, signaturePublicKey, mailSender, config.Database.TicketingTableId) + if err != nil { + return fmt.Errorf("creating ticket domain: %w", err) + } + + userDomain, err := user.NewUserDomain(database, config.Database.UserTableId) + if err != nil { + return fmt.Errorf("creating user domain: %w", err) + } + + administratorDomain, err := administrator.NewAdministratorDomain(config.AdministratorUserMapping) + if err != nil { + return fmt.Errorf("creating administrator domain: %w", err) + } + + httpServer, err := server.NewServer(&server.ServerConfig{ + UserDomain: userDomain, + TicketDomain: ticketDomain, + AdministratorDomain: administratorDomain, + FeatureFlag: &config.FeatureFlags, + MailSender: mailSender, + Environment: config.Environment, + ValidateTicketKey: config.ValidateTicketKey, + Hostname: "", + Port: config.Port, + }) + if err != nil { + return fmt.Errorf("creating http server: %w", err) + } + + exitSig := make(chan os.Signal, 1) + signal.Notify(exitSig, os.Interrupt, os.Kill) + + go func() { + <-exitSig + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Error().Err(err).Msg("failed to shutdown server") + } + }() + + err = httpServer.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error().Err(err).Msg("serving http server") + } + + return nil +} diff --git a/backend/ticketing.go b/backend/ticketing.go deleted file mode 100644 index 8657830..0000000 --- a/backend/ticketing.go +++ /dev/null @@ -1,475 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "io" - "mime" - "strings" - "time" - - "github.com/getsentry/sentry-go" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/skip2/go-qrcode" - "gocloud.dev/blob" -) - -var ErrInvalidTicket = errors.New("invalid ticket") -var ErrUserEmailNotFound = errors.New("user email not found") - -type TicketDomain struct { - db *pgxpool.Pool - bucket *blob.Bucket - privateKey *ed25519.PrivateKey - publicKey *ed25519.PublicKey - mailer *Mailer -} - -func NewTicketDomain(db *pgxpool.Pool, bucket *blob.Bucket, privateKey ed25519.PrivateKey, publicKey ed25519.PublicKey, mailer *Mailer) (*TicketDomain, error) { - if db == nil { - return nil, fmt.Errorf("db is nil") - } - - if bucket == nil { - return nil, fmt.Errorf("bucket is nil") - } - - if privateKey == nil { - return nil, fmt.Errorf("privateKey is nil") - } - - if publicKey == nil { - return nil, fmt.Errorf("publicKey is nil") - } - - if mailer == nil { - return nil, fmt.Errorf("mailer is nil") - } - - return &TicketDomain{ - db: db, - bucket: bucket, - privateKey: &privateKey, - publicKey: &publicKey, - mailer: mailer, - }, nil -} - -// StorePaymentReceipt stores the photo and email combination into our datastore. -// This will be reviewed manually by the TeknumConf team. -func (t *TicketDomain) StorePaymentReceipt(ctx context.Context, email string, photo io.Reader, contentType string) error { - span := sentry.StartSpan(ctx, "ticket.store_payment_receipt") - defer span.Finish() - - var validationError ValidationError - if email == "" { - validationError.Errors = append(validationError.Errors, "email is empty") - } - - if photo == nil { - validationError.Errors = append(validationError.Errors, "photo is nil") - } - - if contentType == "" { - validationError.Errors = append(validationError.Errors, "contentType is empty") - } - - if len(validationError.Errors) > 0 { - return validationError - } - - // Write entry to postgres - conn, err := t.db.Acquire(ctx) - if err != nil { - return fmt.Errorf("acquiring connection from pool: %w", err) - } - defer conn.Release() - - tx, err := conn.BeginTx(ctx, pgx.TxOptions{ - IsoLevel: pgx.ReadCommitted, - }) - if err != nil { - return fmt.Errorf("creating transaction: %w", err) - } - - var userID string - err = tx.QueryRow(ctx, `SELECT id FROM users WHERE email = $1`, email).Scan(&userID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - if e := tx.Rollback(ctx); e != nil { - return fmt.Errorf("rolling back transaction: %w (%s)", e, ErrUserEmailNotFound.Error()) - } - - return ErrUserEmailNotFound - } - - if e := tx.Rollback(ctx); e != nil { - return fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return fmt.Errorf("executing select query: %w", err) - } - - fileExtensions, _ := mime.ExtensionsByType(contentType) - if len(fileExtensions) == 0 { - fileExtensions = []string{""} // length is not zero, we can safely call fileExtensions[0] - } - - // Store photo to filesystem (please use this one https://pkg.go.dev/gocloud.dev@v0.34.0/blob) - blobKey := fmt.Sprintf("%s_%s.%s", time.Now().Format(time.RFC3339), email, fileExtensions[0]) - err = t.bucket.Upload(ctx, blobKey, photo, &blob.WriterOptions{ - ContentType: contentType, - Metadata: map[string]string{ - "email": email, - }, - }) - if err != nil { - return fmt.Errorf("uploading to bucket storage: %w", err) - } - - _, err = tx.Exec( - ctx, - `INSERT INTO ticketing (id, email, receipt_photo_path) VALUES ($1, $2, $3) - ON CONFLICT (email) DO UPDATE SET receipt_photo_path = $3, updated_at = NOW()`, - uuid.New(), - email, - blobKey) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return fmt.Errorf("executing query: %w", err) - } - - err = tx.Commit(ctx) - if err != nil { - return fmt.Errorf("commiting transaction: %w", err) - } - - return nil -} - -// ValidatePaymentReceipt marks an email payment status as paid. It will create a signature using Ed25519, -// encode it to a QRCode image, and send the QRCode to the user's email. It returns hex-encoded SHA256SUM -// of the QR code. -// -// It will return ErrInvalidTicket if the payment receipt's not uploaded yet. -func (t *TicketDomain) ValidatePaymentReceipt(ctx context.Context, email string) (string, error) { - span := sentry.StartSpan(ctx, "ticket.validate_payment_receipt") - defer span.Finish() - - if email == "" { - return "", ValidationError{Errors: []string{"email is empty"}} - } - - // Mark payment status as paid on postgres - conn, err := t.db.Acquire(ctx) - if err != nil { - return "", fmt.Errorf("acquiring connection from pool: %w", err) - } - defer conn.Release() - - tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) - if err != nil { - return "", fmt.Errorf("creating transaction: %w", err) - } - - var id uuid.UUID - err = tx.QueryRow(ctx, `SELECT id FROM ticketing WHERE email = $1`, email).Scan(&id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return "", fmt.Errorf("%w: not exists", ErrInvalidTicket) - } - - if e := tx.Rollback(ctx); e != nil { - return "", fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", fmt.Errorf("executing select query: %w", err) - } - - _, err = tx.Exec(ctx, `UPDATE ticketing SET paid = TRUE, updated_at = NOW() WHERE email = $1`, email) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", fmt.Errorf("executing update query: %w", err) - } - - // Create a signature using unique key based on the email and random id combination (possibly using any non-text based encoding) - sha384Hasher := sha512.New384() - sha384Hasher.Write([]byte(email)) - hashedEmail := sha384Hasher.Sum(nil) - payload := fmt.Sprintf("%s:%s", id.String(), base64.StdEncoding.EncodeToString(hashedEmail)) - - signature := ed25519.Sign(*t.privateKey, []byte(payload)) - - // Generate QR code with https://github.com/skip2/go-qrcode - qrImage, err := qrcode.Encode(fmt.Sprintf("%s;%s", hex.EncodeToString(signature), payload), qrcode.High, 1024) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", fmt.Errorf("generating qr code: %w", err) - } - - // Create SHA256SUM to the generated QR code - sha256Hasher := sha256.New() - sha256Hasher.Write(qrImage) - sha256Sum := sha256Hasher.Sum(nil) - - imageCid, _, _ := strings.Cut(uuid.NewString(), "-") - - // Send email programmatically - err = t.mailer.Send(ctx, &Mail{ - RecipientName: "", - RecipientEmail: email, - Subject: "TeknumConf 2023: Tiket Anda!", - PlainTextBody: `Hai! Ini dia email yang kamu tunggu-tunggu💃 - -Pembayaran kamu telah di konfirmasi! Dibawah ini terdapat QR code sebagai tiket kamu masuk ke TeknumConf 2023. -Apabila kamu mendapat student discount, pastikan kamu membawa Kartu Mahasiswa atau Kartu Pelajar ya! -Panitia akan melakukan verifikasi tambahan pada lokasi untuk memastikan kalau kamu betulan pelajar. - -Sampai jumpa di TeknumConf 2023! - -Email ini hanya tertuju untuk Anda. Apabila Anda merasa tidak mendaftar untuk TeknumConf 2023, -harap abaikan email ini. Terima kasih!`, - HtmlBody: ` - - - - - - - - - - - TeknumConf 2023: Tiket Anda! - - -

Hai! Ini dia email yang kamu tunggu-tunggu💃

-

- Pembayaran kamu telah di konfirmasi! Dibawah ini terdapat QR code sebagai tiket kamu masuk ke TeknumConf 2023. - Apabila kamu mendapat student discount, pastikan kamu membawa Kartu Mahasiswa atau Kartu Pelajar ya! - Panitia akan melakukan verifikasi tambahan pada lokasi untuk memastikan kalau kamu betulan pelajar. -

-

Sampai jumpa di TeknumConf 2023!

-

-

- - Email ini hanya tertuju untuk Anda. Apabila Anda merasa tidak mendaftar untuk TeknumConf 2023, - harap abaikan email ini. Terima kasih! - -

- - -`, - Attachments: []Attachment{ - { - Name: "qrcode_ticket.png", - Description: "QR code ticket TeknumConf 2023", - ContentType: "image/png", - ContentDisposition: ContentDispositionInline, - ContentId: imageCid, - SHA256Checksum: sha256Sum, - Payload: qrImage, - }, - }, - }) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", fmt.Errorf("sending mail: %w", err) - } - - _, err = tx.Exec(ctx, `UPDATE ticketing SET sha256sum = $1, updated_at = NOW() WHERE email = $2`, sha256Sum, email) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", fmt.Errorf("executing select query: %w", err) - } - - err = tx.Commit(ctx) - if err != nil { - return "", fmt.Errorf("commiting transaction: %w", err) - } - - return hex.EncodeToString(sha256Sum), nil -} - -// VerifyTicket will verify a ticket from the QR code payload. It will disassemble the payload and validate -// the signature and mark the ticket as used. Each ticket can only be used once. -// -// If the signature is invalid or the ticket is used, it will return ErrInvalidTicket error. -func (t *TicketDomain) VerifyTicket(ctx context.Context, payload []byte) (email string, name string, student bool, err error) { - span := sentry.StartSpan(ctx, "ticket.verify_ticket") - defer span.Finish() - - if len(payload) == 0 { - return "", "", false, ValidationError{Errors: []string{"payload is empty"}} - } - - // Separate the payload into the signature + email + random id that's generated from ValidatePaymentReceipt - rawSignature, payloadAfter, found := bytes.Cut(payload, []byte(";")) - if !found { - return "", "", false, ErrInvalidTicket - } - - rawTicketId, rawHashedEmail, found := bytes.Cut(payloadAfter, []byte(":")) - if !found { - return "", "", false, ErrInvalidTicket - } - - ticketId, err := uuid.ParseBytes(rawTicketId) - if err != nil { - return "", "", false, ErrInvalidTicket - } - - userHashedEmail, err := base64.StdEncoding.DecodeString(string(rawHashedEmail)) - if err != nil { - return "", "", false, fmt.Errorf("decoding base64 string for email: %w", err) - } - - signature, err := hex.DecodeString(string(rawSignature)) - if err != nil { - return "", "", false, fmt.Errorf("decoding hex string for signature: %w", err) - } - - // Validate the signature and its message using ed25519. If it's invalid, return ErrInvalidTicket - signatureValidated := ed25519.Verify(*t.publicKey, payloadAfter, signature) - if !signatureValidated { - return "", "", false, fmt.Errorf("%w (verifying signature)", ErrInvalidTicket) - } - - // Check the ticket if it's been used before. If it is, return ErrInvalidTicket. Decorate it a bit. - conn, err := t.db.Acquire(ctx) - if err != nil { - return "", "", false, fmt.Errorf("acquiring connection from pool: %w", err) - } - defer conn.Release() - - tx, err := conn.BeginTx(ctx, pgx.TxOptions{ - IsoLevel: pgx.RepeatableRead, - AccessMode: pgx.ReadWrite, - }) - if err != nil { - return "", "", false, fmt.Errorf("creating transaction: %w", err) - } - - err = tx.QueryRow(ctx, "SELECT email, student FROM ticketing WHERE id = $1 AND used = FALSE", ticketId).Scan(&email, &student) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", "", false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - if errors.Is(err, pgx.ErrNoRows) { - return "", "", false, fmt.Errorf("%w (id not exists, or ticket has been used)", ErrInvalidTicket) - } - - return "", "", false, fmt.Errorf("acquiring data from table: %w", err) - } - - err = tx.QueryRow(ctx, "SELECT name FROM users WHERE email = $1", email).Scan(&name) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", "", false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - // Do not return error if something's wrong with name. - // Instead, just report to Sentry. - if !errors.Is(err, pgx.ErrNoRows) { - sentry.GetHubFromContext(ctx).CaptureException(err) - } - } - - // Validate email - sha384Hasher := sha512.New384() - sha384Hasher.Write([]byte(email)) - hashedEmail := sha384Hasher.Sum(nil) - if !bytes.Equal(hashedEmail, userHashedEmail) { - return "", "", false, fmt.Errorf("%w (mismatched email)", ErrInvalidTicket) - } - - // Mark the ticket as used - _, err = tx.Exec(ctx, "UPDATE ticketing SET used = TRUE WHERE id = $1", ticketId) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - return "", "", false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) - } - - return "", "", false, fmt.Errorf("acquiring data from table: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return "", "", false, fmt.Errorf("commiting transaction: %w", err) - } - - return email, name, student, nil -} - -func (t *TicketDomain) VerifyIsStudent(ctx context.Context, email string) (err error) { - span := sentry.StartSpan(ctx, "ticket.verify_is_student") - defer span.Finish() - - if email == "" { - return ValidationError{Errors: []string{"email is empty"}} - } - - c, err := t.db.Acquire(ctx) - if err != nil { - return - } - defer c.Release() - - tx, err := c.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) - if err != nil { - return - } - - _, err = tx.Exec(ctx, "UPDATE ticketing SET student = TRUE WHERE email = $1", email) - if err != nil { - if e := tx.Rollback(ctx); e != nil { - err = e - return - } - - return - } - - err = tx.Commit(ctx) - if err != nil { - return - } - - return nil -} diff --git a/backend/ticketing/errors.go b/backend/ticketing/errors.go new file mode 100644 index 0000000..cc3d75b --- /dev/null +++ b/backend/ticketing/errors.go @@ -0,0 +1,16 @@ +package ticketing + +import ( + "errors" + "strings" +) + +type ValidationError struct { + Errors []string +} + +func (v ValidationError) Error() string { + return strings.Join(v.Errors, ", ") +} + +var ErrInvalidTicket = errors.New("invalid ticket") diff --git a/backend/ticketing/store_payment_receipt.go b/backend/ticketing/store_payment_receipt.go new file mode 100644 index 0000000..cbb107a --- /dev/null +++ b/backend/ticketing/store_payment_receipt.go @@ -0,0 +1,66 @@ +package ticketing + +import ( + "context" + "fmt" + "io" + "mime" + "time" + + "conf/user" + "github.com/getsentry/sentry-go" + "gocloud.dev/blob" +) + +// StorePaymentReceipt stores the photo and email combination into our datastore. +// This will be reviewed manually by the TeknumConf team. +func (t *TicketDomain) StorePaymentReceipt(ctx context.Context, user user.User, photo io.Reader, contentType string) error { + span := sentry.StartSpan(ctx, "ticketing.store_payment_receipt", sentry.WithTransactionName("StorePaymentReceipt")) + defer span.Finish() + + var validationError ValidationError + if photo == nil { + validationError.Errors = append(validationError.Errors, "photo is nil") + } + + if contentType == "" { + validationError.Errors = append(validationError.Errors, "contentType is empty") + } + + if len(validationError.Errors) > 0 { + return validationError + } + + // Write entry to database + fileExtensions, _ := mime.ExtensionsByType(contentType) + if len(fileExtensions) == 0 { + fileExtensions = []string{""} // length is not zero, we can safely call fileExtensions[0] + } + + // Store photo to filesystem (please use this one https://pkg.go.dev/gocloud.dev@v0.34.0/blob) + blobKey := fmt.Sprintf("%s_%s.%s", time.Now().Format(time.RFC3339), user.Email, fileExtensions[0]) + err := t.bucket.Upload(ctx, blobKey, photo, &blob.WriterOptions{ + ContentType: contentType, + Metadata: map[string]string{ + "email": user.Email, + }, + }) + if err != nil { + return fmt.Errorf("uploading to bucket storage: %w", err) + } + + err = t.db.CreateTableRecords(ctx, t.tableId, []any{Ticketing{ + Email: user.Email, + ReceiptPhotoPath: blobKey, + Paid: false, + SHA256Sum: "", + Used: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }}) + if err != nil { + return fmt.Errorf("inserting ticketing entry into database: %w", err) + } + + return nil +} diff --git a/backend/ticketing/store_payment_receipt_test.go b/backend/ticketing/store_payment_receipt_test.go new file mode 100644 index 0000000..52484b2 --- /dev/null +++ b/backend/ticketing/store_payment_receipt_test.go @@ -0,0 +1,95 @@ +package ticketing_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "strings" + "testing" + "time" + + "conf/ticketing" + "conf/user" +) + +func TestTicketDomain_StorePaymentReceipt(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating new ed25519 key: %s", err.Error()) + return + } + + ticketDomain, err := ticketing.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender, tableId) + if err != nil { + t.Fatalf("creating a ticket domain instance: %s", err.Error()) + } + + userDomain, err := user.NewUserDomain(database, "testing") + if err != nil { + t.Fatalf("creating user domain instance: %s", err.Error()) + } + + t.Run("Invalid photo", func(t *testing.T) { + err := ticketDomain.StorePaymentReceipt(context.Background(), user.User{}, nil, "") + if err == nil { + t.Error("expecting an error, got nil instead") + } + + var validationError *ticketing.ValidationError + if errors.As(err, &validationError) { + if len(validationError.Errors) != 2 { + t.Errorf("expecting two errors, got %d", len(validationError.Errors)) + } + } + }) + + t.Run("Happy scenario", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + email := "johndoe+happy@example.com" + err := userDomain.CreateParticipant(ctx, user.CreateParticipantRequest{ + Name: "John Doe", + Email: email, + }) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + err = ticketDomain.StorePaymentReceipt(ctx, user.User{Email: email}, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + }) + + t.Run("Update data if email already exists", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + email := "johndoe+happy@example.com" + err := userDomain.CreateParticipant(ctx, user.CreateParticipantRequest{ + Name: "John Doe", + Email: email, + }) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + user := user.User{ + Email: email, + } + + // First attempt + err = ticketDomain.StorePaymentReceipt(ctx, user, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + // Second attempt, should not return error + err = ticketDomain.StorePaymentReceipt(ctx, user, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + }) +} diff --git a/backend/ticketing/ticketing.go b/backend/ticketing/ticketing.go new file mode 100644 index 0000000..8bdb0a4 --- /dev/null +++ b/backend/ticketing/ticketing.go @@ -0,0 +1,114 @@ +package ticketing + +import ( + "crypto/ed25519" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" + "time" + + "conf/mailer" + "conf/nocodb" + + "gocloud.dev/blob" +) + +type TicketDomain struct { + db *nocodb.Client + tableId string + bucket *blob.Bucket + privateKey *ed25519.PrivateKey + publicKey *ed25519.PublicKey + mailer *mailer.Mailer +} + +func NewTicketDomain(db *nocodb.Client, bucket *blob.Bucket, privateKey ed25519.PrivateKey, publicKey ed25519.PublicKey, mailer *mailer.Mailer, tableId string) (*TicketDomain, error) { + if db == nil { + return nil, fmt.Errorf("db is nil") + } + + if bucket == nil { + return nil, fmt.Errorf("bucket is nil") + } + + if privateKey == nil { + return nil, fmt.Errorf("privateKey is nil") + } + + if publicKey == nil { + return nil, fmt.Errorf("publicKey is nil") + } + + if mailer == nil { + return nil, fmt.Errorf("mailer is nil") + } + + if tableId == "" { + return nil, fmt.Errorf("tableId is nil") + } + + return &TicketDomain{ + db: db, + bucket: bucket, + privateKey: &privateKey, + publicKey: &publicKey, + mailer: mailer, + tableId: tableId, + }, nil +} + +type Ticketing struct { + Id int64 `json:"Id,omitempty"` + Email string `json:"Email,omitempty"` + ReceiptPhotoPath string `json:"ReceiptPhotoPath,omitempty"` + Paid bool `json:"Paid,omitempty"` + Student bool `json:"Student,omitempty"` + SHA256Sum string `json:"SHA256Sum,omitempty"` + Used bool `json:"Used,omitempty"` + CreatedAt time.Time `json:"CreatedAt,omitempty"` + UpdatedAt time.Time `json:"UpdatedAt,omitempty"` +} + +type NullTicketing struct { + Id sql.NullInt64 `json:"Id,omitempty"` + Email sql.NullString `json:"Email,omitempty"` + ReceiptPhotoPath sql.NullString `json:"ReceiptPhotoPath,omitempty"` + Paid sql.NullBool `json:"Paid,omitempty"` + Student sql.NullBool `json:"Student,omitempty"` + SHA256Sum sql.NullString `json:"SHA256Sum,omitempty"` + Used sql.NullBool `json:"Used,omitempty"` + CreatedAt sql.NullTime `json:"CreatedAt,omitempty"` + UpdatedAt sql.NullTime `json:"UpdatedAt,omitempty"` +} + +func (t NullTicketing) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{} + + ut := reflect.TypeOf(t) + uv := reflect.ValueOf(t) + + for i := 0; i < ut.NumField(); i++ { + field := ut.Field(i) + intf := uv.Field(i).Interface() + valuer, ok := intf.(driver.Valuer) + if ok { + v, err := valuer.Value() + if err != nil { + continue + } + + if v == nil { + continue + } + + m[field.Name] = v + continue + } + + m[field.Name] = intf + } + + return json.Marshal(m) +} diff --git a/backend/ticketing/ticketing_test.go b/backend/ticketing/ticketing_test.go new file mode 100644 index 0000000..109e9d6 --- /dev/null +++ b/backend/ticketing/ticketing_test.go @@ -0,0 +1,188 @@ +package ticketing_test + +import ( + "context" + "crypto/ed25519" + "database/sql" + "encoding/json" + "os" + "testing" + "time" + + "conf/mailer" + "conf/nocodb" + "conf/nocodb/nocodbmock" + "conf/ticketing" + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "gocloud.dev/blob" + _ "gocloud.dev/blob/fileblob" +) + +var database *nocodb.Client +var bucket *blob.Bucket +var mailSender *mailer.Mailer +var tableId = "ticketing" + +func TestMain(m *testing.M) { + tempDir, err := os.MkdirTemp(os.TempDir(), "teknologi-umum-conference") + if err != nil { + log.Fatal().Err(err).Msg("creating temporary directory") + return + } + + blobUrl, ok := os.LookupEnv("BLOB_URL") + if !ok { + blobUrl = "file://" + tempDir + } + + smtpHostname, ok := os.LookupEnv("SMTP_HOSTNAME") + if !ok { + smtpHostname = "localhost" + } + smtpPort, ok := os.LookupEnv("SMTP_PORT") + if !ok { + smtpPort = "1025" + } + smtpFrom, ok := os.LookupEnv("SMTP_FROM") + if !ok { + smtpFrom = "" + } + smtpPassword, ok := os.LookupEnv("SMTP_PASSWORD") + if !ok { + smtpPassword = "" + } + + _ = sentry.Init(sentry.ClientOptions{}) + + nocodbMockServer, err := nocodbmock.NewNocoDBMockServer() + if err != nil { + log.Fatal().Err(err).Msg("creating nocodb mock server") + return + } + + database, err = nocodb.NewClient(nocodb.ClientOptions{ + ApiToken: "testing", + BaseUrl: nocodbMockServer.URL, + HttpClient: nocodbMockServer.Client(), + Logger: log.Logger, + }) + if err != nil { + log.Fatal().Err(err).Msg("creating nocodb client") + return + } + + bucket, err = blob.OpenBucket(context.Background(), blobUrl) + if err != nil { + log.Fatal().Err(err).Msg("creating bucket instance") + return + } + + mailSender = mailer.NewMailSender(&mailer.MailConfiguration{ + SmtpHostname: smtpHostname, + SmtpPort: smtpPort, + SmtpFrom: smtpFrom, + SmtpPassword: smtpPassword, + }) + + exitCode := m.Run() + + _ = os.RemoveAll(tempDir) + _ = bucket.Close() + nocodbMockServer.Close() + + os.Exit(exitCode) +} + +func TestNewTicketDomain(t *testing.T) { + // Create mock dependencies. + db := &nocodb.Client{} + bucket := &blob.Bucket{} + privateKey := ed25519.PrivateKey{} + publicKey := ed25519.PublicKey{} + mailSender := &mailer.Mailer{} + + // Group the tests with t.Run(). + t.Run("all dependencies set", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(db, bucket, privateKey, publicKey, mailSender, "asd") + if err != nil { + t.Errorf("NewTicketDomain failed: %v", err) + } + if ticketDomain == nil { + t.Error("NewTicketDomain returned nil ticketDomain") + } + }) + + t.Run("nil database", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(nil, bucket, privateKey, publicKey, mailSender, "") + if err == nil { + t.Error("NewTicketDomain did not return error with nil database") + } + if ticketDomain != nil { + t.Error("NewTicketDomain returned non-nil ticketDomain with nil database") + } + }) + + t.Run("nil bucket", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(db, nil, privateKey, publicKey, mailSender, "") + if err == nil { + t.Error("NewTicketDomain did not return error with nil bucket") + } + if ticketDomain != nil { + t.Error("NewTicketDomain returned non-nil ticketDomain with nil bucket") + } + }) + + t.Run("nil private key", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(db, bucket, nil, publicKey, mailSender, "") + if err == nil { + t.Error("NewTicketDomain did not return error with nil private key") + } + if ticketDomain != nil { + t.Error("NewTicketDomain returned non-nil ticketDomain with nil private key") + } + }) + + t.Run("nil public key", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(db, bucket, privateKey, nil, mailSender, "") + if err == nil { + t.Error("NewTicketDomain did not return error with nil public key") + } + if ticketDomain != nil { + t.Error("NewTicketDomain returned non-nil ticketDomain with nil public key") + } + }) + + t.Run("nil mailSender", func(t *testing.T) { + ticketDomain, err := ticketing.NewTicketDomain(db, bucket, privateKey, publicKey, nil, "") + if err == nil { + t.Error("NewTicketDomain did not return error with nil mailSender") + } + if ticketDomain != nil { + t.Error("NewTicketDomain returned non-nil ticketDomain with nil mailSender") + } + }) +} + +func TestNullTicketing_MarshalJSON(t *testing.T) { + n := ticketing.NullTicketing{ + Id: sql.NullInt64{Valid: true, Int64: 123}, + Email: sql.NullString{}, + ReceiptPhotoPath: sql.NullString{}, + Paid: sql.NullBool{}, + SHA256Sum: sql.NullString{}, + Used: sql.NullBool{}, + CreatedAt: sql.NullTime{Valid: false}, + UpdatedAt: sql.NullTime{Valid: true, Time: time.Time{}}, + } + + out, err := json.Marshal(n) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + expect := `{"Id":123,"UpdatedAt":"0001-01-01T00:00:00Z"}` + if string(out) != expect { + t.Errorf("expecting %s, got %s", expect, string(out)) + } +} diff --git a/backend/ticketing/validate_payment_receipt.go b/backend/ticketing/validate_payment_receipt.go new file mode 100644 index 0000000..9ac2c9a --- /dev/null +++ b/backend/ticketing/validate_payment_receipt.go @@ -0,0 +1,156 @@ +package ticketing + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/sha512" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "conf/mailer" + "conf/nocodb" + "conf/user" + "github.com/getsentry/sentry-go" + "github.com/google/uuid" + "github.com/skip2/go-qrcode" +) + +// ValidatePaymentReceipt marks an email payment status as paid. It will create a signature using Ed25519, +// encode it to a QRCode image, and send the QRCode to the user's email. It returns hex-encoded SHA256SUM +// of the QR code. +// +// It will return ErrInvalidTicket if the payment receipt's not uploaded yet. +func (t *TicketDomain) ValidatePaymentReceipt(ctx context.Context, user user.User) (string, error) { + span := sentry.StartSpan(ctx, "ticketing.validate_payment_receipt", sentry.WithTransactionName("ValidatePaymentReceipt")) + defer span.Finish() + + // Mark payment status as paid on database + var rawTicketingResults []Ticketing + _, err := t.db.ListTableRecords(ctx, t.tableId, &rawTicketingResults, nocodb.ListTableRecordOptions{ + Where: fmt.Sprintf("(Email,eq,%s)", user.Email), + Sort: []nocodb.Sort{nocodb.SortDescending("CreatedAt")}, + Limit: 1, + }) + if err != nil { + return "", fmt.Errorf("acquiring records: %w", err) + } + + if len(rawTicketingResults) == 0 { + return "", fmt.Errorf("%w: not exists", ErrInvalidTicket) + } + + var ticketing = rawTicketingResults[0] + + // Create a signature using unique key based on the email and random id combination (possibly using any non-text based encoding) + sha384Hasher := sha512.New384() + sha384Hasher.Write([]byte(user.Email)) + hashedEmail := sha384Hasher.Sum(nil) + payload := fmt.Sprintf("%s:%s", strconv.FormatInt(ticketing.Id, 10), base64.StdEncoding.EncodeToString(hashedEmail)) + + signature := ed25519.Sign(*t.privateKey, []byte(payload)) + + // Generate QR code with https://github.com/skip2/go-qrcode + qrImage, err := qrcode.Encode(fmt.Sprintf("%s;%s", hex.EncodeToString(signature), payload), qrcode.High, 1024) + if err != nil { + return "", fmt.Errorf("generating qr code: %w", err) + } + + // Create SHA256SUM to the generated QR code + sha256Hasher := sha256.New() + sha256Hasher.Write(qrImage) + sha256Sum := sha256Hasher.Sum(nil) + + imageCid, _, _ := strings.Cut(uuid.NewString(), "-") + + // Send email programmatically + err = t.mailer.Send(ctx, &mailer.Mail{ + RecipientName: "", + RecipientEmail: user.Email, + Subject: "TeknumConf 2023: Tiket Anda!", + PlainTextBody: `Hai! Ini dia email yang kamu tunggu-tunggu💃 + +Pembayaran kamu telah di konfirmasi! Dibawah ini terdapat QR code sebagai tiket kamu masuk ke TeknumConf 2023. +Apabila kamu mendapat student discount, pastikan kamu membawa Kartu Mahasiswa atau Kartu Pelajar ya! +Panitia akan melakukan verifikasi tambahan pada lokasi untuk memastikan kalau kamu betulan pelajar. + +Sampai jumpa di TeknumConf 2023! + +Email ini hanya tertuju untuk Anda. Apabila Anda merasa tidak mendaftar untuk TeknumConf 2023, +harap abaikan email ini. Terima kasih!`, + HtmlBody: ` + + + + + + + + + + + TeknumConf 2023: Tiket Anda! + + +

Hai! Ini dia email yang kamu tunggu-tunggu💃

+

+ Pembayaran kamu telah di konfirmasi! Dibawah ini terdapat QR code sebagai tiket kamu masuk ke TeknumConf 2023. + Apabila kamu mendapat student discount, pastikan kamu membawa Kartu Mahasiswa atau Kartu Pelajar ya! + Panitia akan melakukan verifikasi tambahan pada lokasi untuk memastikan kalau kamu betulan pelajar. +

+

Sampai jumpa di TeknumConf 2023!

+

+

+ + Email ini hanya tertuju untuk Anda. Apabila Anda merasa tidak mendaftar untuk TeknumConf 2023, + harap abaikan email ini. Terima kasih! + +

+ + +`, + Attachments: []mailer.Attachment{ + { + Name: "qrcode_ticket.png", + Description: "QR code ticket TeknumConf 2023", + ContentType: "image/png", + ContentDisposition: mailer.ContentDispositionInline, + ContentId: imageCid, + SHA256Checksum: sha256Sum, + Payload: qrImage, + }, + }, + }) + if err != nil { + return "", fmt.Errorf("sending mail: %w", err) + } + + err = t.db.UpdateTableRecords(ctx, t.tableId, []any{NullTicketing{ + Id: sql.NullInt64{Int64: ticketing.Id, Valid: true}, + Paid: sql.NullBool{Bool: true, Valid: true}, + SHA256Sum: sql.NullString{String: hex.EncodeToString(sha256Sum), Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }}) + if err != nil { + return "", fmt.Errorf("updating table records: %w", err) + } + + return hex.EncodeToString(sha256Sum), nil +} diff --git a/backend/ticketing/validate_payment_receipt_test.go b/backend/ticketing/validate_payment_receipt_test.go new file mode 100644 index 0000000..bba88b5 --- /dev/null +++ b/backend/ticketing/validate_payment_receipt_test.go @@ -0,0 +1,78 @@ +package ticketing_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "strings" + "testing" + "time" + + "conf/ticketing" + "conf/user" +) + +func TestTicketDomain_ValidatePaymentReceipt(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating new ed25519 key: %s", err.Error()) + return + } + + ticketDomain, err := ticketing.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender, tableId) + if err != nil { + t.Fatalf("creating a ticket domain instance: %s", err.Error()) + } + + userDomain, err := user.NewUserDomain(database, "testing") + if err != nil { + t.Fatalf("creating user domain instance: %s", err.Error()) + } + + t.Run("Email not found", func(t *testing.T) { + t.Skip("We don't do any parameter checking on mock server") + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + _, err := ticketDomain.ValidatePaymentReceipt(ctx, user.User{Email: "not-found@example.com"}) + if err == nil { + t.Error("expecting an error, got nil") + } + + if !errors.Is(err, ticketing.ErrInvalidTicket) { + t.Errorf("expecting an error of ErrInvalidTicket, instead got %s", err.Error()) + } + }) + + t.Run("Happy scenario", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + email := "johndoe+happy@example.com" + err := userDomain.CreateParticipant(ctx, user.CreateParticipantRequest{ + Name: "John Doe", + Email: email, + }) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + user := user.User{Email: email} + + err = ticketDomain.StorePaymentReceipt(ctx, user, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + + sum, err := ticketDomain.ValidatePaymentReceipt(ctx, user) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if sum == "" { + t.Error("expecting sum to have value, got empty string") + } + }) +} diff --git a/backend/ticketing/verify_student.go b/backend/ticketing/verify_student.go new file mode 100644 index 0000000..57fe9cf --- /dev/null +++ b/backend/ticketing/verify_student.go @@ -0,0 +1,44 @@ +package ticketing + +import ( + "context" + "database/sql" + "fmt" + "time" + + "conf/nocodb" + "conf/user" + "github.com/getsentry/sentry-go" +) + +func (t *TicketDomain) VerifyIsStudent(ctx context.Context, user user.User) (err error) { + span := sentry.StartSpan(ctx, "ticketing.verify_is_student", sentry.WithTransactionName("VerifyIsStudent")) + defer span.Finish() + + var rawTicketingResults []Ticketing + _, err = t.db.ListTableRecords(ctx, t.tableId, &rawTicketingResults, nocodb.ListTableRecordOptions{ + Where: fmt.Sprintf("(Email,eq,%s)", user.Email), + Sort: []nocodb.Sort{nocodb.SortDescending("CreatedAt")}, + Limit: 1, + }) + if err != nil { + return fmt.Errorf("acquiring records: %w", err) + } + + if len(rawTicketingResults) == 0 { + return fmt.Errorf("%w: not exists", ErrInvalidTicket) + } + + var ticketing = rawTicketingResults[0] + + err = t.db.UpdateTableRecords(ctx, "TODO: Table Id", []any{NullTicketing{ + Id: sql.NullInt64{Int64: ticketing.Id, Valid: true}, + Student: sql.NullBool{Bool: true, Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }}) + if err != nil { + return fmt.Errorf("updating table records: %w", err) + } + + return nil +} diff --git a/backend/ticketing/verify_student_test.go b/backend/ticketing/verify_student_test.go new file mode 100644 index 0000000..c66394f --- /dev/null +++ b/backend/ticketing/verify_student_test.go @@ -0,0 +1,35 @@ +package ticketing_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "testing" + "time" + + "conf/ticketing" + "conf/user" +) + +func TestTicketDomain_VerifyIsStudent(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generating new ed25519 key: %s", err.Error()) + return + } + + ticketDomain, err := ticketing.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender, tableId) + if err != nil { + t.Fatalf("creating a ticket domain instance: %s", err.Error()) + } + + t.Run("Happy scenario", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + err := ticketDomain.VerifyIsStudent(ctx, user.User{Email: "aji@test.com"}) + if err != nil { + t.Errorf("unexpected error: %s", err.Error()) + } + }) +} diff --git a/backend/ticketing/verify_ticket.go b/backend/ticketing/verify_ticket.go new file mode 100644 index 0000000..7d44ee0 --- /dev/null +++ b/backend/ticketing/verify_ticket.go @@ -0,0 +1,99 @@ +package ticketing + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha512" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "time" + + "conf/nocodb" + "github.com/getsentry/sentry-go" +) + +// VerifyTicket will verify a ticket from the QR code payload. It will disassemble the payload and validate +// the signature and mark the ticket as used. Each ticket can only be used once. +// +// If the signature is invalid or the ticket is used, it will return ErrInvalidTicket error. +func (t *TicketDomain) VerifyTicket(ctx context.Context, payload []byte) (ticketing Ticketing, err error) { + span := sentry.StartSpan(ctx, "ticketing.verify_ticket", sentry.WithTransactionName("VerifyTicket")) + defer span.Finish() + + if len(payload) == 0 { + return Ticketing{}, ValidationError{Errors: []string{"payload is empty"}} + } + + // Separate the payload into the signature + email + random id that's generated from ValidatePaymentReceipt + rawSignature, payloadAfter, found := bytes.Cut(payload, []byte(";")) + if !found { + return Ticketing{}, ErrInvalidTicket + } + + rawTicketId, rawHashedEmail, found := bytes.Cut(payloadAfter, []byte(":")) + if !found { + return Ticketing{}, ErrInvalidTicket + } + + ticketId, err := strconv.ParseInt(string(rawTicketId), 10, 64) + if err != nil { + return Ticketing{}, ErrInvalidTicket + } + + userHashedEmail, err := base64.StdEncoding.DecodeString(string(rawHashedEmail)) + if err != nil { + return Ticketing{}, fmt.Errorf("decoding base64 string for email: %w", err) + } + + signature, err := hex.DecodeString(string(rawSignature)) + if err != nil { + return Ticketing{}, fmt.Errorf("decoding hex string for signature: %w", err) + } + + // Validate the signature and its message using ed25519. If it's invalid, return ErrInvalidTicket + signatureValidated := ed25519.Verify(*t.publicKey, payloadAfter, signature) + if !signatureValidated { + return Ticketing{}, fmt.Errorf("%w (verifying signature)", ErrInvalidTicket) + } + + // Check the ticket if it's been used before. If it is, return ErrInvalidTicket. Decorate it a bit. + var rawTicketingResults []Ticketing + _, err = t.db.ListTableRecords(ctx, t.tableId, &rawTicketingResults, nocodb.ListTableRecordOptions{ + Where: fmt.Sprintf("(Id,eq,%d)~and(Used,eq,false)", ticketId), + Sort: []nocodb.Sort{nocodb.SortDescending("CreatedAt")}, + Limit: 1, + }) + if err != nil { + return Ticketing{}, fmt.Errorf("acquiring records: %w", err) + } + + if len(rawTicketingResults) == 0 { + return Ticketing{}, fmt.Errorf("%w: not exists", ErrInvalidTicket) + } + + ticketing = rawTicketingResults[0] + + // Validate email + sha384Hasher := sha512.New384() + sha384Hasher.Write([]byte(ticketing.Email)) + hashedEmail := sha384Hasher.Sum(nil) + if !bytes.Equal(hashedEmail, userHashedEmail) { + return Ticketing{}, fmt.Errorf("%w (mismatched email)", ErrInvalidTicket) + } + + // Mark the ticket as used + err = t.db.UpdateTableRecords(ctx, t.tableId, []any{NullTicketing{ + Id: sql.NullInt64{Int64: ticketing.Id, Valid: true}, + Used: sql.NullBool{Bool: true, Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }}) + if err != nil { + return Ticketing{}, fmt.Errorf("updating table records: %w", err) + } + + return ticketing, nil +} diff --git a/backend/ticketing_test.go b/backend/ticketing_test.go deleted file mode 100644 index 62a9893..0000000 --- a/backend/ticketing_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package main_test - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "errors" - "strings" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - "gocloud.dev/blob" - - main "conf" -) - -func TestNewTicketDomain(t *testing.T) { - // Create mock dependencies. - db := &pgxpool.Pool{} - bucket := &blob.Bucket{} - privateKey := ed25519.PrivateKey{} - publicKey := ed25519.PublicKey{} - mailer := &main.Mailer{} - - // Group the tests with t.Run(). - t.Run("all dependencies set", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(db, bucket, privateKey, publicKey, mailer) - if err != nil { - t.Errorf("NewTicketDomain failed: %v", err) - } - if ticketDomain == nil { - t.Error("NewTicketDomain returned nil ticketDomain") - } - }) - - t.Run("nil database", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(nil, bucket, privateKey, publicKey, mailer) - if err == nil { - t.Error("NewTicketDomain did not return error with nil database") - } - if ticketDomain != nil { - t.Error("NewTicketDomain returned non-nil ticketDomain with nil database") - } - }) - - t.Run("nil bucket", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(db, nil, privateKey, publicKey, mailer) - if err == nil { - t.Error("NewTicketDomain did not return error with nil bucket") - } - if ticketDomain != nil { - t.Error("NewTicketDomain returned non-nil ticketDomain with nil bucket") - } - }) - - t.Run("nil private key", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(db, bucket, nil, publicKey, mailer) - if err == nil { - t.Error("NewTicketDomain did not return error with nil private key") - } - if ticketDomain != nil { - t.Error("NewTicketDomain returned non-nil ticketDomain with nil private key") - } - }) - - t.Run("nil public key", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(db, bucket, privateKey, nil, mailer) - if err == nil { - t.Error("NewTicketDomain did not return error with nil public key") - } - if ticketDomain != nil { - t.Error("NewTicketDomain returned non-nil ticketDomain with nil public key") - } - }) - - t.Run("nil mailer", func(t *testing.T) { - ticketDomain, err := main.NewTicketDomain(db, bucket, privateKey, publicKey, nil) - if err == nil { - t.Error("NewTicketDomain did not return error with nil mailer") - } - if ticketDomain != nil { - t.Error("NewTicketDomain returned non-nil ticketDomain with nil mailer") - } - }) -} - -func TestTicketDomain_StorePaymentReceipt(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generating new ed25519 key: %s", err.Error()) - return - } - - ticketDomain, err := main.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender) - if err != nil { - t.Fatalf("creating a ticket domain instance: %s", err.Error()) - } - - userDomain := main.NewUserDomain(database) - - t.Run("Invalid Email and photo", func(t *testing.T) { - err := ticketDomain.StorePaymentReceipt(context.Background(), "", nil, "") - if err == nil { - t.Error("expecting an error, got nil instead") - } - - var validationError *main.ValidationError - if errors.As(err, &validationError) { - if len(validationError.Errors) != 3 { - t.Errorf("expecting three errors, got %d", len(validationError.Errors)) - } - } - }) - - t.Run("Happy scenario", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - email := "johndoe+happy@example.com" - err := userDomain.CreateParticipant(ctx, main.CreateParticipantRequest{ - Name: "John Doe", - Email: email, - }) - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - - err = ticketDomain.StorePaymentReceipt(ctx, email, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - }) - - t.Run("Update data if email already exists", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - email := "johndoe+happy@example.com" - err := userDomain.CreateParticipant(ctx, main.CreateParticipantRequest{ - Name: "John Doe", - Email: email, - }) - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - - // First attempt - err = ticketDomain.StorePaymentReceipt(ctx, email, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - - // Second attempt, should not return error - err = ticketDomain.StorePaymentReceipt(ctx, email, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - }) - - t.Run("User email not found, should return ErrUserEmailNotFound", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - err := ticketDomain.StorePaymentReceipt(ctx, "johndoe+not+found@example.com", strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") - if err == nil { - t.Error("expecting an error, got nil instead") - } - - if err != nil && !errors.Is(err, main.ErrUserEmailNotFound) { - t.Errorf("unexpected error: %s", err.Error()) - } - }) -} - -func TestTicketDomain_ValidatePaymentReceipt(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generating new ed25519 key: %s", err.Error()) - return - } - - ticketDomain, err := main.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender) - if err != nil { - t.Fatalf("creating a ticket domain instance: %s", err.Error()) - } - - userDomain := main.NewUserDomain(database) - - t.Run("Invalid email", func(t *testing.T) { - _, err := ticketDomain.ValidatePaymentReceipt(context.Background(), "") - if err == nil { - t.Error("expecting an error, got nil instead") - } - - var validationError *main.ValidationError - if errors.As(err, &validationError) { - if len(validationError.Errors) != 1 { - t.Errorf("expecting one error, got %d", len(validationError.Errors)) - } - } - }) - - t.Run("Email not found", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - _, err := ticketDomain.ValidatePaymentReceipt(ctx, "not-found@example.com") - if err == nil { - t.Error("expecting an error, got nil") - } - - if !errors.Is(err, main.ErrInvalidTicket) { - t.Errorf("expecting an error of ErrInvalidTicket, instead got %s", err.Error()) - } - }) - - t.Run("Happy scenario", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - email := "johndoe+happy@example.com" - err := userDomain.CreateParticipant(ctx, main.CreateParticipantRequest{ - Name: "John Doe", - Email: email, - }) - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - - err = ticketDomain.StorePaymentReceipt(ctx, email, strings.NewReader("Hello world! This is not a photo. Yet this will be a text file."), "text/plain") - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - - sum, err := ticketDomain.ValidatePaymentReceipt(ctx, email) - if err != nil { - t.Errorf("unexpected error: %s", err) - } - - if sum == "" { - t.Error("expecting sum to have value, got empty string") - } - }) -} - -func TestTicketDomain_VerifyIsStudent(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generating new ed25519 key: %s", err.Error()) - return - } - ticketDomain, err := main.NewTicketDomain(database, bucket, privateKey, publicKey, mailSender) - if err != nil { - t.Fatalf("creating a ticket domain instance: %s", err.Error()) - } - t.Run("Invalid email", func(t *testing.T) { - err := ticketDomain.VerifyIsStudent(context.Background(), "") - if err == nil { - t.Error("expecting an error, got nil instead") - } - - var validationError *main.ValidationError - if errors.As(err, &validationError) { - if len(validationError.Errors) != 1 { - t.Errorf("expecting one error, got %d", len(validationError.Errors)) - } - } - }) - - t.Run("Happy scenario", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - err := ticketDomain.VerifyIsStudent(ctx, "aji@test.com") - if err != nil { - t.Errorf("unexpected error: %s", err.Error()) - } - }) -} diff --git a/backend/user.go b/backend/user.go deleted file mode 100644 index cb230bf..0000000 --- a/backend/user.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "context" - "encoding/csv" - "os" - "strconv" - "time" - - "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" -) - -type UserDomain struct { - db *pgxpool.Pool -} - -func NewUserDomain(db *pgxpool.Pool) *UserDomain { - if db == nil { - panic("db is nil") - } - - return &UserDomain{db: db} -} - -type Type string - -const ( - TypeParticipant Type = "participant" - TypeSpeaker Type = "speaker" -) - -type CreateParticipantRequest struct { - Name string - Email string -} - -type User struct { - Name string - Email string - Type Type - IsProcessed bool - CreatedAt time.Time -} - -func (c CreateParticipantRequest) validate() (errors []string) { - if c.Name == "" { - errors = append(errors, "Invalid name") - } - - if c.Email == "" { - errors = append(errors, "Invalid email") - } - - return errors -} - -func (u *UserDomain) CreateParticipant(ctx context.Context, req CreateParticipantRequest) error { - span := sentry.StartSpan(ctx, "user.create_participant") - defer span.Finish() - - if errors := req.validate(); len(errors) > 0 { - return &ValidationError{Errors: errors} - } - - c, err := u.db.Acquire(ctx) - if err != nil { - return err - } - defer c.Release() - - t, err := c.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) - if err != nil { - return err - } - - _, err = t.Exec( - ctx, - "INSERT INTO users (name, email, type) VALUES ($1, $2, $3)", - req.Name, - req.Email, - TypeParticipant, - ) - if err != nil { - if e := t.Rollback(ctx); e != nil { - return e - } - return err - } - - err = t.Commit(ctx) - if err != nil { - return err - } - - return nil -} - -type UserFilterRequest struct { - Type Type - IsProcessed bool -} - -func (u *UserDomain) GetUsers(ctx context.Context, filter UserFilterRequest) ([]User, error) { - span := sentry.StartSpan(ctx, "user.get_users") - defer span.Finish() - - c, err := u.db.Acquire(ctx) - if err != nil { - return nil, err - } - defer c.Release() - - rows, err := c.Query( - ctx, - "SELECT name, email, type, is_processed, created_at FROM users WHERE type = $1 AND is_processed = $2", - filter.Type, - filter.IsProcessed, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var users []User - for rows.Next() { - var user User - err := rows.Scan(&user.Name, &user.Email, &user.Type, &user.IsProcessed, &user.CreatedAt) - if err != nil { - return nil, err - } - users = append(users, user) - } - - return users, nil -} - -func (u *UserDomain) ExportUnprocessedUsersAsCSV(ctx context.Context) error { - span := sentry.StartSpan(ctx, "user.export_unprocessed_users_as_csv") - defer span.Finish() - - users, err := u.GetUsers(ctx, UserFilterRequest{ - Type: TypeParticipant, - IsProcessed: false, - }) - if err != nil { - return err - } - - csvData := [][]string{ - {"name", "email", "type", "is_processed"}, - } - - for _, user := range users { - csvData = append(csvData, []string{ - user.Name, - user.Email, - string(user.Type), - strconv.FormatBool(user.IsProcessed), - }) - } - - csvFile, err := os.Create("/app/csv/users.csv") - if err != nil { - return err - } - defer csvFile.Close() - - csvWriter := csv.NewWriter(csvFile) - defer csvWriter.Flush() - - for _, row := range csvData { - err := csvWriter.Write(row) - if err != nil { - return err - } - } - - return nil -} diff --git a/backend/user/errors.go b/backend/user/errors.go new file mode 100644 index 0000000..4e0cd0e --- /dev/null +++ b/backend/user/errors.go @@ -0,0 +1,16 @@ +package user + +import ( + "errors" + "strings" +) + +type ValidationError struct { + Errors []string +} + +func (v ValidationError) Error() string { + return strings.Join(v.Errors, ", ") +} + +var ErrUserEmailNotFound = errors.New("user email not found") diff --git a/backend/user/user.go b/backend/user/user.go new file mode 100644 index 0000000..4d39f4c --- /dev/null +++ b/backend/user/user.go @@ -0,0 +1,207 @@ +package user + +import ( + "context" + "encoding/csv" + "fmt" + "os" + "strconv" + "time" + + "conf/nocodb" + "github.com/getsentry/sentry-go" +) + +type UserDomain struct { + db *nocodb.Client + tableId string +} + +func NewUserDomain(db *nocodb.Client, tableId string) (*UserDomain, error) { + if db == nil { + return nil, fmt.Errorf("db is nil") + } + + if tableId == "" { + return nil, fmt.Errorf("tableId is empty") + } + + return &UserDomain{db: db, tableId: tableId}, nil +} + +type Type string + +const ( + TypeParticipant Type = "participant" + TypeSpeaker Type = "speaker" +) + +type CreateParticipantRequest struct { + Name string + Email string +} + +type User struct { + Name string + Email string + Type Type + IsProcessed bool + CreatedAt time.Time +} + +func (c CreateParticipantRequest) validate() (errors []string) { + if c.Name == "" { + errors = append(errors, "Invalid name") + } + + if c.Email == "" { + errors = append(errors, "Invalid email") + } + + return errors +} + +func (u *UserDomain) CreateParticipant(ctx context.Context, req CreateParticipantRequest) error { + span := sentry.StartSpan(ctx, "user.create_participant") + defer span.Finish() + + if errors := req.validate(); len(errors) > 0 { + return &ValidationError{Errors: errors} + } + + user := User{ + Name: req.Name, + Email: req.Email, + Type: TypeParticipant, + IsProcessed: false, + CreatedAt: time.Now(), + } + + err := u.db.CreateTableRecords(ctx, u.tableId, []any{user}) + if err != nil { + return fmt.Errorf("creating table records: %w", err) + } + + return nil +} + +type UserFilterRequest struct { + Type Type + IsProcessed bool +} + +func (u *UserDomain) GetUsers(ctx context.Context, filter UserFilterRequest) ([]User, error) { + span := sentry.StartSpan(ctx, "user.get_users") + defer span.Finish() + + var users []User + var offset int64 + for { + var currentUserSets []User + pageInfo, err := u.db.ListTableRecords(ctx, u.tableId, ¤tUserSets, nocodb.ListTableRecordOptions{ + Offset: offset, + Where: fmt.Sprintf("(Type,eq,%s)~and(IsProcessed,eq,%s)", filter.Type, strconv.FormatBool(filter.IsProcessed)), + }) + if err != nil { + return users, fmt.Errorf("list table records: %w", err) + } + + offset += int64(len(currentUserSets)) + users = append(users, currentUserSets...) + + if pageInfo.IsLastPage { + break + } + } + + return users, nil +} + +func (u *UserDomain) GetUserByEmail(ctx context.Context, email string) (User, error) { + span := sentry.StartSpan(ctx, "user.get_user_by_email", sentry.WithTransactionName("GetUserByEmail")) + defer span.Finish() + + var user User + var offset int64 + var found = false + for found { + var currentUserSets []User + pageInfo, err := u.db.ListTableRecords(ctx, u.tableId, ¤tUserSets, nocodb.ListTableRecordOptions{ + Offset: offset, + Where: fmt.Sprintf("(Email,eq,%s)", email), + }) + if err != nil { + return user, fmt.Errorf("list table records: %w", err) + } + + offset += int64(len(currentUserSets)) + + for _, userEntry := range currentUserSets { + if userEntry.Email == email { + user = userEntry + found = true + break + } + } + + if pageInfo.IsLastPage { + break + } + } + + if !found { + return user, ErrUserEmailNotFound + } + + return user, nil +} + +func (u *UserDomain) ExportUnprocessedUsersAsCSV(ctx context.Context) error { + span := sentry.StartSpan(ctx, "user.export_unprocessed_users_as_csv") + defer span.Finish() + + users, err := u.GetUsers(ctx, UserFilterRequest{ + Type: TypeParticipant, + IsProcessed: false, + }) + if err != nil { + return err + } + + csvData := [][]string{ + {"name", "email", "type", "is_processed"}, + } + + for _, user := range users { + csvData = append(csvData, []string{ + user.Name, + user.Email, + string(user.Type), + strconv.FormatBool(user.IsProcessed), + }) + } + + csvFile, err := os.Create("/app/csv/users.csv") + if err != nil { + return err + } + defer func(csvFile *os.File) { + err := csvFile.Close() + if err != nil { + sentry.GetHubFromContext(ctx).Scope().SetLevel(sentry.LevelWarning) + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }(csvFile) + + csvWriter := csv.NewWriter(csvFile) + defer csvWriter.Flush() + + for _, row := range csvData { + err := csvWriter.Write(row) + if err != nil { + return err + } + } + + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index 75019c0..5363dff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,20 +3,12 @@ # Please don't directly use this configuration for production deployment. services: - postgres: - image: postgres:15.3-bookworm + # DO NOT use this version of NocoDB if you are deploying this to production. + # Always use their SaaS offering. + nocodb: + image: nocodb/nocodb:latest ports: - - 127.0.0.1:5432:5432 - environment: - POSTGRES_PASSWORD: VeryStrongPassword - POSTGRES_USER: conference - POSTGRES_DB: conference - TZ: UTC - healthcheck: - test: pg_isready - interval: 30s - timeout: 20s - retries: 10 + - 127.0.0.1:8080:8080 deploy: mode: replicated replicas: 1 @@ -27,17 +19,10 @@ services: window: 120s resources: limits: - memory: 2GB - cpus: '2' - reservations: - memory: 50MB - cpus: '0.10' + memory: 500MB + cpus: '1' volumes: - - postgres-data:/var/lib/postgresql/data - logging: - driver: local - options: - max-size: 10M + - nocodb-data:/usr/app/data/ mailcrab: image: marlonb/mailcrab:latest @@ -61,38 +46,15 @@ services: options: max-size: 10M - backend-migrate: - build: ./backend - entrypoint: /app/conf-backend migrate up - environment: - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: conference - DB_PASSWORD: VeryStrongPassword - DB_NAME: conference - depends_on: - postgres: - condition: service_healthy - logging: - driver: local - options: - max-size: 10M - backend: build: ./backend ports: - 127.0.0.1:8080:8080 environment: - DB_HOST: postgres - DB_PORT: 5432 - DB_USER: conference - DB_PASSWORD: VeryStrongPassword - DB_NAME: conference + NOCODB_BASE_URL: http://nocodb:8080 PORT: 8080 depends_on: - backend-migrate: - condition: service_completed_successfully - postgres: + nocodb: condition: service_healthy mailcrab: condition: service_started @@ -114,4 +76,4 @@ services: max-size: 10M volumes: - postgres-data: + nocodb-data: diff --git a/emails/post_event_feedback.html b/emails/post_event_feedback.html new file mode 100644 index 0000000..e69de29