From dd784da23295b886b13315c12101d8ae5a074bcd Mon Sep 17 00:00:00 2001 From: mario Date: Tue, 13 May 2025 15:44:11 +0700 Subject: [PATCH] add: implement shortlink --- config/config.go | 6 + config/config.yaml | 5 + internal/api/handlers/shortlink.go | 129 ++++++++++ internal/api/models/user.go | 51 ++++ internal/api/routes.go | 19 +- internal/api/service/shortlink_service.go | 296 ++++++++++++++++++++++ internal/auth/jwt.go | 10 + test/http/shortlink-flow.http | 50 ++++ 8 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/shortlink.go create mode 100644 internal/api/service/shortlink_service.go create mode 100644 test/http/shortlink-flow.http diff --git a/config/config.go b/config/config.go index 5197953..5767182 100644 --- a/config/config.go +++ b/config/config.go @@ -33,6 +33,12 @@ type Config struct { EnableDatabaseAuth bool `mapstructure:"enable_database_auth"` } `mapstructure:"auth"` + Shortlink struct { + BaseURL string `mapstructure:"base_url"` // Base URL for shortlinks (e.g., https://example.com) + DefaultExpiryHours int `mapstructure:"default_expiry_hours"` // Default expiry time in hours + MaxAttempts int `mapstructure:"max_attempts"` // Maximum failed attempts allowed + } `mapstructure:"shortlink"` + Database struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` diff --git a/config/config.yaml b/config/config.yaml index 39b22f5..6683926 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -19,6 +19,11 @@ auth: refresh_token_expiry: 168 # hours (7 days) enable_database_auth: false # Set to true when ready to use database +shortlink: + base_url: "http://localhost:3333" # The base URL for generated OHIF Auth shortlinks + default_expiry_hours: 24 # Default expiry time for shortlinks (1 day) + max_attempts: 5 # Maximum number of failed login attempts + database: host: "localhost" port: 3306 diff --git a/internal/api/handlers/shortlink.go b/internal/api/handlers/shortlink.go new file mode 100644 index 0000000..ca002a9 --- /dev/null +++ b/internal/api/handlers/shortlink.go @@ -0,0 +1,129 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/middleware" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service" + + "go.uber.org/zap" +) + +// ShortLinkHandler handles shortlink operations +type ShortLinkHandler struct { + logger *zap.Logger + shortLinkService *service.ShortLinkService +} + +// NewShortLinkHandler creates a new shortlink handler +func NewShortLinkHandler(logger *zap.Logger, shortLinkService *service.ShortLinkService) *ShortLinkHandler { + return &ShortLinkHandler{ + logger: logger, + shortLinkService: shortLinkService, + } +} + +// GenerateShortLink handles shortlink generation requests +func (h *ShortLinkHandler) GenerateShortLink(w http.ResponseWriter, r *http.Request) { + // Only allow admin or expertise_doctor roles to generate shortlinks + userRole, ok := r.Context().Value(middleware.UserRoleKey).(string) + if !ok || (userRole != "admin" && userRole != "expertise_doctor") { + h.logger.Warn("Unauthorized attempt to generate shortlink", + zap.String("role", userRole)) + http.Error(w, "Only admin or expertise doctor can generate short links", http.StatusForbidden) + return + } + + // Parse request body + var req models.GenerateShortLinkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to parse shortlink generation request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get user ID from context + userID, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok { + h.logger.Error("User ID not found in context") + http.Error(w, "User context not found", http.StatusInternalServerError) + return + } + + // Generate shortlink using configured baseURL from service + response, err := h.shortLinkService.GenerateShortLink(&req, userID) + if err != nil { + h.logger.Error("Failed to generate shortlink", + zap.Error(err), + zap.String("patientID", req.PatientID), + zap.String("studyUID", req.StudyUID)) + + statusCode := http.StatusInternalServerError + message := "Failed to generate shortlink" + + if err == service.ErrInvalidStudyUID { + statusCode = http.StatusBadRequest + message = "Invalid StudyInstanceUID" + } + + http.Error(w, message, statusCode) + return + } + + // Log successful shortlink generation + h.logger.Info("Shortlink generated successfully", + zap.String("token", response.ShortToken), + zap.String("patientID", req.PatientID), + zap.String("studyUID", req.StudyUID), + zap.String("createdBy", userID)) + + // Return response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// ShortLinkAuth handles authentication requests using shortlinks +func (h *ShortLinkHandler) ShortLinkAuth(w http.ResponseWriter, r *http.Request) { + // Parse request body + var req models.ShortLinkAuthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to parse shortlink auth request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate and authenticate + response, err := h.shortLinkService.AuthenticateWithShortLink(&req) + if err != nil { + h.logger.Warn("Shortlink authentication failed", + zap.Error(err), + zap.String("token", req.ShortToken)) + + statusCode := http.StatusUnauthorized + message := "Authentication failed" + + switch err { + case service.ErrShortLinkNotFound, service.ErrShortLinkExpired: + message = "Short link not found or expired" + case service.ErrInvalidDOB: + message = "Invalid date of birth" + case service.ErrTooManyAttempts: + message = "Too many failed attempts" + } + + http.Error(w, message, statusCode) + return + } + + // Log successful authentication + h.logger.Info("Shortlink authentication successful", + zap.String("token", req.ShortToken)) + + // Return response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/internal/api/models/user.go b/internal/api/models/user.go index 47bad44..5bb7822 100644 --- a/internal/api/models/user.go +++ b/internal/api/models/user.go @@ -59,3 +59,54 @@ type RefreshRequest struct { type RefreshResponse struct { AccessToken string `json:"access_token"` } + +// ShortLink represents a short URL token for patient access +type ShortLink struct { + ID string `db:"id" json:"id"` + Token string `db:"token" json:"token"` // The short token used in the URL + PatientID string `db:"patient_id" json:"patient_id"` + StudyUID string `db:"study_uid" json:"study_uid"` // The StudyInstanceUID this token grants access to + HashedDOB string `db:"hashed_dob" json:"-"` // Hashed Date of Birth for verification + ExpiresAt string `db:"expires_at" json:"expires_at"` + IsRevoked bool `db:"is_revoked" json:"is_revoked"` + CreatedAt string `db:"created_at" json:"created_at"` + CreatedByID string `db:"created_by_id" json:"created_by_id"` // ID of admin who created this + RemainingTries int `db:"remaining_tries" json:"-"` // Number of failed attempts allowed +} + +// GenerateShortLinkRequest represents request to create a short URL +type GenerateShortLinkRequest struct { + PatientID string `json:"patient_id"` + StudyUID string `json:"study_uid"` + DOB string `json:"dob"` // Date of birth in YYYY-MM-DD format + ExpiresIn int `json:"expires_in"` // Expiry in hours (optional, defaults to 72) +} + +// GenerateShortLinkResponse is the response for a generated short link +type GenerateShortLinkResponse struct { + ShortToken string `json:"short_token"` + FullURL string `json:"full_url"` + ExpiresAt string `json:"expires_at"` +} + +// ShortLinkAuthRequest represents the shortlink authentication request +type ShortLinkAuthRequest struct { + ShortToken string `json:"short_token,omitempty"` // The original field + ShortTokenAlt string `json:"shortToken,omitempty"` // Support for camelCase naming from OHIF + DOB string `json:"dob"` // Date of birth in YYYY-MM-DD format +} + +func (r *ShortLinkAuthRequest) GetToken() string { + // Use ShortTokenAlt if ShortToken is empty + if r.ShortToken == "" { + return r.ShortTokenAlt + } + return r.ShortToken +} + +// ShortLinkAuthResponse is the response for successful shortlink authentication +type ShortLinkAuthResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` // Token expiry in seconds + RedirectURL string `json:"redirect_url"` +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 11f437e..f2519d5 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -55,7 +55,7 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { // Initialize JWT auth service jwtSecret := cfg.Auth.JWTSecret if jwtSecret == "" { - jwtSecret = "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" // Default from your example, should be set in config + logger.Warn("JWT secret not provided in config") } // Convert config values to time.Duration @@ -66,6 +66,15 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry) authService := service.NewAuthService(jwtManager) + // Initialize shortlink service with config values + shortLinkService := service.NewShortLinkService( + jwtManager, + logger, + cfg.Shortlink.BaseURL, + cfg.Shortlink.DefaultExpiryHours, + cfg.Shortlink.MaxAttempts, + ) + // Public routes that don't require authentication r.Group(func(r chi.Router) { // Health check @@ -77,6 +86,10 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { r.Post("/login", authHandler.Login) r.Post("/refresh", authHandler.RefreshToken) r.Post("/logout", authHandler.Logout) + + // ShortLink authentication - no auth required + shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService) + r.Post("/shortlink", shortLinkHandler.ShortLinkAuth) }) }) @@ -85,6 +98,10 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { // Apply authentication middleware r.Use(apiMiddleware.Auth(authService, logger)) + // Shortlink generation - only for admin and expertise_doctor roles + shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService) + r.Post("/generate-link", shortLinkHandler.GenerateShortLink) + // DICOM Web routes r.Route("/dicomWeb", func(r chi.Router) { // Add audit logging middleware to DICOM routes diff --git a/internal/api/service/shortlink_service.go b/internal/api/service/shortlink_service.go new file mode 100644 index 0000000..5041657 --- /dev/null +++ b/internal/api/service/shortlink_service.go @@ -0,0 +1,296 @@ +package service + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth" + "go.uber.org/zap" +) + +const ( + // DefaultShortLinkExpiry is the default expiration time for short links (72 hours) + DefaultShortLinkExpiry = 72 * time.Hour + + // DefaultMaxTries is the default number of login attempts allowed for a shortlink + DefaultMaxTries = 5 + + // ShortTokenLength is the length of the generated short token + ShortTokenLength = 8 +) + +var ( + ErrShortLinkNotFound = errors.New("short link not found or expired") + ErrInvalidDOB = errors.New("invalid date of birth") + ErrShortLinkExpired = errors.New("short link has expired") + ErrTooManyAttempts = errors.New("too many failed attempts") + ErrCreationFailed = errors.New("failed to create short link") + ErrInvalidStudyUID = errors.New("invalid or missing StudyInstanceUID") + ErrAdminRoleRequired = errors.New("admin role required to generate shortlinks") +) + +// ShortLinkService handles operations related to short links +type ShortLinkService struct { + jwtManager *auth.JWTManager + logger *zap.Logger + // Configuration settings + baseURL string + defaultExpiryTime time.Duration + maxAttempts int + // Mock in-memory storage for now, would use a database in production + shortLinks map[string]*models.ShortLink +} + +// NewShortLinkService creates a new short link service +func NewShortLinkService(jwtManager *auth.JWTManager, logger *zap.Logger, baseURL string, defaultExpiryHours int, maxAttempts int) *ShortLinkService { + // Set default values if not provided + if baseURL == "" { + baseURL = "http://localhost:3000" + } + + if defaultExpiryHours <= 0 { + defaultExpiryHours = 72 // Default to 72 hours if not specified + } + + if maxAttempts <= 0 { + maxAttempts = 5 // Default to 5 attempts if not specified + } + + return &ShortLinkService{ + jwtManager: jwtManager, + logger: logger, + baseURL: baseURL, + defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour, + maxAttempts: maxAttempts, + shortLinks: make(map[string]*models.ShortLink), + } +} + +// GenerateShortLink creates a new short link for patient and study access +func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkRequest, creatorID string) (*models.GenerateShortLinkResponse, error) { + // Validate inputs + if req.PatientID == "" { + return nil, errors.New("patient ID is required") + } + if req.StudyUID == "" { + return nil, ErrInvalidStudyUID + } + if req.DOB == "" { + return nil, errors.New("date of birth is required") + } + + // Normalize DOB format (ensure YYYY-MM-DD) + dob := normalizeDOB(req.DOB) + if !isValidDOBFormat(dob) { + return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD") + } + + // Set expiration if not provided + expiresIn := s.defaultExpiryTime + if req.ExpiresIn > 0 { + expiresIn = time.Duration(req.ExpiresIn) * time.Hour + } + expiresAt := time.Now().Add(expiresIn) + + // Generate a secure random token + token, err := generateSecureToken(ShortTokenLength) + if err != nil { + s.logger.Error("Failed to generate secure token", zap.Error(err)) + return nil, ErrCreationFailed + } + + // Hash the DOB for secure storage + hashedDOB := hashDOB(dob) + + // Create the short link record + shortLink := &models.ShortLink{ + ID: generateID(), + Token: token, + PatientID: req.PatientID, + StudyUID: req.StudyUID, + HashedDOB: hashedDOB, + ExpiresAt: expiresAt.Format(time.RFC3339), + IsRevoked: false, + CreatedAt: time.Now().Format(time.RFC3339), + CreatedByID: creatorID, + RemainingTries: s.maxAttempts, + } + + // Store the short link (in production, this would be in a database) + s.shortLinks[token] = shortLink + + // Generate the full URL using the configured base URL + fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, token) + + return &models.GenerateShortLinkResponse{ + ShortToken: token, + FullURL: fullURL, + ExpiresAt: shortLink.ExpiresAt, + }, nil +} + +// ValidateShortLink validates a short link token and DOB +func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) (*models.ShortLink, error) { + // Get the token using the helper method that handles both field names + token := req.GetToken() + + // Find the short link + shortLink, exists := s.shortLinks[token] + if !exists { + return nil, ErrShortLinkNotFound + } + + // Check if expired + expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt) + if err != nil || time.Now().After(expiresAt) { + return nil, ErrShortLinkExpired + } + + // Check if revoked + if shortLink.IsRevoked { + return nil, ErrShortLinkNotFound + } + + // Check remaining tries + if shortLink.RemainingTries <= 0 { + return nil, ErrTooManyAttempts + } + + // Normalize and hash the provided DOB + dob := normalizeDOB(req.DOB) + if !isValidDOBFormat(dob) { + // Decrement remaining tries on invalid format + shortLink.RemainingTries-- + return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD") + } + + hashedDOB := hashDOB(dob) + + // Verify the DOB + if hashedDOB != shortLink.HashedDOB { + // Decrement remaining tries on failed verification + shortLink.RemainingTries-- + return nil, ErrInvalidDOB + } + + // DOB verified, reset tries count as successful login + shortLink.RemainingTries = s.maxAttempts + + return shortLink, nil +} + +// AuthenticateWithShortLink authenticates a user using a short link and DOB +func (s *ShortLinkService) AuthenticateWithShortLink(req *models.ShortLinkAuthRequest) (*models.ShortLinkAuthResponse, error) { + // Validate the short link + shortLink, err := s.ValidateShortLink(req) + if err != nil { + return nil, err + } + + // Determine patient name (could be fetched from a database) + patientName := "Patient" // Placeholder, in production get real name + + // Create additional claims for the JWT + additionalClaims := make(map[string]interface{}) + additionalClaims["patient_id"] = shortLink.PatientID + additionalClaims["patient_name"] = patientName + additionalClaims["study_iuids"] = []string{shortLink.StudyUID} + additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", shortLink.StudyUID) + additionalClaims["study_list"] = "disabled" + + // Generate JWT + // Using a virtual "user" for the patient with a patient role + userID := fmt.Sprintf("shortlink_%s", shortLink.ID) + email := fmt.Sprintf("patient_%s@shortlink.local", shortLink.ID) // Virtual email for JWT + role := "patient" // Always patient role for shortlinks + + // Generate access token (24-hour validity for patient access) + accessToken, err := s.jwtManager.GenerateAccessToken(userID, email, role, patientName, additionalClaims) + if err != nil { + s.logger.Error("Failed to generate JWT for shortlink auth", zap.Error(err)) + return nil, errors.New("authentication error") + } + + // Create response + redirectURL := fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", shortLink.StudyUID) + + return &models.ShortLinkAuthResponse{ + AccessToken: accessToken, + ExpiresIn: int(s.jwtManager.GetAccessExpiry().Seconds()), + RedirectURL: redirectURL, + }, nil +} + +// Helper functions + +// hashDOB creates a secure hash of a date of birth +func hashDOB(dob string) string { + hash := sha256.Sum256([]byte(dob)) + return hex.EncodeToString(hash[:]) +} + +// generateSecureToken generates a secure random token for short links +func generateSecureToken(length int) (string, error) { + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b)[:length], nil +} + +// generateID generates a unique ID for a shortlink +func generateID() string { + b := make([]byte, 6) + rand.Read(b) + return fmt.Sprintf("sl_%s", hex.EncodeToString(b)) +} + +// normalizeDOB normalizes date of birth to YYYY-MM-DD format +func normalizeDOB(dob string) string { + // Remove any non-alphanumeric characters except dash + dob = strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '-' { + return r + } + return -1 + }, dob) + + return dob +} + +// isValidDOBFormat checks if the DOB is in YYYY-MM-DD format +func isValidDOBFormat(dob string) bool { + // Check basic format + if len(dob) != 10 { + return false + } + + // Check dashes + if dob[4] != '-' || dob[7] != '-' { + return false + } + + // Parse year, month, day + yearStr := dob[0:4] + monthStr := dob[5:7] + dayStr := dob[8:10] + + // Check if all are numeric + for _, ch := range yearStr + monthStr + dayStr { + if ch < '0' || ch > '9' { + return false + } + } + + // Could add more validation here (leap years, month/day ranges) + + return true +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 747e700..b0cc718 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -170,3 +170,13 @@ func (m *JWTManager) ValidateToken(tokenString string) (*CustomClaims, error) { return claims, nil } + +// GetAccessExpiry returns the configured access token expiry duration +func (m *JWTManager) GetAccessExpiry() time.Duration { + return m.accessExpiry +} + +// GetRefreshExpiry returns the configured refresh token expiry duration +func (m *JWTManager) GetRefreshExpiry() time.Duration { + return m.refreshExpiry +} diff --git a/test/http/shortlink-flow.http b/test/http/shortlink-flow.http new file mode 100644 index 0000000..3ed58c6 --- /dev/null +++ b/test/http/shortlink-flow.http @@ -0,0 +1,50 @@ +### Shortlink Authentication Test File +@baseUrl = http://localhost:5555 +@adminToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInVzZXJfbmFtZSI6IkFkbWluIFVzZXIiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiaG9tZV91cmwiOiIvIiwic3R1ZHlfbGlzdCI6ImVuYWJsZWQiLCJleHAiOjE3NDcyMDY5NTAsImlhdCI6MTc0NzEyMDU1MH0.M3fhlKB8MX-NxGdEgnaA9-AhMXnXjUjRsWYOBXntJe4 + +### 0. Login as Admin and take the token +POST http://localhost:5555/auth/login +Content-Type: application/json + +{ + "email": "admin", + "password": "admin" +} + + +### 1. Generate Short Link (Admin/Expertise Doctor Only) +POST {{baseUrl}}/generate-link +Content-Type: application/json +Authorization: Bearer {{adminToken}} + +{ + "patient_id": "MR00000359", + "study_uid": "1.2.826.0.1.3680043.9.7307.1.202503196393.01", + "dob": "2001-10-07", + "expires_in": 48 +} + +### 2. Authenticate with Short Link and DOB +# Use the short_token from the response of request #1 +POST {{baseUrl}}/auth/shortlink +Content-Type: application/json + +{ + "short_token": "oP-tLWeu", + "dob": "2001-10-07" +} + +### 3. Try authentication with incorrect DOB +POST {{baseUrl}}/auth/shortlink +Content-Type: application/json + +{ + "short_token": "erB5xT5S", + "dob": "1985-04-16" +} + +### 4. Access DICOM data with the JWT from successful shortlink auth +# Replace with token from successful auth in request #2 +GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01 +Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2hvcnRsaW5rX3NsXzgzZGNmNzQ1NGYwZiIsImVtYWlsIjoicGF0aWVudF9zbF84M2RjZjc0NTRmMGZAc2hvcnRsaW5rLmxvY2FsIiwicm9sZSI6InBhdGllbnQiLCJ1c2VyX25hbWUiOiJQYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsInBhdGllbnRfaWQiOiJNUjAwMDAwMzU5IiwicGF0aWVudF9uYW1lIjoiUGF0aWVudCIsInN0dWR5X2l1aWRzIjpbIjEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIl0sImhvbWVfdXJsIjoidmlld2VyP1N0dWR5SW5zdGFuY2VVSURzPTEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIiwic3R1ZHlfbGlzdCI6ImRpc2FibGVkIiwiZXhwIjoxNzQ3MjA3MDI4LCJpYXQiOjE3NDcxMjA2Mjh9.RMGF9ParYAmOXbJqd0DP2kl0X6O0n8j_LI6FF9el4qM \ No newline at end of file