From 36417fe515d655d3101fc75d0a9a57ffe0c3a4ac Mon Sep 17 00:00:00 2001 From: mario Date: Fri, 16 May 2025 10:36:19 +0700 Subject: [PATCH] add: /uploaded_dicom for pydicom-google-upload to get shortlink --- config/config.go | 1 + config/config.yaml | 3 +- internal/api/handlers/pydicom.go | 158 +++++++++++++++++++++++++++ internal/api/middleware/pydicom.go | 32 ++++++ internal/api/repository/shortlink.go | 45 +++++--- internal/api/routes.go | 21 ++++ test/http/pydicom-upload.http | 38 +++++++ 7 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 internal/api/handlers/pydicom.go create mode 100644 internal/api/middleware/pydicom.go create mode 100644 test/http/pydicom-upload.http diff --git a/config/config.go b/config/config.go index df348f3..3a95f28 100644 --- a/config/config.go +++ b/config/config.go @@ -31,6 +31,7 @@ type Config struct { AccessTokenExpiry int `mapstructure:"access_token_expiry"` // in minutes RefreshTokenExpiry int `mapstructure:"refresh_token_expiry"` // in hours EnableDatabaseAuth bool `mapstructure:"enable_database_auth"` + PydicomApiKey string `mapstructure:"pydicom_api_key"` // API Key for PYDICOM uploader service } `mapstructure:"auth"` Shortlink struct { diff --git a/config/config.yaml b/config/config.yaml index b4fb7af..345cdbc 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -18,10 +18,11 @@ auth: access_token_expiry: 1440 # minutes (24 hours) refresh_token_expiry: 168 # hours (7 days) enable_database_auth: true # Changed to true to use database + pydicom_api_key: "2f0ff447b2c3aeef2004e83a750ded97e29ba8c0ccc70053d5e26f5d715e42ff" 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) + default_expiry_hours: 30 * 24 # Default expiry time for shortlinks (30 days) max_attempts: 5 # Maximum number of failed login attempts database: diff --git a/internal/api/handlers/pydicom.go b/internal/api/handlers/pydicom.go new file mode 100644 index 0000000..f59f570 --- /dev/null +++ b/internal/api/handlers/pydicom.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "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" +) + +// PydicomHandler handles operations related to PYDICOM uploads +type PydicomHandler struct { + logger *zap.Logger + shortLinkService *service.ShortLinkService + registerService *service.RegisterService +} + +// NewPydicomHandler creates a new PydicomHandler +func NewPydicomHandler(logger *zap.Logger, shortLinkService *service.ShortLinkService, registerService *service.RegisterService) *PydicomHandler { + return &PydicomHandler{ + logger: logger, + shortLinkService: shortLinkService, + registerService: registerService, + } +} + +// HandleUploadedDicom processes a request from the PYDICOM uploader service to register a patient and generate a shortlink +func (h *PydicomHandler) HandleUploadedDicom(w http.ResponseWriter, r *http.Request) { + // Parse the request body into a temporary struct that matches the expected JSON + var reqData struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Role string `json:"role"` + Patient struct { + PatientID string `json:"patient_id"` + PatientName string `json:"patient_name"` + DateOfBirth string `json:"date_of_birth"` + } `json:"patient"` + Studies []struct { + StudyInstanceUID string `json:"study_instance_uid"` + AccessionNumber string `json:"accession_number"` + StudyDate string `json:"study_date"` + StudyDescription string `json:"study_description"` + } `json:"studies"` + } + + if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { + h.logger.Error("Failed to parse uploaded DICOM request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate the request + if reqData.Email == "" || reqData.Password == "" || reqData.Name == "" { + h.logger.Error("Missing required user fields in uploaded DICOM request") + http.Error(w, "Missing required user fields", http.StatusBadRequest) + return + } + + if reqData.Patient.PatientID == "" || reqData.Patient.DateOfBirth == "" { + h.logger.Error("Missing required patient fields in uploaded DICOM request") + http.Error(w, "Missing required patient fields", http.StatusBadRequest) + return + } + + if len(reqData.Studies) == 0 || reqData.Studies[0].StudyInstanceUID == "" { + h.logger.Error("Missing required study fields in uploaded DICOM request") + http.Error(w, "Missing required study fields", http.StatusBadRequest) + return + } + + // Convert to our internal models + patientDetails := &models.PatientDetails{ + PatientID: reqData.Patient.PatientID, + PatientName: reqData.Patient.PatientName, + DateOfBirth: reqData.Patient.DateOfBirth, + } + + // Convert studies to our internal model + studies := make([]models.Study, len(reqData.Studies)) + for i, s := range reqData.Studies { + studies[i] = models.Study{ + StudyInstanceUID: s.StudyInstanceUID, + AccessionNumber: s.AccessionNumber, + StudyDate: s.StudyDate, + StudyDescription: s.StudyDescription, + } + } + + // Create registration request + regRequest := &service.RegisterRequest{ + Email: reqData.Email, + Password: reqData.Password, + Name: reqData.Name, + Role: "patient", // Force the role to be "patient" regardless of what was sent + Patient: patientDetails, + Studies: studies, + } + + // Register the patient (or confirm it exists) using the RegisterService + user, err := h.registerService.Register(regRequest) + if err != nil { + // If the error is about duplicate email, we'll continue with generating a shortlink + // Otherwise, return the error + if err != service.ErrEmailExists { + h.logger.Error("Failed to register patient", zap.Error(err)) + http.Error(w, fmt.Sprintf("Failed to register patient: %v", err), http.StatusInternalServerError) + return + } + + h.logger.Info("Patient already exists, continuing with shortlink generation", + zap.String("email", reqData.Email), + zap.String("patientID", reqData.Patient.PatientID)) + } else { + h.logger.Info("Patient registered successfully", + zap.String("userID", user.ID), + zap.String("email", reqData.Email), + zap.String("patientID", reqData.Patient.PatientID)) + } + + // For each study, generate a shortlink + // For simplicity, we'll just use the first study in the array + study := reqData.Studies[0] + + // Create a shortlink request + shortLinkReq := &models.GenerateShortLinkRequest{ + PatientID: reqData.Patient.PatientID, + StudyUID: study.StudyInstanceUID, + DOB: reqData.Patient.DateOfBirth, + ExpiresIn: 0, // Set to 0 to use the default expiry from config + } + + // Generate a shortlink + // We set empty string as creatorID because you mentioned ShortlinkCreate_UserID is now nullable + shortLinkResp, err := h.shortLinkService.GenerateShortLink(shortLinkReq, "") + if err != nil { + h.logger.Error("Failed to generate shortlink", + zap.Error(err), + zap.String("patientID", reqData.Patient.PatientID), + zap.String("studyUID", study.StudyInstanceUID)) + http.Error(w, fmt.Sprintf("Failed to generate shortlink: %v", err), http.StatusInternalServerError) + return + } + + // Log successful shortlink generation + h.logger.Info("Shortlink generated for uploaded DICOM", + zap.String("patientID", reqData.Patient.PatientID), + zap.String("studyUID", study.StudyInstanceUID), + zap.String("shortToken", shortLinkResp.ShortToken)) + + // Return the shortlink response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(shortLinkResp) +} diff --git a/internal/api/middleware/pydicom.go b/internal/api/middleware/pydicom.go new file mode 100644 index 0000000..5cb2f1e --- /dev/null +++ b/internal/api/middleware/pydicom.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "net/http" + + "go.uber.org/zap" +) + +// PydicomAPIKey validates requests to pydicom endpoints by checking the API key +func PydicomAPIKey(apiKey string, logger *zap.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the API key header is present + providedKey := r.Header.Get("X-PYDICOM-API-KEY") + if providedKey == "" { + logger.Warn("API key missing from PYDICOM request") + http.Error(w, "API key required", http.StatusUnauthorized) + return + } + + // Validate the API key + if providedKey != apiKey { + logger.Warn("Invalid API key for PYDICOM request") + http.Error(w, "Invalid API key", http.StatusUnauthorized) + return + } + + // API key is valid, proceed to the next handler + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/api/repository/shortlink.go b/internal/api/repository/shortlink.go index 2c158f7..5d80c80 100644 --- a/internal/api/repository/shortlink.go +++ b/internal/api/repository/shortlink.go @@ -13,16 +13,16 @@ import ( // DBShortLink represents a shortlink from the database type DBShortLink struct { - ShortlinkID int `db:"ShortlinkID"` - ShortlinkCode string `db:"ShortlinkCode"` - Shortlink_PatientID string `db:"Shortlink_PatientID"` - Shortlink_Study_IUID string `db:"Shortlink_Study_IUID"` - ShortlinkHashDoB string `db:"ShortlinkHashDoB"` - ShortlinkExpiredAt time.Time `db:"ShortlinkExpiredAt"` - ShortlinkIsRevoked bool `db:"ShortlinkIsRevoked"` - ShortlinkRemainingTries int `db:"ShortlinkRemainingTries"` - ShortlinkCreatedAt time.Time `db:"ShortlinkCreatedAt"` - ShortlinkCreate_UserID int `db:"ShortlinkCreate_UserID"` + ShortlinkID int `db:"ShortlinkID"` + ShortlinkCode string `db:"ShortlinkCode"` + Shortlink_PatientID string `db:"Shortlink_PatientID"` + Shortlink_Study_IUID string `db:"Shortlink_Study_IUID"` + ShortlinkHashDoB string `db:"ShortlinkHashDoB"` + ShortlinkExpiredAt time.Time `db:"ShortlinkExpiredAt"` + ShortlinkIsRevoked bool `db:"ShortlinkIsRevoked"` + ShortlinkRemainingTries int `db:"ShortlinkRemainingTries"` + ShortlinkCreatedAt time.Time `db:"ShortlinkCreatedAt"` + ShortlinkCreate_UserID sql.NullInt64 `db:"ShortlinkCreate_UserID"` } // ShortLinkRepository handles database operations related to shortlinks @@ -39,6 +39,13 @@ func NewShortLinkRepository() *ShortLinkRepository { // ToShortLink converts a DBShortLink to a ShortLink model func (s *DBShortLink) ToShortLink() *models.ShortLink { + var createdByID string + if s.ShortlinkCreate_UserID.Valid { + createdByID = fmt.Sprintf("%d", s.ShortlinkCreate_UserID.Int64) + } else { + createdByID = "" + } + return &models.ShortLink{ ID: fmt.Sprintf("%d", s.ShortlinkID), Token: s.ShortlinkCode, @@ -49,7 +56,7 @@ func (s *DBShortLink) ToShortLink() *models.ShortLink { IsRevoked: s.ShortlinkIsRevoked, RemainingTries: s.ShortlinkRemainingTries, CreatedAt: s.ShortlinkCreatedAt.Format(time.RFC3339), - CreatedByID: fmt.Sprintf("%d", s.ShortlinkCreate_UserID), + CreatedByID: createdByID, } } @@ -84,9 +91,19 @@ func (r *ShortLinkRepository) CreateShortLinkTx(tx *sqlx.Tx, shortLink *models.S ShortlinkCreate_UserID) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)` - createdByID, err := strconv.Atoi(shortLink.CreatedByID) - if err != nil { - return fmt.Errorf("invalid created by ID: %w", err) + var createdByID sql.NullInt64 + + // Handle empty CreatedByID + if shortLink.CreatedByID != "" { + id, err := strconv.Atoi(shortLink.CreatedByID) + if err != nil { + return fmt.Errorf("invalid created by ID: %w", err) + } + createdByID.Int64 = int64(id) + createdByID.Valid = true + } else { + // If CreatedByID is empty, insert NULL + createdByID.Valid = false } expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt) diff --git a/internal/api/routes.go b/internal/api/routes.go index decc3e2..db2292e 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -141,5 +141,26 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { }) }) + // PYDICOM Uploader Service endpoint + // This endpoint is protected by API key middleware + r.Group(func(r chi.Router) { + // Apply PYDICOM API key middleware + r.Use(apiMiddleware.PydicomAPIKey(cfg.Auth.PydicomApiKey, logger)) + + // Create handler for PYDICOM uploads + registerService := service.NewRegisterService(logger) + shortLinkService := service.NewShortLinkService( + jwtManager, + logger, + cfg.Shortlink.BaseURL, + cfg.Shortlink.DefaultExpiryHours, + cfg.Shortlink.MaxAttempts, + ) + pydicomHandler := handlers.NewPydicomHandler(logger, shortLinkService, registerService) + + // Add route for uploaded DICOM + r.Post("/uploaded_dicom", pydicomHandler.HandleUploadedDicom) + }) + return r } diff --git a/test/http/pydicom-upload.http b/test/http/pydicom-upload.http new file mode 100644 index 0000000..2574da8 --- /dev/null +++ b/test/http/pydicom-upload.http @@ -0,0 +1,38 @@ +# pydicom-upload.http +# This file can be used with REST Client extension in VS Code to test the PYDICOM upload endpoint + +@baseUrl = http://localhost:5555 +@pydicomApiKey=2f0ff447b2c3aeef2004e83a750ded97e29ba8c0ccc70053d5e26f5d715e42ff + +### Test the PYDICOM upload endpoint +POST {{baseUrl}}/uploaded_dicom +X-PYDICOM-API-KEY: {{pydicomApiKey}} +Content-Type: application/json + +{ + "email": "patient2@example.com", + "password": "password123", + "name": "Bobon Santoso", + "role": "patient", + "patient": { + "patient_id": "MR00000359", + "patient_name": "Bobon Santoso", + "date_of_birth": "1985-01-01" + }, + "studies": [ + { + "study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.202503196393.01", + "accession_number": "CR.250319.6393.01", + "study_date": "2025-03-19", + "study_description": "MRI Scan" + } + ] +} + +### Expected Response: +# { +# "short_token": "LDYZX", +# "full_url": "http://localhost:3333/short-auth?short=LDYZX", +# "expires_at": "2025-05-18T02:04:46Z", +# "is_existing": true +# } \ No newline at end of file