diff --git a/cmd/seed_shortcodes/seed_shortcodes.go b/cmd/seed_shortcodes/seed_shortcodes.go new file mode 100644 index 0000000..81e7bc2 --- /dev/null +++ b/cmd/seed_shortcodes/seed_shortcodes.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/repository" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" +) + +func main() { + + /* + * HOW TO USE: + * `go run cmd/seed_shortcodes/seed_shortcodes.go -dsn="user:password@tcp(host_db:port)/nama_db_ohif_proxy"` + * + * Ini akan seeding DB dengan 3333 data (sesuai var count) + */ + + // Parse command line flags + count := flag.Int("count", 3333, "Number of shortcodes to generate") + dsn := flag.String("dsn", "", "Database connection string (required)") + flag.Parse() + + // Check if DSN is provided + if *dsn == "" { + fmt.Println("Error: Database connection string (DSN) is required") + fmt.Println("Usage: go run seed_shortcodes.go -dsn=user:password@tcp(localhost:3306)/database_name -count=1000") + os.Exit(1) + } + + // Initialize database connection + err := database.Initialize(*dsn, 10, 5, 60*time.Minute) + if err != nil { + fmt.Printf("Failed to connect to database: %v\n", err) + os.Exit(1) + } + defer database.Close() + + // Create a shortcode repository + shortCodeRepo := repository.NewShortCodeRepository() + + // Seed the shortcodes + fmt.Printf("Seeding %d shortcodes...\n", *count) + err = shortCodeRepo.SeedShortCodes(*count) + if err != nil { + fmt.Printf("Error seeding shortcodes: %v\n", err) + os.Exit(1) + } + + fmt.Println("Shortcode seeding completed successfully!") +} diff --git a/internal/api/handlers/shortlink.go b/internal/api/handlers/shortlink.go index ca002a9..d66621b 100644 --- a/internal/api/handlers/shortlink.go +++ b/internal/api/handlers/shortlink.go @@ -127,3 +127,38 @@ func (h *ShortLinkHandler) ShortLinkAuth(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) } + +// RecycleShortcodes handles requests to recycle shortcodes from expired shortlinks +func (h *ShortLinkHandler) RecycleShortcodes(w http.ResponseWriter, r *http.Request) { + // Only allow admin role to recycle shortcodes + userRole, ok := r.Context().Value(middleware.UserRoleKey).(string) + if !ok || userRole != "admin" { + h.logger.Warn("Unauthorized attempt to recycle shortcodes", + zap.String("role", userRole)) + http.Error(w, "Only admin can recycle shortcodes", http.StatusForbidden) + return + } + + // Call the service to recycle shortcodes + count, err := h.shortLinkService.CleanupExpiredShortcodes() + if err != nil { + h.logger.Error("Failed to recycle shortcodes", zap.Error(err)) + http.Error(w, "Failed to recycle shortcodes", http.StatusInternalServerError) + return + } + + // Log successful recycling + h.logger.Info("Shortcodes recycled successfully", + zap.Int("count", count)) + + // Return response + response := map[string]interface{}{ + "status": "success", + "message": "Shortcodes recycled successfully", + "count": count, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/internal/api/repository/shortcode.go b/internal/api/repository/shortcode.go new file mode 100644 index 0000000..49e69be --- /dev/null +++ b/internal/api/repository/shortcode.go @@ -0,0 +1,218 @@ +package repository + +import ( + "fmt" + "math/rand" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" + "github.com/jmoiron/sqlx" +) + +// DBShortCode represents a shortcode from the database +type DBShortCode struct { + ID int `db:"id"` + Shortcode string `db:"Shortcode"` + ShortcodeIsUsed string `db:"ShortcodeIsUsed"` + ShortcodeIsUsedBy_ShortlinkID *int `db:"ShortcodeIsUsedBy_ShortlinkID"` + ShortcodeCreatedAt time.Time `db:"ShortcodeCreatedAt"` + ShortcodeUpdatedAt time.Time `db:"ShortcodeUpdatedAt"` +} + +// ShortCodeRepository handles database operations related to shortcodes +type ShortCodeRepository struct { + *Repository +} + +// NewShortCodeRepository creates a new shortcode repository +func NewShortCodeRepository() *ShortCodeRepository { + return &ShortCodeRepository{ + Repository: NewRepository(), + } +} + +// GetUnusedShortCode retrieves an unused shortcode from the database +func (r *ShortCodeRepository) GetUnusedShortCode() (*string, error) { + var shortcode string + + query := `SELECT Shortcode FROM shortcodes + WHERE ShortcodeIsUsed = 'N' + ORDER BY RAND() + LIMIT 1` + + err := database.DB.Get(&shortcode, query) + if err != nil { + return nil, fmt.Errorf("database error getting unused shortcode: %w", err) + } + + return &shortcode, nil +} + +// MarkShortCodeAsUsed marks a shortcode as used by a specific shortlink +func (r *ShortCodeRepository) MarkShortCodeAsUsed(tx *sqlx.Tx, shortcode string, shortlinkID int) error { + query := `UPDATE shortcodes + SET ShortcodeIsUsed = 'Y', + ShortcodeIsUsedBy_ShortlinkID = ? + WHERE Shortcode = ?` + + _, err := tx.Exec(query, shortlinkID, shortcode) + if err != nil { + return fmt.Errorf("database error marking shortcode as used: %w", err) + } + + return nil +} + +// MarkShortCodeAsUnused marks a shortcode as unused when shortlink expires or is deleted +func (r *ShortCodeRepository) MarkShortCodeAsUnused(shortcode string) error { + query := `UPDATE shortcodes + SET ShortcodeIsUsed = 'N', + ShortcodeIsUsedBy_ShortlinkID = NULL + WHERE Shortcode = ?` + + _, err := database.DB.Exec(query, shortcode) + if err != nil { + return fmt.Errorf("database error marking shortcode as unused: %w", err) + } + + return nil +} + +// MarkShortCodeAsUnusedByShortlinkID marks a shortcode as unused by shortlink ID +func (r *ShortCodeRepository) MarkShortCodeAsUnusedByShortlinkID(shortlinkID int) error { + query := `UPDATE shortcodes + SET ShortcodeIsUsed = 'N', + ShortcodeIsUsedBy_ShortlinkID = NULL + WHERE ShortcodeIsUsedBy_ShortlinkID = ?` + + _, err := database.DB.Exec(query, shortlinkID) + if err != nil { + return fmt.Errorf("database error marking shortcode as unused by shortlink ID: %w", err) + } + + return nil +} + +// MarkExpiredShortlinkShortcodesAsUnused finds all shortcodes associated with expired shortlinks +// and marks them as unused +func (r *ShortCodeRepository) MarkExpiredShortlinkShortcodesAsUnused() (int, error) { + query := ` + UPDATE shortcodes sc + INNER JOIN shortlink sl ON sc.ShortcodeIsUsedBy_ShortlinkID = sl.ShortlinkID + SET sc.ShortcodeIsUsed = 'N', + sc.ShortcodeIsUsedBy_ShortlinkID = NULL + WHERE (sl.ShortlinkExpiredAt < NOW() OR sl.ShortlinkIsRevoked = TRUE) + AND sc.ShortcodeIsUsed = 'Y' + ` + + result, err := database.DB.Exec(query) + if err != nil { + return 0, fmt.Errorf("database error freeing expired shortlink shortcodes: %w", err) + } + + count, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("error getting affected rows count: %w", err) + } + + return int(count), nil +} + +// GenerateUniqueShortCode generates a unique 5-character capital letter shortcode +func GenerateUniqueShortCode() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const codeLength = 5 + + code := make([]byte, codeLength) + for i := range code { + code[i] = charset[rand.Intn(len(charset))] + } + + return string(code) +} + +// SeedShortCodes seeds the shortcodes table with n unique 5-character codes +func (r *ShortCodeRepository) SeedShortCodes(n int) error { + // Initialize random seed + rand.Seed(time.Now().UnixNano()) + + // Use a transaction for better performance + tx, err := database.DB.Beginx() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + + // Set up deferred rollback that will be canceled if we commit + defer func() { + if tx != nil { + tx.Rollback() + } + }() + + // Check how many shortcodes already exist + var count int + err = tx.Get(&count, "SELECT COUNT(*) FROM shortcodes") + if err != nil { + return fmt.Errorf("failed to count existing shortcodes: %w", err) + } + + fmt.Printf("Found %d existing shortcodes, generating %d more\n", count, n) + + // Track already generated codes to avoid duplicates + generatedCodes := make(map[string]bool) + + // Get existing codes to avoid duplicates + existingCodes := []string{} + err = tx.Select(&existingCodes, "SELECT Shortcode FROM shortcodes") + if err != nil { + return fmt.Errorf("failed to retrieve existing shortcodes: %w", err) + } + + // Add existing codes to the map + for _, code := range existingCodes { + generatedCodes[code] = true + } + + // Prepare the insert statement + stmt, err := tx.Prepare("INSERT INTO shortcodes (Shortcode, ShortcodeIsUsed) VALUES (?, 'N')") + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + // Generate and insert the shortcodes + inserted := 0 + attempts := 0 + maxAttempts := n * 10 // Limiting attempts to avoid infinite loop + + for inserted < n && attempts < maxAttempts { + attempts++ + code := GenerateUniqueShortCode() + + if !generatedCodes[code] { + _, err := stmt.Exec(code) + if err != nil { + continue // Skip if insertion fails and try another code + } + + generatedCodes[code] = true + inserted++ + + // Print progress every 100 codes + if inserted%100 == 0 { + fmt.Printf("Generated %d/%d codes\n", inserted, n) + } + } + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + // Clear the tx to prevent the deferred rollback + tx = nil + + fmt.Printf("Successfully generated %d unique shortcodes\n", inserted) + return nil +} diff --git a/internal/api/routes.go b/internal/api/routes.go index efd7a33..decc3e2 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -108,6 +108,9 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService) r.Post("/generate-link", shortLinkHandler.GenerateShortLink) + // Shortcode recycling - only for admin role (role check is in the handler) + r.Post("/recycle-shortcode", shortLinkHandler.RecycleShortcodes) + // 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 index ae2ab52..f6cf760 100644 --- a/internal/api/service/shortlink_service.go +++ b/internal/api/service/shortlink_service.go @@ -24,8 +24,8 @@ const ( // 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 + // ShortTokenLength is the length of the generated short token. 5 digit kapital semua + ShortTokenLength = 5 ) var ( @@ -36,6 +36,7 @@ var ( 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 @@ -43,6 +44,7 @@ type ShortLinkService struct { jwtManager *auth.JWTManager logger *zap.Logger shortLinkRepo *repository.ShortLinkRepository + shortCodeRepo *repository.ShortCodeRepository patientRepo *repository.PatientRepository // Configuration settings baseURL string @@ -69,6 +71,7 @@ func NewShortLinkService(jwtManager *auth.JWTManager, logger *zap.Logger, baseUR jwtManager: jwtManager, logger: logger, shortLinkRepo: repository.NewShortLinkRepository(), + shortCodeRepo: repository.NewShortCodeRepository(), patientRepo: repository.NewPatientRepository(), baseURL: baseURL, defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour, @@ -127,31 +130,10 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques } 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, - } - - // Start a transaction for creating the shortlink + // 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)) @@ -165,6 +147,31 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques } }() + // 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 { @@ -172,6 +179,21 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques 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)) @@ -182,10 +204,10 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques tx = nil // Generate the full URL using the configured base URL - fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, token) + fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, shortLink.Token) return &models.GenerateShortLinkResponse{ - ShortToken: token, + ShortToken: shortLink.Token, FullURL: fullURL, ExpiresAt: shortLink.ExpiresAt, IsExisting: false, @@ -367,6 +389,21 @@ func (s *ShortLinkService) AuthenticateWithShortLink(req *models.ShortLinkAuthRe }, 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 diff --git a/test/http/shortlink-flow.http b/test/http/shortlink-flow.http index d03c7c8..3cf46db 100644 --- a/test/http/shortlink-flow.http +++ b/test/http/shortlink-flow.http @@ -2,7 +2,7 @@ @baseUrl = http://localhost:5555 @adminToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNiIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJBZG1pbiBVc2VyIiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImhvbWVfdXJsIjoiLyIsInN0dWR5X2xpc3QiOiJlbmFibGVkIiwiZXhwIjoxNzQ3MzY0NjgxLCJpYXQiOjE3NDcyNzgyODF9.bkzxV8f26wN_r6uI5T6o58TNX4U0z-Wel1hCAl5-8ag -### 1. Generate Short Link Single Study +### 1. Generate Short Link Single Study (Tidak ganti untuk 1 patient sampai expired) POST {{baseUrl}}/generate-link Content-Type: application/json Authorization: Bearer {{adminToken}} @@ -43,8 +43,8 @@ POST {{baseUrl}}/auth/shortlink Content-Type: application/json { - "short_token": "aePWCTrU", - "dob": "1980-01-01" + "short_token": "DZDWF", + "dob": "2001-10-07" } ### Authenticate with Incorrect DOB @@ -56,9 +56,12 @@ Content-Type: application/json "dob": "2001-10-00" } - -### 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.20180713036 +### Recycle shortcode from expired or revoked token +POST {{baseUrl}}/recycle-shortcode +Authorization: Bearer {{adminToken}} Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2hvcnRsaW5rXzIiLCJlbWFpbCI6InBhdGllbnRfMkBzaG9ydGxpbmsubG9jYWwiLCJyb2xlIjoicGF0aWVudCIsInVzZXJfbmFtZSI6IlBhdGllbnQiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwicGF0aWVudF9pZCI6IjAwMjExNjIyIiwicGF0aWVudF9uYW1lIjoiUGF0aWVudCIsInN0dWR5X2l1aWRzIjpbIjEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAxODA3MTMwMzYiXSwiaG9tZV91cmwiOiJ2aWV3ZXI_U3R1ZHlJbnN0YW5jZVVJRHM9MS4yLjgyNi4wLjEuMzY4MDA0My45LjczMDcuMS4yMDE4MDcxMzAzNiIsInN0dWR5X2xpc3QiOiJkaXNhYmxlZCIsImV4cCI6MTc0NzM2OTc1NSwiaWF0IjoxNzQ3MjgzMzU1fQ.rj1Xr7StW_O3rE2Pwq6L-WGBAW1tFcUq8bt5nu4u050 \ No newline at end of file +### Check the response from recycling +# The response should include: +# - status: "success" +# - message: "Shortcodes recycled successfully" +# - count: number of recycled shortcodes (integer)