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/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 0debb95..99a530d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.5.5 github.com/kelseyhightower/envconfig v1.4.0 + github.com/pquerna/otp v1.4.0 github.com/pressly/goose/v3 v3.18.0 github.com/rs/cors v1.10.1 github.com/rs/zerolog v1.32.0 @@ -41,6 +42,7 @@ require ( 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 diff --git a/backend/go.sum b/backend/go.sum index 51fcb2e..7bfa640 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -66,6 +66,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNIC 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/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -223,6 +225,8 @@ 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/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pressly/goose/v3 v3.18.0 h1:CUQKjZ0li91GLrMekHPR0yz4UyjT21AqyhSm/ERcPTo= github.com/pressly/goose/v3 v3.18.0/go.mod h1:NTDry9taDJXEV6IqkABnZqm1MRGOSrCWrNEz1x6f4wI= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 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/main.go b/backend/main.go index 690df55..75eba2d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,25 +1,13 @@ package main import ( - "context" - "encoding/hex" - "fmt" "os" - "strings" - "text/tabwriter" "time" - "conf/mailer" - "conf/ticketing" - "conf/user" - "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" ) @@ -44,8 +32,18 @@ func App() *cli.App { Action: ServerHandlerAction, }, { - Name: "migrate", - Action: MigrateHandlerAction, + Name: "healthcheck", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "port", + Value: "8080", + }, + &cli.DurationFlag{ + Name: "timeout", + Value: time.Second * 15, + }, + }, + Action: HealthcheckHandlerAction, }, { Name: "blast-email", @@ -82,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.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 - }, - }, - { - 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 := user.NewUserDomain(conn) - users, err := userDomain.GetUsers(cCtx.Context, user.UserFilterRequest{Type: user.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.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.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 := mailer.NewMailSender(&mailer.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 := ticketing.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 := mailer.NewMailSender(&mailer.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 := ticketing.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/migrateHandler.go b/backend/migrateHandler.go deleted file mode 100644 index d12e22d..0000000 --- a/backend/migrateHandler.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - - "github.com/getsentry/sentry-go" - "github.com/rs/zerolog/log" - "github.com/urfave/cli/v2" -) - -func MigrateHandlerAction(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", - 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 ctx.Args().First() { - case "down": - err := migration.Down(ctx.Context) - if err != nil { - return fmt.Errorf("executing down migration: %w", err) - } - case "up": - fallthrough - default: - err := migration.Up(ctx.Context) - if err != nil { - return fmt.Errorf("executing up migration: %w", err) - } - } - - log.Info().Msg("Migration succeed") - - return nil -} 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/pgx_tracer.go b/backend/pgx_tracer.go deleted file mode 100644 index 3d91b65..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 r.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/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 index 875e84a..207cb30 100644 --- a/backend/server/day_ticket_scan.go +++ b/backend/server/day_ticket_scan.go @@ -70,7 +70,7 @@ func (s *ServerDependency) DayTicketScan(w http.ResponseWriter, r *http.Request) return } - email, name, student, err := s.ticketDomain.VerifyTicket(r.Context(), []byte(requestBody.Code)) + verifiedTicket, err := s.ticketDomain.VerifyTicket(r.Context(), []byte(requestBody.Code)) if err != nil { var validationError *ticketing.ValidationError if errors.As(err, &validationError) { @@ -106,13 +106,27 @@ func (s *ServerDependency) DayTicketScan(w http.ResponseWriter, r *http.Request) 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": student, - "name": name, - "email": email, + "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 index 2ae2cd2..a47413c 100644 --- a/backend/server/payment_proof.go +++ b/backend/server/payment_proof.go @@ -9,6 +9,7 @@ import ( "slices" "conf/ticketing" + "conf/user" "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5/middleware" "github.com/rs/zerolog/log" @@ -18,6 +19,11 @@ func (s *ServerDependency) UploadPaymentProof(w http.ResponseWriter, r *http.Req 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) @@ -86,26 +92,39 @@ func (s *ServerDependency) UploadPaymentProof(w http.ResponseWriter, r *http.Req photoContentType := mime.TypeByExtension(photoExtension) - err = s.ticketDomain.StorePaymentReceipt(r.Context(), email, photoFile, photoContentType) + userEntry, err := s.userDomain.GetUserByEmail(r.Context(), email) if err != nil { - var validationError *ticketing.ValidationError - if errors.As(err, &validationError) { + if errors.Is(err, user.ErrUserEmailNotFound) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) + w.WriteHeader(http.StatusPreconditionFailed) _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Validation error", - "errors": validationError.Error(), + "message": "User not found", + "errors": err.Error(), "request_id": requestId, }) return } - if errors.Is(err, ticketing.ErrUserEmailNotFound) { + 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.StatusPreconditionFailed) + w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "User not found", - "errors": err.Error(), + "message": "Validation error", + "errors": validationError.Error(), "request_id": requestId, }) return diff --git a/backend/server/register_user.go b/backend/server/register_user.go index aaa92b2..ec130ab 100644 --- a/backend/server/register_user.go +++ b/backend/server/register_user.go @@ -19,7 +19,7 @@ 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.registrationClosed { + if !s.featureFlag.EnableRegistration { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotAcceptable) _ = json.NewEncoder(w).Encode(map[string]string{ diff --git a/backend/server/server.go b/backend/server/server.go index 2465a26..5a612bb 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -1,10 +1,14 @@ 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" @@ -14,37 +18,58 @@ import ( ) type ServerConfig struct { - UserDomain *user.UserDomain - TicketDomain *ticketing.TicketDomain - Environment string - FeatureRegistrationClosed bool - ValidateTicketKey string - Hostname string - Port string + 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 - registrationClosed bool - validateTicketKey string + userDomain *user.UserDomain + ticketDomain *ticketing.TicketDomain + administratorDomain *administrator.AdministratorDomain + featureFlag *features.FeatureFlag + mailSender *mailer.Mailer + validateTicketKey string } -func NewServer(config *ServerConfig) *http.Server { - 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") +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, - registrationClosed: config.FeatureRegistrationClosed, - validateTicketKey: config.ValidateTicketKey, + userDomain: config.UserDomain, + ticketDomain: config.TicketDomain, + administratorDomain: config.AdministratorDomain, + featureFlag: config.FeatureFlag, + mailSender: config.MailSender, + validateTicketKey: config.ValidateTicketKey, } r := chi.NewRouter() @@ -53,22 +78,25 @@ func NewServer(config *ServerConfig) *http.Server { 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"} + 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}, - AllowCredentials: false, + AllowedHeaders: []string{"Authorization"}, + AllowCredentials: true, MaxAge: 3600, // 1 day }).Handler) - r.Use(middleware.Heartbeat("/api/ping")) + 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("/users", dependencies.RegisterUser) - r.Post("/bukti-transfer", dependencies.UploadPaymentProof) - r.Post("/scan-tiket", dependencies.DayTicketScan) + r.Post("/api/administrator/login", dependencies.AdministratorLogin) return &http.Server{ Addr: net.JoinHostPort(config.Hostname, config.Port), @@ -77,5 +105,5 @@ func NewServer(config *ServerConfig) *http.Server { ReadHeaderTimeout: time.Minute, WriteTimeout: time.Hour, IdleTimeout: time.Hour, - } + }, nil } diff --git a/backend/serverHandler.go b/backend/server_handler_action.go similarity index 62% rename from backend/serverHandler.go rename to backend/server_handler_action.go index 253b1c8..1dac763 100644 --- a/backend/serverHandler.go +++ b/backend/server_handler_action.go @@ -10,12 +10,13 @@ import ( "os/signal" "time" + "conf/administrator" "conf/mailer" + "conf/nocodb" "conf/server" "conf/ticketing" "conf/user" "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" "gocloud.dev/blob" @@ -28,13 +29,12 @@ func ServerHandlerAction(ctx *cli.Context) error { } err = sentry.Init(sentry.ClientOptions{ - Dsn: "", - Debug: config.Environment != "production", - AttachStacktrace: true, - SampleRate: 1.0, - EnableTracing: true, + Dsn: "", + Debug: config.Environment != "production", + SampleRate: 1.0, + EnableTracing: true, TracesSampler: func(ctx sentry.SamplingContext) float64 { - if ctx.Span.Name == "GET /internal/ping" { + if ctx.Span.Name == "GET /api/public/ping" { return 0 } @@ -56,27 +56,15 @@ func ServerHandlerAction(ctx *cli.Context) error { } 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(ctx.Context, pgxConfig) + 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 { - log.Fatal().Err(err).Msg("failed to connect to database") + return fmt.Errorf("creating database client instance: %w", err) } - defer conn.Close() bucket, err := blob.OpenBucket(context.Background(), config.BlobUrl) if err != nil { @@ -106,18 +94,35 @@ func ServerHandlerAction(ctx *cli.Context) error { SmtpPassword: config.Mailer.Password, }) - ticketDomain, err := ticketing.NewTicketDomain(conn, bucket, signaturePrivateKey, signaturePublicKey, mailSender) + ticketDomain, err := ticketing.NewTicketDomain(database, bucket, signaturePrivateKey, signaturePublicKey, mailSender) if err != nil { return fmt.Errorf("creating ticket domain: %w", err) } - httpServer := server.NewServer(&server.ServerConfig{ - UserDomain: user.NewUserDomain(conn), - TicketDomain: ticketDomain, - Environment: config.Environment, - FeatureRegistrationClosed: config.FeatureFlags.RegistrationClosed, - ValidateTicketKey: config.ValidateTicketKey, + 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)