diff --git a/backend/server.go b/backend/server.go index 35ffbe3..2faef2b 100644 --- a/backend/server.go +++ b/backend/server.go @@ -7,6 +7,7 @@ import ( "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" @@ -68,6 +69,7 @@ func NewServer(config *ServerConfig) *echo.Echo { e.POST("/users", dependencies.RegisterUser) e.POST("/bukti-transfer", dependencies.UploadBuktiTransfer) + e.POST("/scan-tiket", dependencies.DayTicketScan) return e } @@ -81,6 +83,9 @@ func (s *ServerDependency) RegisterUser(c echo.Context) error { 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", @@ -98,7 +103,7 @@ func (s *ServerDependency) RegisterUser(c echo.Context) error { } err := s.userDomain.CreateParticipant( - c.Request().Context(), + span.Context(), CreateParticipantRequest{ Name: p.Name, Email: p.Email, @@ -130,6 +135,9 @@ func (s *ServerDependency) UploadBuktiTransfer(c echo.Context) error { 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", @@ -183,7 +191,7 @@ func (s *ServerDependency) UploadBuktiTransfer(c echo.Context) error { photoContentType := mime.TypeByExtension(photoExtension) - err = s.ticketDomain.StorePaymentReceipt(c.Request().Context(), email, photoFile, photoContentType) + err = s.ticketDomain.StorePaymentReceipt(span.Context(), email, photoFile, photoContentType) if err != nil { var validationError *ValidationError if errors.As(err, &validationError) { @@ -204,3 +212,59 @@ func (s *ServerDependency) UploadBuktiTransfer(c echo.Context) error { return c.NoContent(http.StatusCreated) } + +type DayTicketScanRequest struct { + Code string `json:"code"` +} + +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, + }) + } + + 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/ticketing.go b/backend/ticketing.go index 839c13e..9ef08e9 100644 --- a/backend/ticketing.go +++ b/backend/ticketing.go @@ -312,50 +312,50 @@ harap abaikan email ini. Terima kasih!`, // 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) (ok bool, student bool, err 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, false, ValidationError{Errors: []string{"payload is empty"}} + 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, false, ErrInvalidTicket + return "", "", false, ErrInvalidTicket } rawTicketId, rawHashedEmail, found := bytes.Cut(payloadAfter, []byte(":")) if !found { - return false, false, ErrInvalidTicket + return "", "", false, ErrInvalidTicket } ticketId, err := uuid.FromBytes(rawTicketId) if err != nil { - return false, false, ErrInvalidTicket + return "", "", false, ErrInvalidTicket } userHashedEmail, err := base64.StdEncoding.DecodeString(string(rawHashedEmail)) if err != nil { - return false, false, fmt.Errorf("decoding base64 string for email: %w", err) + return "", "", false, fmt.Errorf("decoding base64 string for email: %w", err) } signature, err := hex.DecodeString(string(rawSignature)) if err != nil { - return false, false, fmt.Errorf("decoding hex string for signature: %w", err) + 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, false, fmt.Errorf("%w (verifying signature)", ErrInvalidTicket) + 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, false, fmt.Errorf("acquiring connection from pool: %w", err) + return "", "", false, fmt.Errorf("acquiring connection from pool: %w", err) } defer conn.Release() @@ -364,21 +364,33 @@ func (t *TicketDomain) VerifyTicket(ctx context.Context, payload []byte) (ok boo AccessMode: pgx.ReadOnly, }) if err != nil { - return false, false, fmt.Errorf("creating transaction: %w", err) + return "", "", false, fmt.Errorf("creating transaction: %w", err) } - var email string err = tx.QueryRow(ctx, "SELECT email, student FROM ticketing WHERE id = $1", ticketId).Scan(&email, &student) if err != nil { if e := tx.Rollback(ctx); e != nil { - return false, false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) + return "", "", false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) } if errors.Is(err, pgx.ErrNoRows) { - return false, false, ErrInvalidTicket + return "", "", false, ErrInvalidTicket } - return false, false, fmt.Errorf("acquiring data from table: %w", err) + 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 @@ -386,24 +398,24 @@ func (t *TicketDomain) VerifyTicket(ctx context.Context, payload []byte) (ok boo sha384Hasher.Write([]byte(email)) hashedEmail := sha384Hasher.Sum(nil) if !bytes.Equal(hashedEmail, userHashedEmail) { - return false, false, fmt.Errorf("%w (mismatched email)", ErrInvalidTicket) + 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, false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) + return "", "", false, fmt.Errorf("rolling back transaction: %w (%s)", e, err.Error()) } - return false, false, fmt.Errorf("acquiring data from table: %w", err) + return "", "", false, fmt.Errorf("acquiring data from table: %w", err) } if err := tx.Commit(ctx); err != nil { - return false, false, fmt.Errorf("commiting transaction: %w", err) + return "", "", false, fmt.Errorf("commiting transaction: %w", err) } - return true, student, nil + return email, name, student, nil } func (t *TicketDomain) VerifyIsStudent(ctx context.Context, email string) (err error) {