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" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" "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. 5 digit kapital semua ShortTokenLength = 5 ) 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") ErrNoShortcodes = errors.New("no unused shortcodes available") ) // ShortLinkService handles operations related to short links type ShortLinkService struct { jwtManager *auth.JWTManager logger *zap.Logger shortLinkRepo *repository.ShortLinkRepository shortCodeRepo *repository.ShortCodeRepository 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(), shortCodeRepo: repository.NewShortCodeRepository(), 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") } // Check if an unexpired shortlink already exists for this patient and study existingShortLink, err := s.shortLinkRepo.GetActiveShortLinkByPatientAndStudy(req.PatientID, req.StudyUID) if err != nil { s.logger.Error("Error checking for existing shortlinks", zap.Error(err)) return nil, ErrCreationFailed } // If an active shortlink exists, return it instead of creating a new one if existingShortLink != nil { s.logger.Info("Returning existing active shortlink", zap.String("patientID", req.PatientID), zap.String("studyUID", req.StudyUID), zap.String("token", existingShortLink.Token)) // Generate the full URL using the configured base URL fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, existingShortLink.Token) return &models.GenerateShortLinkResponse{ ShortToken: existingShortLink.Token, FullURL: fullURL, ExpiresAt: existingShortLink.ExpiresAt, IsExisting: true, }, nil } // Set expiration if not provided expiresIn := s.defaultExpiryTime if req.ExpiresIn > 0 { expiresIn = time.Duration(req.ExpiresIn) * time.Hour } expiresAt := time.Now().Add(expiresIn) // Hash the DOB for secure storage hashedDOB := hashDOB(dob) // Start a transaction for creating the shortlink and updating the shortcode tx, err := database.DB.Beginx() if err != nil { s.logger.Error("Failed to start transaction", zap.Error(err)) return nil, fmt.Errorf("database error: %w", err) } // Set up deferred rollback that will be canceled if we commit defer func() { if tx != nil { tx.Rollback() } }() // Get an unused shortcode from the bank unusedShortcode, err := s.shortCodeRepo.GetUnusedShortCode() if err != nil { s.logger.Error("Failed to get unused shortcode", zap.Error(err)) return nil, ErrNoShortcodes } if unusedShortcode == nil { s.logger.Error("No unused shortcodes available in the bank") return nil, ErrNoShortcodes } // Create the short link record shortLink := &models.ShortLink{ Token: *unusedShortcode, 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 using transaction err = s.shortLinkRepo.CreateShortLinkTx(tx, shortLink) if err != nil { s.logger.Error("Failed to store shortlink in database", zap.Error(err)) return nil, ErrCreationFailed } // Get the ID of the created shortlink var shortlinkID int err = tx.Get(&shortlinkID, "SELECT ShortlinkID FROM shortlink WHERE ShortlinkCode = ?", shortLink.Token) if err != nil { s.logger.Error("Failed to get shortlink ID", zap.Error(err)) return nil, ErrCreationFailed } // Mark the shortcode as used err = s.shortCodeRepo.MarkShortCodeAsUsed(tx, shortLink.Token, shortlinkID) if err != nil { s.logger.Error("Failed to mark shortcode as used", zap.Error(err)) return nil, ErrCreationFailed } // Commit the transaction if err = tx.Commit(); err != nil { s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, errors.New("database error") } // Clear the tx to prevent the deferred rollback tx = nil // Generate the full URL using the configured base URL fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, shortLink.Token) return &models.GenerateShortLinkResponse{ ShortToken: shortLink.Token, FullURL: fullURL, ExpiresAt: shortLink.ExpiresAt, IsExisting: false, }, 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) { // Use a transaction for updating the tries counter tx, err := database.DB.Beginx() if err != nil { s.logger.Error("Failed to start transaction", zap.Error(err)) return nil, fmt.Errorf("database error: %w", err) } // Set up deferred rollback that will be canceled if we commit defer func() { if tx != nil { tx.Rollback() } }() // Decrement remaining tries on invalid format shortLink.RemainingTries-- // Update the shortlink in the database using the transaction err = s.shortLinkRepo.UpdateShortLinkTx(tx, 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") } // Commit the transaction if err = tx.Commit(); err != nil { s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, errors.New("database error") } // Clear the tx to prevent the deferred rollback tx = nil return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD") } hashedDOB := hashDOB(dob) // Start a transaction for updating the tries counter tx, err := database.DB.Beginx() if err != nil { s.logger.Error("Failed to start transaction", zap.Error(err)) return nil, fmt.Errorf("database error: %w", err) } // Set up deferred rollback that will be canceled if we commit defer func() { if tx != nil { tx.Rollback() } }() // Verify the DOB if hashedDOB != shortLink.HashedDOB { // Decrement remaining tries on failed verification shortLink.RemainingTries-- // Update the shortlink in the database using the transaction err = s.shortLinkRepo.UpdateShortLinkTx(tx, shortLink) if err != nil { s.logger.Error("Failed to update shortlink tries", zap.Error(err)) return nil, ErrInvalidDOB } // Commit the transaction if err = tx.Commit(); err != nil { s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, errors.New("database error") } // Clear the tx to prevent the deferred rollback tx = nil return nil, ErrInvalidDOB } // DOB verified, reset tries count as successful login shortLink.RemainingTries = s.maxAttempts // Update the shortlink in the database using the transaction err = s.shortLinkRepo.UpdateShortLinkTx(tx, shortLink) if err != nil { s.logger.Error("Failed to update shortlink tries after successful validation", zap.Error(err)) return nil, errors.New("database error") } // Commit the transaction if err = tx.Commit(); err != nil { s.logger.Error("Failed to commit transaction", zap.Error(err)) return nil, errors.New("database error") } // Clear the tx to prevent the deferred rollback tx = nil 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 } // CleanupExpiredShortcodes checks for expired shortlinks and frees their shortcodes func (s *ShortLinkService) CleanupExpiredShortcodes() (int, error) { count, err := s.shortCodeRepo.MarkExpiredShortlinkShortcodesAsUnused() if err != nil { s.logger.Error("Failed to clean up expired shortcodes", zap.Error(err)) return 0, err } if count > 0 { s.logger.Info("Freed shortcodes from expired shortlinks", zap.Int("count", count)) } return count, 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 }