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/api/repository" "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 shortLinkRepo *repository.ShortLinkRepository patientRepo *repository.PatientRepository // Configuration settings baseURL string defaultExpiryTime time.Duration maxAttempts int } // 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, shortLinkRepo: repository.NewShortLinkRepository(), patientRepo: repository.NewPatientRepository(), baseURL: baseURL, defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour, maxAttempts: maxAttempts, } } // 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 the database err = s.shortLinkRepo.CreateShortLink(shortLink) if err != nil { s.logger.Error("Failed to store shortlink in database", zap.Error(err)) return nil, ErrCreationFailed } // 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 in the database shortLink, err := s.shortLinkRepo.GetShortLinkByToken(token) if err != nil { s.logger.Error("Error retrieving shortlink", zap.Error(err), zap.String("token", token)) return nil, ErrShortLinkNotFound } if shortLink == nil { 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-- // Update the shortlink in the database err = s.shortLinkRepo.UpdateShortLink(shortLink) if err != nil { s.logger.Error("Failed to update shortlink tries", zap.Error(err)) } 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-- // Update the shortlink in the database err = s.shortLinkRepo.UpdateShortLink(shortLink) if err != nil { s.logger.Error("Failed to update shortlink tries", zap.Error(err)) } return nil, ErrInvalidDOB } // DOB verified, reset tries count as successful login shortLink.RemainingTries = s.maxAttempts // Update the shortlink in the database err = s.shortLinkRepo.UpdateShortLink(shortLink) if err != nil { s.logger.Error("Failed to update shortlink tries after successful validation", zap.Error(err)) } 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 }