Compare commits
9 Commits
v1-router-
...
new-domain
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa155dfbc | ||
|
|
ed3feb77d2 | ||
|
|
36417fe515 | ||
|
|
28339e855c | ||
|
|
d2ec8c0f07 | ||
|
|
264435f67e | ||
|
|
047ab1937a | ||
|
|
c13f834b92 | ||
|
|
dd4451c2a8 |
@@ -69,6 +69,12 @@ go-ohif-proxy/
|
|||||||
└── README.md # Project documentation
|
└── README.md # Project documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 2c. Alur Kerja Kode
|
||||||
|
Secara umum kode di repo ini kurang lebih memiliki alur:
|
||||||
|
```
|
||||||
|
Routes --> Handlers --> Service --> Repository
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Instalasi dan Penggunaan
|
## 3. Instalasi dan Penggunaan
|
||||||
|
|
||||||
### Prasyarat
|
### Prasyarat
|
||||||
|
|||||||
54
cmd/seed_shortcodes/seed_shortcodes.go
Normal file
54
cmd/seed_shortcodes/seed_shortcodes.go
Normal file
@@ -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!")
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/config"
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/config"
|
||||||
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api"
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,30 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer l.Sync()
|
defer l.Sync()
|
||||||
|
|
||||||
|
// Initialize database connection
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
|
||||||
|
cfg.Database.User,
|
||||||
|
cfg.Database.Password,
|
||||||
|
cfg.Database.Host,
|
||||||
|
cfg.Database.Port,
|
||||||
|
cfg.Database.Name)
|
||||||
|
|
||||||
|
err = database.Initialize(
|
||||||
|
dsn,
|
||||||
|
cfg.Database.MaxOpenConns,
|
||||||
|
cfg.Database.MaxIdleConns,
|
||||||
|
time.Duration(cfg.Database.ConnMaxLifetimeMins)*time.Minute,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
l.Fatal("Failed to initialize database", zap.Error(err))
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
l.Info("Database connection established",
|
||||||
|
zap.String("host", cfg.Database.Host),
|
||||||
|
zap.Int("port", cfg.Database.Port),
|
||||||
|
zap.String("database", cfg.Database.Name))
|
||||||
|
|
||||||
// Setup router
|
// Setup router
|
||||||
router := api.SetupRouter(cfg, l)
|
router := api.SetupRouter(cfg, l)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Config struct {
|
|||||||
AccessTokenExpiry int `mapstructure:"access_token_expiry"` // in minutes
|
AccessTokenExpiry int `mapstructure:"access_token_expiry"` // in minutes
|
||||||
RefreshTokenExpiry int `mapstructure:"refresh_token_expiry"` // in hours
|
RefreshTokenExpiry int `mapstructure:"refresh_token_expiry"` // in hours
|
||||||
EnableDatabaseAuth bool `mapstructure:"enable_database_auth"`
|
EnableDatabaseAuth bool `mapstructure:"enable_database_auth"`
|
||||||
|
PydicomApiKey string `mapstructure:"pydicom_api_key"` // API Key for PYDICOM uploader service
|
||||||
} `mapstructure:"auth"`
|
} `mapstructure:"auth"`
|
||||||
|
|
||||||
Shortlink struct {
|
Shortlink struct {
|
||||||
@@ -40,11 +41,14 @@ type Config struct {
|
|||||||
} `mapstructure:"shortlink"`
|
} `mapstructure:"shortlink"`
|
||||||
|
|
||||||
Database struct {
|
Database struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
User string `mapstructure:"user"`
|
User string `mapstructure:"user"`
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
Name string `mapstructure:"name"`
|
Name string `mapstructure:"name"`
|
||||||
|
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||||
|
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||||
|
ConnMaxLifetimeMins int `mapstructure:"conn_max_lifetime_mins"`
|
||||||
} `mapstructure:"database"`
|
} `mapstructure:"database"`
|
||||||
|
|
||||||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||||
|
|||||||
@@ -17,19 +17,23 @@ auth:
|
|||||||
jwt_secret: "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" # Change this in production!
|
jwt_secret: "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" # Change this in production!
|
||||||
access_token_expiry: 1440 # minutes (24 hours)
|
access_token_expiry: 1440 # minutes (24 hours)
|
||||||
refresh_token_expiry: 168 # hours (7 days)
|
refresh_token_expiry: 168 # hours (7 days)
|
||||||
enable_database_auth: false # Set to true when ready to use database
|
enable_database_auth: true # Changed to true to use database
|
||||||
|
pydicom_api_key: "2f0ff447b2c3aeef2004e83a750ded97e29ba8c0ccc70053d5e26f5d715e42ff"
|
||||||
|
|
||||||
shortlink:
|
shortlink:
|
||||||
base_url: "http://localhost:3333" # The base URL for generated OHIF Auth shortlinks
|
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: 720 # Default expiry time for shortlinks (30 days = 30 * 24 = 720 hours)
|
||||||
max_attempts: 5 # Maximum number of failed login attempts
|
max_attempts: 5 # Maximum number of failed login attempts
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: "localhost"
|
host: "localhost"
|
||||||
port: 3306
|
port: 3306
|
||||||
user: "dbuser"
|
user: "root"
|
||||||
password: "dbpassword" # Consider using environment variables for sensitive data
|
password: "alfandi102938" # Change this to your MariaDB password
|
||||||
name: "ohif_proxy"
|
name: "ohif_proxy"
|
||||||
|
max_open_conns: 10
|
||||||
|
max_idle_conns: 5
|
||||||
|
conn_max_lifetime_mins: 60
|
||||||
|
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "*" # For development; restrict this in production
|
- "*" # For development; restrict this in production
|
||||||
@@ -128,7 +128,7 @@ func (h *DicomHandler) ForwardRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
queryParams.Del("StudyInstanceUID")
|
queryParams.Del("StudyInstanceUID")
|
||||||
|
|
||||||
// For DICOMweb, we can use comma-separated UIDs
|
// For DICOMweb, we can use comma-separated UIDs
|
||||||
queryParams.Set("StudyInstanceUID", strings.Join(claims.StudyIUIDs, ","))
|
queryParams.Set("StudyInstanceUID", claims.StudyIUIDs[0])
|
||||||
|
|
||||||
h.logger.Debug("Filtering by studies",
|
h.logger.Debug("Filtering by studies",
|
||||||
zap.Strings("studies", claims.StudyIUIDs))
|
zap.Strings("studies", claims.StudyIUIDs))
|
||||||
|
|||||||
158
internal/api/handlers/pydicom.go
Normal file
158
internal/api/handlers/pydicom.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
68
internal/api/handlers/register.go
Normal file
68
internal/api/handlers/register.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterHandler handles user registration requests
|
||||||
|
type RegisterHandler struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
registerService *service.RegisterService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegisterHandler creates a new registration handler
|
||||||
|
func NewRegisterHandler(logger *zap.Logger, registerService *service.RegisterService) *RegisterHandler {
|
||||||
|
return &RegisterHandler{
|
||||||
|
logger: logger,
|
||||||
|
registerService: registerService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handles the creation of new users, patients, and doctors
|
||||||
|
func (h *RegisterHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse registration request
|
||||||
|
var req service.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.logger.Error("Failed to parse registration request", zap.Error(err))
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if req.Email == "" || req.Password == "" || req.Name == "" || req.Role == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform registration
|
||||||
|
user, err := h.registerService.Register(&req)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case service.ErrEmailExists:
|
||||||
|
http.Error(w, "Email already exists", http.StatusConflict)
|
||||||
|
case service.ErrInvalidRole:
|
||||||
|
http.Error(w, "Invalid user role", http.StatusBadRequest)
|
||||||
|
case service.ErrInvalidPatient:
|
||||||
|
http.Error(w, "Invalid patient data", http.StatusBadRequest)
|
||||||
|
case service.ErrInvalidDoctor:
|
||||||
|
http.Error(w, "Invalid doctor data", http.StatusBadRequest)
|
||||||
|
default:
|
||||||
|
h.logger.Error("Registration failed", zap.Error(err))
|
||||||
|
http.Error(w, "Registration failed", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return created user
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|
||||||
|
// Don't include password in response
|
||||||
|
user.Password = ""
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
@@ -127,3 +127,38 @@ func (h *ShortLinkHandler) ShortLinkAuth(w http.ResponseWriter, r *http.Request)
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(response)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ var WhitelistedEndpoints = []*regexp.Regexp{
|
|||||||
func Auth(authService *service.AuthService, logger *zap.Logger) func(http.Handler) http.Handler {
|
func Auth(authService *service.AuthService, logger *zap.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if this is the /dicomWeb/studies POST request which should bypass auth
|
||||||
|
if r.URL.Path == "/dicomWeb/studies" && r.Method == http.MethodPost {
|
||||||
|
logger.Info("Bypassing authentication for DICOM upload endpoint",
|
||||||
|
zap.String("path", r.URL.Path),
|
||||||
|
zap.String("method", r.Method))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get authorization header
|
// Get authorization header
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
@@ -71,7 +80,7 @@ func Auth(authService *service.AuthService, logger *zap.Logger) func(http.Handle
|
|||||||
// Add user info to request context
|
// Add user info to request context
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||||
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
|
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
|
||||||
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
|
ctx = context.WithValue(ctx, UserEmailKey, claims.Email) // TODO: Apakah kita perlu param email untuk generate access token?
|
||||||
|
|
||||||
// Store the claims with the defined context key
|
// Store the claims with the defined context key
|
||||||
ctx = context.WithValue(ctx, ClaimsKey, claims)
|
ctx = context.WithValue(ctx, ClaimsKey, claims)
|
||||||
@@ -136,6 +145,15 @@ func RoleRequired(roles ...string) func(http.Handler) http.Handler {
|
|||||||
func PatientViewRestriction(logger *zap.Logger) func(http.Handler) http.Handler {
|
func PatientViewRestriction(logger *zap.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if this is the /dicomWeb/studies POST request which should bypass restrictions
|
||||||
|
if r.URL.Path == "/dicomWeb/studies" && r.Method == http.MethodPost {
|
||||||
|
logger.Info("Bypassing patient view restriction for DICOM upload endpoint",
|
||||||
|
zap.String("path", r.URL.Path),
|
||||||
|
zap.String("method", r.Method))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get claims from context using the defined key
|
// Get claims from context using the defined key
|
||||||
claimsValue := r.Context().Value(ClaimsKey)
|
claimsValue := r.Context().Value(ClaimsKey)
|
||||||
if claimsValue == nil {
|
if claimsValue == nil {
|
||||||
|
|||||||
32
internal/api/middleware/pydicom.go
Normal file
32
internal/api/middleware/pydicom.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
8
internal/api/models/doctor.go
Normal file
8
internal/api/models/doctor.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// DoctorDetails contains doctor-specific data
|
||||||
|
type DoctorDetails struct {
|
||||||
|
DoctorID string `json:"doctor_id"`
|
||||||
|
DoctorName string `json:"doctor_name"`
|
||||||
|
Type string `json:"type"` // "ref_doctor" or "expertise_doctor"
|
||||||
|
}
|
||||||
19
internal/api/models/patient.go
Normal file
19
internal/api/models/patient.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// PatientDetails contains patient-specific data
|
||||||
|
type PatientDetails struct {
|
||||||
|
PatientID string `json:"patient_id"`
|
||||||
|
PatientName string `json:"patient_name"`
|
||||||
|
DateOfBirth string `json:"date_of_birth"` // YYYY-MM-DD format
|
||||||
|
StudyInstanceUIDs []string `json:"study_instance_uids,omitempty"`
|
||||||
|
AccessionNumbers []string `json:"accession_numbers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Study represents a DICOM study associated with a patient
|
||||||
|
type Study struct {
|
||||||
|
StudyInstanceUID string `json:"study_instance_uid"`
|
||||||
|
AccessionNumber string `json:"accession_number,omitempty"`
|
||||||
|
StudyDate string `json:"study_date,omitempty"`
|
||||||
|
StudyDescription string `json:"study_description,omitempty"`
|
||||||
|
Modalities string `json:"modalities,omitempty"`
|
||||||
|
}
|
||||||
54
internal/api/models/shortlink.go
Normal file
54
internal/api/models/shortlink.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ShortLink represents a short URL token for patient access
|
||||||
|
type ShortLink struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Shortcode string `db:"shortcode" json:"shortcode"` // 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"`
|
||||||
|
URI string `json:"uri"` // The URI path and query without the base URL
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
IsExisting bool `json:"is_existing"` // Indicates if this is an existing link that was reused
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
@@ -21,21 +21,6 @@ type RefreshToken struct {
|
|||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatientDetails contains patient-specific data
|
|
||||||
type PatientDetails struct {
|
|
||||||
PatientID string `json:"patient_id"`
|
|
||||||
PatientName string `json:"patient_name"`
|
|
||||||
StudyInstanceUIDs []string `json:"study_instance_uids,omitempty"`
|
|
||||||
AccessionNumbers []string `json:"accession_numbers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DoctorDetails contains doctor-specific data
|
|
||||||
type DoctorDetails struct {
|
|
||||||
DoctorID string `json:"doctor_id"`
|
|
||||||
DoctorName string `json:"doctor_name"`
|
|
||||||
Type string `json:"type"` // "ref_doctor" or "expertise_doctor"
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRequest represents the login form data
|
// LoginRequest represents the login form data
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -59,54 +44,3 @@ type RefreshRequest struct {
|
|||||||
type RefreshResponse struct {
|
type RefreshResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
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"`
|
|
||||||
}
|
|
||||||
|
|||||||
59
internal/api/repository/doctor.go
Normal file
59
internal/api/repository/doctor.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBDoctor represents a doctor from the database
|
||||||
|
type DBDoctor struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Doctor_UsersID string `db:"Doctor_UsersID"`
|
||||||
|
DoctorID string `db:"DoctorID"`
|
||||||
|
DoctorName string `db:"DoctorName"`
|
||||||
|
DoctorCreatedAt time.Time `db:"DoctorCreatedAt"`
|
||||||
|
DoctorLastUpdatedAt time.Time `db:"DoctorLastUpdatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoctorRepository handles database operations related to doctors
|
||||||
|
type DoctorRepository struct {
|
||||||
|
*Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDoctorRepository creates a new doctor repository
|
||||||
|
func NewDoctorRepository() *DoctorRepository {
|
||||||
|
return &DoctorRepository{
|
||||||
|
Repository: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDoctorDetailsByUserID retrieves doctor details for a user
|
||||||
|
func (r *DoctorRepository) GetDoctorDetailsByUserID(userID string) (*DBDoctor, error) {
|
||||||
|
var dbDoctor DBDoctor
|
||||||
|
|
||||||
|
query := `SELECT * FROM doctor WHERE Doctor_UsersID = ?`
|
||||||
|
err := database.DB.Get(&dbDoctor, query, userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database error getting doctor details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dbDoctor, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDoctorTx creates a new doctor record within a transaction
|
||||||
|
func (r *DoctorRepository) CreateDoctorTx(tx *sqlx.Tx, doctorDetails *models.DoctorDetails, userID string) error {
|
||||||
|
query := `INSERT INTO doctor (Doctor_UsersID, DoctorID, DoctorName, DoctorCreatedAt, DoctorLastUpdatedAt)
|
||||||
|
VALUES (?, ?, ?, NOW(), NOW())`
|
||||||
|
|
||||||
|
_, err := tx.Exec(query, userID, doctorDetails.DoctorID, doctorDetails.DoctorName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error creating doctor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
82
internal/api/repository/patient.go
Normal file
82
internal/api/repository/patient.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBPatient represents a patient from the database
|
||||||
|
type DBPatient struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Patient_UsersID string `db:"Patient_UsersID"`
|
||||||
|
PatientMedrec string `db:"PatientMedrec"`
|
||||||
|
PatientName string `db:"PatientName"`
|
||||||
|
PatientDoB time.Time `db:"PatientDoB"`
|
||||||
|
PatientCreatedAt time.Time `db:"PatientCreatedAt"`
|
||||||
|
PatientUpdatedAt time.Time `db:"PatientUpdatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatientRepository handles database operations related to patients
|
||||||
|
type PatientRepository struct {
|
||||||
|
*Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPatientRepository creates a new patient repository
|
||||||
|
func NewPatientRepository() *PatientRepository {
|
||||||
|
return &PatientRepository{
|
||||||
|
Repository: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPatientDetailsByUserID retrieves patient details for a user
|
||||||
|
func (r *PatientRepository) GetPatientDetailsByUserID(userID string) (*models.PatientDetails, error) {
|
||||||
|
var dbPatient DBPatient
|
||||||
|
|
||||||
|
query := `SELECT * FROM patient WHERE Patient_UsersID = ?`
|
||||||
|
err := database.DB.Get(&dbPatient, query, userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting patient details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create StudyRepository to get patient studies
|
||||||
|
studyRepo := NewStudyRepository()
|
||||||
|
studyUIDs, accessionNumbers, err := studyRepo.GetPatientStudies(dbPatient.PatientMedrec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.PatientDetails{
|
||||||
|
PatientID: dbPatient.PatientMedrec,
|
||||||
|
PatientName: dbPatient.PatientName,
|
||||||
|
StudyInstanceUIDs: studyUIDs,
|
||||||
|
AccessionNumbers: accessionNumbers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePatientTx creates a new patient record within a transaction
|
||||||
|
func (r *PatientRepository) CreatePatientTx(tx *sqlx.Tx, patientRecord *models.PatientDetails, userID string) error {
|
||||||
|
// Parse DOB to time.Time. 2006-1-02 = reference format YYYY-MM-DD in Go, not a default value
|
||||||
|
dob, err := time.Parse("2006-01-02", patientRecord.DateOfBirth)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid date of birth format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO patient (Patient_UsersID, PatientMedrec, PatientName, PatientDoB, PatientCreatedAt, PatientUpdatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, NOW(), NOW())`
|
||||||
|
|
||||||
|
_, err = tx.Exec(query, userID, patientRecord.PatientID, patientRecord.PatientName, dob)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error creating patient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
23
internal/api/repository/repository.go
Normal file
23
internal/api/repository/repository.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository provides a base interface to the database
|
||||||
|
type Repository struct {
|
||||||
|
db *sqlx.DB // Changed from *sql.DB to *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepository creates a new database repository
|
||||||
|
func NewRepository() *Repository {
|
||||||
|
return &Repository{
|
||||||
|
db: database.DB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection (no-op as DB is managed by database package)
|
||||||
|
func (r *Repository) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
218
internal/api/repository/shortcode.go
Normal file
218
internal/api/repository/shortcode.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
184
internal/api/repository/shortlink.go
Normal file
184
internal/api/repository/shortlink.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 sql.NullInt64 `db:"ShortlinkCreate_UserID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortLinkRepository handles database operations related to shortlinks
|
||||||
|
type ShortLinkRepository struct {
|
||||||
|
*Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShortLinkRepository creates a new shortlink repository
|
||||||
|
func NewShortLinkRepository() *ShortLinkRepository {
|
||||||
|
return &ShortLinkRepository{
|
||||||
|
Repository: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
Shortcode: s.ShortlinkCode,
|
||||||
|
PatientID: s.Shortlink_PatientID,
|
||||||
|
StudyUID: s.Shortlink_Study_IUID,
|
||||||
|
HashedDOB: s.ShortlinkHashDoB,
|
||||||
|
ExpiresAt: s.ShortlinkExpiredAt.Format(time.RFC3339),
|
||||||
|
IsRevoked: s.ShortlinkIsRevoked,
|
||||||
|
RemainingTries: s.ShortlinkRemainingTries,
|
||||||
|
CreatedAt: s.ShortlinkCreatedAt.Format(time.RFC3339),
|
||||||
|
CreatedByID: createdByID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShortLinkByToken retrieves a shortlink by token
|
||||||
|
func (r *ShortLinkRepository) GetShortLinkByToken(token string) (*models.ShortLink, error) {
|
||||||
|
var dbShortLink DBShortLink
|
||||||
|
|
||||||
|
query := `SELECT * FROM shortlink WHERE ShortlinkCode = ?`
|
||||||
|
err := database.DB.Get(&dbShortLink, query, token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting shortlink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbShortLink.ToShortLink(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateShortLinkTx stores a new shortlink in the database within a transaction
|
||||||
|
func (r *ShortLinkRepository) CreateShortLinkTx(tx *sqlx.Tx, shortLink *models.ShortLink) error {
|
||||||
|
query := `INSERT INTO shortlink (
|
||||||
|
ShortlinkCode,
|
||||||
|
Shortlink_PatientID,
|
||||||
|
Shortlink_Study_IUID,
|
||||||
|
ShortlinkHashDoB,
|
||||||
|
ShortlinkExpiredAt,
|
||||||
|
ShortlinkIsRevoked,
|
||||||
|
ShortlinkRemainingTries,
|
||||||
|
ShortlinkCreatedAt,
|
||||||
|
ShortlinkCreate_UserID)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)`
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid expiration date: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
query,
|
||||||
|
shortLink.Shortcode,
|
||||||
|
shortLink.PatientID,
|
||||||
|
shortLink.StudyUID,
|
||||||
|
shortLink.HashedDOB,
|
||||||
|
expiresAt,
|
||||||
|
shortLink.IsRevoked,
|
||||||
|
shortLink.RemainingTries,
|
||||||
|
createdByID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error creating shortlink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateShortLinkTx updates an existing shortlink in the database within a transaction
|
||||||
|
func (r *ShortLinkRepository) UpdateShortLinkTx(tx *sqlx.Tx, shortLink *models.ShortLink) error {
|
||||||
|
query := `UPDATE shortlink SET
|
||||||
|
ShortlinkIsRevoked = ?,
|
||||||
|
ShortlinkRemainingTries = ?,
|
||||||
|
ShortlinkExpiredAt = ?
|
||||||
|
WHERE ShortlinkCode = ?`
|
||||||
|
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid expiration date: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
query,
|
||||||
|
shortLink.IsRevoked,
|
||||||
|
shortLink.RemainingTries,
|
||||||
|
expiresAt,
|
||||||
|
shortLink.Shortcode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error updating shortlink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveShortLinkByPatientAndStudy retrieves an active (unexpired, not revoked) shortlink
|
||||||
|
// for the given patient ID and study UID
|
||||||
|
func (r *ShortLinkRepository) GetActiveShortLinkByPatientAndStudy(patientID string, studyUID string) (*models.ShortLink, error) {
|
||||||
|
var dbShortLink DBShortLink
|
||||||
|
|
||||||
|
query := `SELECT * FROM shortlink
|
||||||
|
WHERE Shortlink_PatientID = ?
|
||||||
|
AND Shortlink_Study_IUID = ?
|
||||||
|
AND ShortlinkExpiredAt > NOW()
|
||||||
|
AND ShortlinkIsRevoked = FALSE
|
||||||
|
ORDER BY ShortlinkExpiredAt DESC
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
err := database.DB.Get(&dbShortLink, query, patientID, studyUID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting active shortlink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbShortLink.ToShortLink(), nil
|
||||||
|
}
|
||||||
100
internal/api/repository/study.go
Normal file
100
internal/api/repository/study.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBStudy represents a study from the database
|
||||||
|
type DBStudy struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Study_PatientID string `db:"Study_PatientID"`
|
||||||
|
StudyIUID string `db:"StudyIUID"`
|
||||||
|
StudyAccessionNumber string `db:"StudyAccessionNumber"`
|
||||||
|
StudyDate time.Time `db:"StudyDate"`
|
||||||
|
StudyCreatedAt time.Time `db:"StudyCreatedAt"`
|
||||||
|
StudyUpdatedAt time.Time `db:"StudyUpdatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StudyRepository handles database operations related to studies
|
||||||
|
type StudyRepository struct {
|
||||||
|
*Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStudyRepository creates a new study repository
|
||||||
|
func NewStudyRepository() *StudyRepository {
|
||||||
|
return &StudyRepository{
|
||||||
|
Repository: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPatientStudies retrieves all studies for a patient
|
||||||
|
func (r *StudyRepository) GetPatientStudies(patientID string) ([]string, []string, error) {
|
||||||
|
var studies []DBStudy
|
||||||
|
|
||||||
|
query := `SELECT * FROM study WHERE Study_PatientID = ?`
|
||||||
|
err := database.DB.Select(&studies, query, patientID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("database error getting patient studies: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var studyUIDs []string
|
||||||
|
var accessionNumbers []string
|
||||||
|
|
||||||
|
for _, study := range studies {
|
||||||
|
studyUIDs = append(studyUIDs, study.StudyIUID)
|
||||||
|
if study.StudyAccessionNumber != "" {
|
||||||
|
accessionNumbers = append(accessionNumbers, study.StudyAccessionNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return studyUIDs, accessionNumbers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStudyByUID retrieves a study by its UID
|
||||||
|
func (r *StudyRepository) GetStudyByUID(studyUID string) (*DBStudy, error) {
|
||||||
|
var study DBStudy
|
||||||
|
|
||||||
|
query := `SELECT * FROM study WHERE StudyIUID = ?`
|
||||||
|
err := database.DB.Get(&study, query, studyUID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database error getting study: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &study, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateStudyTx creates a new study record for a patient within a transaction
|
||||||
|
func (r *StudyRepository) CreateStudyTx(tx *sqlx.Tx, patientID string, study models.Study) error {
|
||||||
|
// Parse study date if provided
|
||||||
|
var studyDate *time.Time
|
||||||
|
if study.StudyDate != "" {
|
||||||
|
parsedTime, err := time.Parse("2006-01-02", study.StudyDate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid study date format: %w", err)
|
||||||
|
}
|
||||||
|
studyDate = &parsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO study
|
||||||
|
(Study_PatientID, StudyIUID, StudyAccessionNumber, StudyDate, StudyCreatedAt, StudyUpdatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, NOW(), NOW())`
|
||||||
|
|
||||||
|
_, err := tx.Exec(query,
|
||||||
|
patientID,
|
||||||
|
study.StudyInstanceUID,
|
||||||
|
study.AccessionNumber,
|
||||||
|
studyDate)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error creating study: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
169
internal/api/repository/user.go
Normal file
169
internal/api/repository/user.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBUser represents a user from the database
|
||||||
|
type DBUser struct {
|
||||||
|
UserID int `db:"UserID"`
|
||||||
|
UserEmail string `db:"UserEmail"`
|
||||||
|
UserPassword string `db:"UserPassword"`
|
||||||
|
UserRole string `db:"UserRole"`
|
||||||
|
UserName string `db:"UserName"`
|
||||||
|
UserCreatedAt time.Time `db:"UserCreatedAt"`
|
||||||
|
UserUpdatedAt time.Time `db:"UserUpdatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBRefreshToken represents a refresh token from the database
|
||||||
|
type DBRefreshToken struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Token string `db:"token"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
IsRevoked bool `db:"is_revoked"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRepository handles database operations related to users
|
||||||
|
type UserRepository struct {
|
||||||
|
*Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserRepository creates a new user repository
|
||||||
|
func NewUserRepository() *UserRepository {
|
||||||
|
return &UserRepository{
|
||||||
|
Repository: NewRepository(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToUser converts a DBUser to a User model
|
||||||
|
func (u *DBUser) ToUser() *models.User {
|
||||||
|
return &models.User{
|
||||||
|
ID: fmt.Sprintf("%d", u.UserID),
|
||||||
|
Email: u.UserEmail,
|
||||||
|
Password: u.UserPassword,
|
||||||
|
Role: u.UserRole,
|
||||||
|
Name: u.UserName,
|
||||||
|
CreatedAt: u.UserCreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: u.UserUpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves a user by email
|
||||||
|
func (r *UserRepository) GetUserByEmail(email string) (*models.User, error) {
|
||||||
|
var dbUser DBUser
|
||||||
|
|
||||||
|
query := `SELECT * FROM user WHERE UserEmail = ?`
|
||||||
|
err := database.DB.Get(&dbUser, query, email)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting user by email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbUser.ToUser(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by ID
|
||||||
|
func (r *UserRepository) GetUserByID(id string) (*models.User, error) {
|
||||||
|
var dbUser DBUser
|
||||||
|
|
||||||
|
query := `SELECT * FROM user WHERE UserID = ?`
|
||||||
|
err := database.DB.Get(&dbUser, query, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting user by ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbUser.ToUser(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreRefreshToken saves a refresh token to the database
|
||||||
|
func (r *UserRepository) StoreRefreshToken(userID string, token string, expiresAt time.Time) error {
|
||||||
|
query := `INSERT INTO refresh_tokens (token, user_id, expires_at, is_revoked, created_at)
|
||||||
|
VALUES (?, ?, ?, false, NOW())`
|
||||||
|
|
||||||
|
_, err := database.DB.Exec(query, token, userID, expiresAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error storing refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRefreshToken retrieves a refresh token from the database
|
||||||
|
func (r *UserRepository) GetRefreshToken(token string) (*models.RefreshToken, error) {
|
||||||
|
var dbToken DBRefreshToken
|
||||||
|
|
||||||
|
query := `SELECT * FROM refresh_tokens WHERE token = ?`
|
||||||
|
err := database.DB.Get(&dbToken, query, token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database error getting refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.RefreshToken{
|
||||||
|
ID: fmt.Sprintf("%d", dbToken.ID),
|
||||||
|
UserID: dbToken.UserID,
|
||||||
|
Token: dbToken.Token,
|
||||||
|
ExpiresAt: dbToken.ExpiresAt.Format(time.RFC3339),
|
||||||
|
IsRevoked: dbToken.IsRevoked,
|
||||||
|
CreatedAt: dbToken.CreatedAt.Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRefreshToken marks a refresh token as revoked
|
||||||
|
func (r *UserRepository) RevokeRefreshToken(token string) error {
|
||||||
|
query := `UPDATE refresh_tokens SET is_revoked = true WHERE token = ?`
|
||||||
|
_, err := database.DB.Exec(query, token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error revoking refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserTx creates a new user within a transaction
|
||||||
|
func (r *UserRepository) CreateUserTx(tx *sqlx.Tx, user *models.User) error {
|
||||||
|
// Hash the password before storing
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO user (UserEmail, UserPassword, UserRole, UserName, UserCreatedAt, UserUpdatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, NOW(), NOW())`
|
||||||
|
|
||||||
|
result, err := tx.Exec(query, user.Email, string(hashedPassword), user.Role, user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database error creating user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the last inserted ID
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last insert ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user ID
|
||||||
|
user.ID = fmt.Sprintf("%d", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -53,7 +53,8 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
// Initialize JWT auth service
|
// Initialize JWT auth service
|
||||||
jwtSecret := cfg.Auth.JWTSecret
|
jwtSecret := cfg.Auth.JWTSecret
|
||||||
if jwtSecret == "" {
|
if jwtSecret == "" {
|
||||||
logger.Warn("JWT secret not provided in config")
|
logger.Warn("JWT secret not provided in config, using default value. This is insecure for production!")
|
||||||
|
jwtSecret = "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY="
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert config values to time.Duration
|
// Convert config values to time.Duration
|
||||||
@@ -62,6 +63,8 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
|
|
||||||
// Create JWT manager with config values
|
// Create JWT manager with config values
|
||||||
jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry)
|
jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry)
|
||||||
|
|
||||||
|
// Initialize services with domain-specific repositories
|
||||||
authService := service.NewAuthService(jwtManager)
|
authService := service.NewAuthService(jwtManager)
|
||||||
|
|
||||||
// Initialize shortlink service with config values
|
// Initialize shortlink service with config values
|
||||||
@@ -85,6 +88,11 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
r.Post("/refresh", authHandler.RefreshToken)
|
r.Post("/refresh", authHandler.RefreshToken)
|
||||||
r.Post("/logout", authHandler.Logout)
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
|
||||||
|
// Registration endpoint
|
||||||
|
registerService := service.NewRegisterService(logger)
|
||||||
|
registerHandler := handlers.NewRegisterHandler(logger, registerService)
|
||||||
|
r.Post("/register", registerHandler.Register)
|
||||||
|
|
||||||
// ShortLink authentication - no auth required
|
// ShortLink authentication - no auth required
|
||||||
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
||||||
r.Post("/shortlink", shortLinkHandler.ShortLinkAuth)
|
r.Post("/shortlink", shortLinkHandler.ShortLinkAuth)
|
||||||
@@ -100,6 +108,9 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
shortLinkHandler := handlers.NewShortLinkHandler(logger, shortLinkService)
|
||||||
r.Post("/generate-link", shortLinkHandler.GenerateShortLink)
|
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
|
// DICOM Web routes
|
||||||
r.Route("/dicomWeb", func(r chi.Router) {
|
r.Route("/dicomWeb", func(r chi.Router) {
|
||||||
// Add audit logging middleware to DICOM routes
|
// Add audit logging middleware to DICOM routes
|
||||||
@@ -123,6 +134,9 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler {
|
|||||||
|
|
||||||
// Query routes - accessible by all roles
|
// Query routes - accessible by all roles
|
||||||
r.Get("/", dicomHandler.ForwardRequest) // Study list with filters
|
r.Get("/", dicomHandler.ForwardRequest) // Study list with filters
|
||||||
|
|
||||||
|
// DICOM upload endpoint - for pydicom-uploader service
|
||||||
|
r.Post("/", dicomHandler.ForwardRequest) // Upload studies
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expertise doctors have full access to all DICOM endpoints
|
// Expertise doctors have full access to all DICOM endpoints
|
||||||
@@ -130,5 +144,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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
"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/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,23 +20,34 @@ var (
|
|||||||
|
|
||||||
// AuthService handles authentication operations
|
// AuthService handles authentication operations
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
// When you implement database connection, add a db client here
|
userRepo *repository.UserRepository
|
||||||
// db *sqlx.DB
|
patientRepo *repository.PatientRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService creates a new authentication service
|
// NewAuthService creates a new authentication service
|
||||||
func NewAuthService(jwtManager *auth.JWTManager) *AuthService {
|
func NewAuthService(jwtManager *auth.JWTManager) *AuthService {
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
|
userRepo: repository.NewUserRepository(),
|
||||||
|
patientRepo: repository.NewPatientRepository(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login authenticates a user and generates tokens
|
// Login authenticates a user and generates tokens
|
||||||
func (s *AuthService) Login(email, password string) (*models.LoginResponse, error) {
|
func (s *AuthService) Login(email, password string) (*models.LoginResponse, error) {
|
||||||
// Find user in mock data
|
// Find user in database
|
||||||
user := models.FindUserByCredentials(email, password)
|
user, err := s.userRepo.GetUserByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error finding user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if err := CheckPassword(password, user.Password); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +58,11 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
switch user.Role {
|
switch user.Role {
|
||||||
case "patient":
|
case "patient":
|
||||||
// Get patient data
|
// Get patient data
|
||||||
patientData := models.FindPatientDataByUserID(user.ID)
|
patientData, err := s.patientRepo.GetPatientDetailsByUserID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting patient details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if patientData == nil {
|
if patientData == nil {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
@@ -53,15 +70,15 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
// Set patient-specific claims
|
// Set patient-specific claims
|
||||||
additionalClaims["patient_id"] = patientData.PatientID
|
additionalClaims["patient_id"] = patientData.PatientID
|
||||||
additionalClaims["patient_name"] = patientData.PatientName
|
additionalClaims["patient_name"] = patientData.PatientName
|
||||||
additionalClaims["study_iuids"] = patientData.StudyIUIDs
|
additionalClaims["study_iuids"] = patientData.StudyInstanceUIDs
|
||||||
additionalClaims["accession_numbers"] = patientData.AccessionNumbers
|
additionalClaims["accession_numbers"] = patientData.AccessionNumbers
|
||||||
|
|
||||||
// For redirectURL and home_url, use first study for simplicity
|
// For redirectURL and home_url, use first study for simplicity
|
||||||
if len(patientData.StudyIUIDs) > 0 {
|
if len(patientData.StudyInstanceUIDs) > 0 {
|
||||||
additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0])
|
additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyInstanceUIDs[0])
|
||||||
redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0])
|
redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyInstanceUIDs[0])
|
||||||
} else {
|
} else {
|
||||||
// Fallback for empty studies array (shouldn't happen)
|
// Fallback for empty studies array
|
||||||
additionalClaims["home_url"] = "/"
|
additionalClaims["home_url"] = "/"
|
||||||
redirectURL = "/"
|
redirectURL = "/"
|
||||||
}
|
}
|
||||||
@@ -79,7 +96,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
|
|
||||||
redirectURL = "/"
|
redirectURL = "/"
|
||||||
|
|
||||||
case "expertise_doctor":
|
case "expertise_doctor", "admin":
|
||||||
// Expertise doctors have full access
|
// Expertise doctors have full access
|
||||||
additionalClaims["home_url"] = "/"
|
additionalClaims["home_url"] = "/"
|
||||||
additionalClaims["study_list"] = "enabled"
|
additionalClaims["study_list"] = "enabled"
|
||||||
@@ -87,6 +104,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
redirectURL = "/"
|
redirectURL = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Apakah kita perlu param email untuk generate access token?
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role, user.Name, additionalClaims)
|
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role, user.Name, additionalClaims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,6 +116,12 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store refresh token in database
|
||||||
|
expiresAt := time.Now().Add(s.jwtManager.GetRefreshExpiry())
|
||||||
|
if err := s.userRepo.StoreRefreshToken(user.ID, refreshToken, expiresAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &models.LoginResponse{
|
return &models.LoginResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
@@ -108,6 +132,27 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro
|
|||||||
|
|
||||||
// RefreshToken generates a new access token using a refresh token
|
// RefreshToken generates a new access token using a refresh token
|
||||||
func (s *AuthService) RefreshToken(refreshToken string) (string, error) {
|
func (s *AuthService) RefreshToken(refreshToken string) (string, error) {
|
||||||
|
// Check if token exists and is not revoked
|
||||||
|
dbToken, err := s.userRepo.GetRefreshToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbToken == nil || dbToken.IsRevoked {
|
||||||
|
return "", errors.New("invalid or revoked refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expiry time
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, dbToken.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid token expiry format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
return "", auth.ErrExpiredToken
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the refresh token
|
// Validate the refresh token
|
||||||
claims, err := s.jwtManager.ValidateToken(refreshToken)
|
claims, err := s.jwtManager.ValidateToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,7 +193,13 @@ func (s *AuthService) RefreshToken(refreshToken string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new access token with the same claims
|
// Generate a new access token with the same claims
|
||||||
accessToken, err := s.jwtManager.GenerateAccessToken(claims.UserID, claims.Email, claims.Role, claims.UserName, additionalClaims)
|
accessToken, err := s.jwtManager.GenerateAccessToken(
|
||||||
|
claims.UserID,
|
||||||
|
claims.Email,
|
||||||
|
claims.Role,
|
||||||
|
claims.UserName,
|
||||||
|
additionalClaims,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -161,6 +212,11 @@ func (s *AuthService) ValidateToken(token string) (*auth.CustomClaims, error) {
|
|||||||
return s.jwtManager.ValidateToken(token)
|
return s.jwtManager.ValidateToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logout revokes a refresh token
|
||||||
|
func (s *AuthService) Logout(refreshToken string) error {
|
||||||
|
return s.userRepo.RevokeRefreshToken(refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
// HashPassword hashes a password using bcrypt
|
// HashPassword hashes a password using bcrypt
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -174,17 +230,3 @@ func HashPassword(password string) (string, error) {
|
|||||||
func CheckPassword(password, hash string) error {
|
func CheckPassword(password, hash string) error {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Below functions would be implemented when connecting to a real database
|
|
||||||
|
|
||||||
// storeRefreshToken stores a refresh token in the database
|
|
||||||
func (s *AuthService) storeRefreshToken(userID, token string) error {
|
|
||||||
// TODO: In a real implementation, this would insert a record in the database
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// revokeRefreshToken marks a refresh token as revoked
|
|
||||||
func (s *AuthService) revokeRefreshToken(token string) error {
|
|
||||||
// TODO: In a real implementation, this would update a record in the database
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql" // Hanya menjalankan side-effectsnya saja dahulu
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Repository provides an interface to the database
|
|
||||||
type Repository struct {
|
|
||||||
db *sqlx.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRepository creates a new database repository
|
|
||||||
func NewRepository(dsn string) (*Repository, error) {
|
|
||||||
db, err := sqlx.Connect("mysql", dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set connection pool settings
|
|
||||||
db.SetMaxOpenConns(10)
|
|
||||||
db.SetMaxIdleConns(5)
|
|
||||||
db.SetConnMaxLifetime(time.Hour)
|
|
||||||
|
|
||||||
return &Repository{
|
|
||||||
db: db,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserByEmail retrieves a user by email
|
|
||||||
func (r *Repository) GetUserByEmail(email string) (*models.User, error) {
|
|
||||||
var user models.User
|
|
||||||
err := r.db.Get(&user, "SELECT * FROM users WHERE email = ?", email)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserByID retrieves a user by ID
|
|
||||||
func (r *Repository) GetUserByID(id string) (*models.User, error) {
|
|
||||||
var user models.User
|
|
||||||
err := r.db.Get(&user, "SELECT * FROM users WHERE id = ?", id)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoreRefreshToken saves a refresh token to the database
|
|
||||||
func (r *Repository) StoreRefreshToken(userID, token string, expiresAt time.Time) error {
|
|
||||||
refreshToken := models.RefreshToken{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: userID,
|
|
||||||
Token: token,
|
|
||||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
||||||
IsRevoked: false,
|
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := r.db.NamedExec(
|
|
||||||
`INSERT INTO refresh_tokens (id, user_id, token, expires_at, is_revoked, created_at)
|
|
||||||
VALUES (:id, :user_id, :token, :expires_at, :is_revoked, :created_at)`,
|
|
||||||
refreshToken,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRefreshToken retrieves a refresh token from the database
|
|
||||||
func (r *Repository) GetRefreshToken(token string) (*models.RefreshToken, error) {
|
|
||||||
var refreshToken models.RefreshToken
|
|
||||||
err := r.db.Get(&refreshToken, "SELECT * FROM refresh_tokens WHERE token = ?", token)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &refreshToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RevokeRefreshToken marks a refresh token as revoked
|
|
||||||
func (r *Repository) RevokeRefreshToken(token string) error {
|
|
||||||
_, err := r.db.Exec("UPDATE refresh_tokens SET is_revoked = true WHERE token = ?", token)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPatientDetails retrieves patient details for a user
|
|
||||||
func (r *Repository) GetPatientDetails(userID string) (*models.PatientDetails, error) {
|
|
||||||
var patientDetails models.PatientDetails
|
|
||||||
err := r.db.Get(&patientDetails, "SELECT * FROM patient_details WHERE user_id = ?", userID)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &patientDetails, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDoctorDetails retrieves doctor details for a user
|
|
||||||
func (r *Repository) GetDoctorDetails(userID string) (*models.DoctorDetails, error) {
|
|
||||||
var doctorDetails models.DoctorDetails
|
|
||||||
err := r.db.Get(&doctorDetails, "SELECT * FROM doctor_details WHERE user_id = ?", userID)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &doctorDetails, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the database connection
|
|
||||||
func (r *Repository) Close() error {
|
|
||||||
return r.db.Close()
|
|
||||||
}
|
|
||||||
165
internal/api/service/register_service.go
Normal file
165
internal/api/service/register_service.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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/database"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmailExists = errors.New("email already exists")
|
||||||
|
ErrUserNotCreated = errors.New("failed to create user")
|
||||||
|
ErrInvalidRole = errors.New("invalid user role")
|
||||||
|
ErrInvalidPatient = errors.New("invalid patient data")
|
||||||
|
ErrInvalidDoctor = errors.New("invalid doctor data")
|
||||||
|
ErrTransaction = errors.New("transaction error")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRequest represents a user registration request
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"` // "patient", "ref_doctor", "expertise_doctor", or "admin"
|
||||||
|
Patient *models.PatientDetails `json:"patient,omitempty"`
|
||||||
|
Doctor *models.DoctorDetails `json:"doctor,omitempty"`
|
||||||
|
Studies []models.Study `json:"studies,omitempty"` // Study records for patient
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterService handles user registration
|
||||||
|
type RegisterService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
patientRepo *repository.PatientRepository
|
||||||
|
doctorRepo *repository.DoctorRepository
|
||||||
|
studyRepo *repository.StudyRepository
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegisterService creates a new register service
|
||||||
|
func NewRegisterService(logger *zap.Logger) *RegisterService {
|
||||||
|
if logger == nil {
|
||||||
|
// If no logger is provided, create a no-op logger
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RegisterService{
|
||||||
|
userRepo: repository.NewUserRepository(),
|
||||||
|
patientRepo: repository.NewPatientRepository(),
|
||||||
|
doctorRepo: repository.NewDoctorRepository(),
|
||||||
|
studyRepo: repository.NewStudyRepository(),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new user with their associated role-specific data
|
||||||
|
func (s *RegisterService) Register(req *RegisterRequest) (*models.User, error) {
|
||||||
|
// Validate role
|
||||||
|
if req.Role != "patient" && req.Role != "ref_doctor" && req.Role != "expertise_doctor" && req.Role != "admin" {
|
||||||
|
return nil, ErrInvalidRole
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role-specific data
|
||||||
|
if req.Role == "patient" && (req.Patient == nil || req.Patient.PatientID == "" || req.Patient.DateOfBirth == "") {
|
||||||
|
return nil, ErrInvalidPatient
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.Role == "ref_doctor" || req.Role == "expertise_doctor") && (req.Doctor == nil || req.Doctor.DoctorID == "") {
|
||||||
|
return nil, ErrInvalidDoctor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists - do this outside the transaction
|
||||||
|
existingUser, err := s.userRepo.GetUserByEmail(req.Email)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("error checking existing user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, ErrEmailExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
tx, err := database.DB.Beginx()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to begin transaction", zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("%w: failed to begin transaction", ErrTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the transaction is rolled back if we return an error
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
s.logger.Error("Panic in transaction", zap.Any("recover", r))
|
||||||
|
tx.Rollback()
|
||||||
|
panic(r) // re-throw the panic after cleanup
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
newUser := &models.User{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: req.Password, // Will be hashed in repository
|
||||||
|
Role: req.Role,
|
||||||
|
Name: req.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the transaction for user creation
|
||||||
|
if err := s.userRepo.CreateUserTx(tx, newUser); err != nil {
|
||||||
|
s.logger.Error("Failed to create user", zap.Error(err), zap.String("email", req.Email))
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("error creating user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create role-specific data
|
||||||
|
if req.Role == "patient" {
|
||||||
|
err = s.patientRepo.CreatePatientTx(tx, req.Patient, newUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create patient", zap.Error(err), zap.String("patientID", req.Patient.PatientID))
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("error creating patient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create associated study records if provided
|
||||||
|
if len(req.Studies) > 0 {
|
||||||
|
for _, study := range req.Studies {
|
||||||
|
if study.StudyInstanceUID == "" {
|
||||||
|
continue // Skip studies without UIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.studyRepo.CreateStudyTx(tx, req.Patient.PatientID, study)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create study",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("patientID", req.Patient.PatientID),
|
||||||
|
zap.String("studyUID", study.StudyInstanceUID))
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("error creating study for patient: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if req.Role == "ref_doctor" || req.Role == "expertise_doctor" {
|
||||||
|
err = s.doctorRepo.CreateDoctorTx(tx, req.Doctor, newUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to create doctor", zap.Error(err), zap.String("doctorID", req.Doctor.DoctorID))
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, fmt.Errorf("error creating doctor: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
s.logger.Error("Failed to commit transaction", zap.Error(err))
|
||||||
|
tx.Rollback() // This is actually redundant as the transaction will be rolled back on failure
|
||||||
|
return nil, fmt.Errorf("%w: failed to commit transaction", ErrTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Successfully registered new user",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.String("role", req.Role),
|
||||||
|
zap.String("userID", newUser.ID))
|
||||||
|
|
||||||
|
return newUser, nil
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models"
|
"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/auth"
|
||||||
|
"devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,8 +24,8 @@ const (
|
|||||||
// DefaultMaxTries is the default number of login attempts allowed for a shortlink
|
// DefaultMaxTries is the default number of login attempts allowed for a shortlink
|
||||||
DefaultMaxTries = 5
|
DefaultMaxTries = 5
|
||||||
|
|
||||||
// ShortTokenLength is the length of the generated short token
|
// ShortTokenLength is the length of the generated short token. 5 digit kapital semua
|
||||||
ShortTokenLength = 8
|
ShortTokenLength = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,18 +36,20 @@ var (
|
|||||||
ErrCreationFailed = errors.New("failed to create short link")
|
ErrCreationFailed = errors.New("failed to create short link")
|
||||||
ErrInvalidStudyUID = errors.New("invalid or missing StudyInstanceUID")
|
ErrInvalidStudyUID = errors.New("invalid or missing StudyInstanceUID")
|
||||||
ErrAdminRoleRequired = errors.New("admin role required to generate shortlinks")
|
ErrAdminRoleRequired = errors.New("admin role required to generate shortlinks")
|
||||||
|
ErrNoShortcodes = errors.New("no unused shortcodes available")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ShortLinkService handles operations related to short links
|
// ShortLinkService handles operations related to short links
|
||||||
type ShortLinkService struct {
|
type ShortLinkService struct {
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
shortLinkRepo *repository.ShortLinkRepository
|
||||||
|
shortCodeRepo *repository.ShortCodeRepository
|
||||||
|
patientRepo *repository.PatientRepository
|
||||||
// Configuration settings
|
// Configuration settings
|
||||||
baseURL string
|
baseURL string
|
||||||
defaultExpiryTime time.Duration
|
defaultExpiryTime time.Duration
|
||||||
maxAttempts int
|
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
|
// NewShortLinkService creates a new short link service
|
||||||
@@ -66,10 +70,12 @@ func NewShortLinkService(jwtManager *auth.JWTManager, logger *zap.Logger, baseUR
|
|||||||
return &ShortLinkService{
|
return &ShortLinkService{
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
shortLinkRepo: repository.NewShortLinkRepository(),
|
||||||
|
shortCodeRepo: repository.NewShortCodeRepository(),
|
||||||
|
patientRepo: repository.NewPatientRepository(),
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour,
|
defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour,
|
||||||
maxAttempts: maxAttempts,
|
maxAttempts: maxAttempts,
|
||||||
shortLinks: make(map[string]*models.ShortLink),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +98,33 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques
|
|||||||
return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD")
|
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.Shortcode))
|
||||||
|
|
||||||
|
// Generate the full URL and URI
|
||||||
|
uri := fmt.Sprintf("short-auth?short=%s", existingShortLink.Shortcode)
|
||||||
|
fullURL := fmt.Sprintf("%s/%s", s.baseURL, uri)
|
||||||
|
|
||||||
|
return &models.GenerateShortLinkResponse{
|
||||||
|
ShortToken: existingShortLink.Shortcode,
|
||||||
|
FullURL: fullURL,
|
||||||
|
URI: uri,
|
||||||
|
ExpiresAt: existingShortLink.ExpiresAt,
|
||||||
|
IsExisting: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Set expiration if not provided
|
// Set expiration if not provided
|
||||||
expiresIn := s.defaultExpiryTime
|
expiresIn := s.defaultExpiryTime
|
||||||
if req.ExpiresIn > 0 {
|
if req.ExpiresIn > 0 {
|
||||||
@@ -99,20 +132,38 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques
|
|||||||
}
|
}
|
||||||
expiresAt := time.Now().Add(expiresIn)
|
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
|
// Hash the DOB for secure storage
|
||||||
hashedDOB := hashDOB(dob)
|
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
|
// Create the short link record
|
||||||
shortLink := &models.ShortLink{
|
shortLink := &models.ShortLink{
|
||||||
ID: generateID(),
|
Shortcode: *unusedShortcode,
|
||||||
Token: token,
|
|
||||||
PatientID: req.PatientID,
|
PatientID: req.PatientID,
|
||||||
StudyUID: req.StudyUID,
|
StudyUID: req.StudyUID,
|
||||||
HashedDOB: hashedDOB,
|
HashedDOB: hashedDOB,
|
||||||
@@ -123,16 +174,47 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques
|
|||||||
RemainingTries: s.maxAttempts,
|
RemainingTries: s.maxAttempts,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the short link (in production, this would be in a database)
|
// Store the short link in the database using transaction
|
||||||
s.shortLinks[token] = shortLink
|
err = s.shortLinkRepo.CreateShortLinkTx(tx, 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
|
// Get the ID of the created shortlink
|
||||||
fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, token)
|
var shortlinkID int
|
||||||
|
err = tx.Get(&shortlinkID, "SELECT ShortlinkID FROM shortlink WHERE ShortlinkCode = ?", shortLink.Shortcode)
|
||||||
|
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.Shortcode, 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 and URI
|
||||||
|
uri := fmt.Sprintf("short-auth?short=%s", shortLink.Shortcode)
|
||||||
|
fullURL := fmt.Sprintf("%s/%s", s.baseURL, uri)
|
||||||
|
|
||||||
return &models.GenerateShortLinkResponse{
|
return &models.GenerateShortLinkResponse{
|
||||||
ShortToken: token,
|
ShortToken: shortLink.Shortcode,
|
||||||
FullURL: fullURL,
|
FullURL: fullURL,
|
||||||
|
URI: uri,
|
||||||
ExpiresAt: shortLink.ExpiresAt,
|
ExpiresAt: shortLink.ExpiresAt,
|
||||||
|
IsExisting: false,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +223,14 @@ func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) (
|
|||||||
// Get the token using the helper method that handles both field names
|
// Get the token using the helper method that handles both field names
|
||||||
token := req.GetToken()
|
token := req.GetToken()
|
||||||
|
|
||||||
// Find the short link
|
// Find the short link in the database
|
||||||
shortLink, exists := s.shortLinks[token]
|
shortLink, err := s.shortLinkRepo.GetShortLinkByToken(token)
|
||||||
if !exists {
|
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
|
return nil, ErrShortLinkNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,23 +253,101 @@ func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) (
|
|||||||
// Normalize and hash the provided DOB
|
// Normalize and hash the provided DOB
|
||||||
dob := normalizeDOB(req.DOB)
|
dob := normalizeDOB(req.DOB)
|
||||||
if !isValidDOBFormat(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
|
// Decrement remaining tries on invalid format
|
||||||
shortLink.RemainingTries--
|
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")
|
return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedDOB := hashDOB(dob)
|
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
|
// Verify the DOB
|
||||||
if hashedDOB != shortLink.HashedDOB {
|
if hashedDOB != shortLink.HashedDOB {
|
||||||
// Decrement remaining tries on failed verification
|
// Decrement remaining tries on failed verification
|
||||||
shortLink.RemainingTries--
|
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
|
return nil, ErrInvalidDOB
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOB verified, reset tries count as successful login
|
// DOB verified, reset tries count as successful login
|
||||||
shortLink.RemainingTries = s.maxAttempts
|
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
|
return shortLink, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +393,21 @@ func (s *ShortLinkService) AuthenticateWithShortLink(req *models.ShortLinkAuthRe
|
|||||||
}, nil
|
}, 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
|
// Helper functions
|
||||||
|
|
||||||
// hashDOB creates a secure hash of a date of birth
|
// hashDOB creates a secure hash of a date of birth
|
||||||
|
|||||||
43
internal/database/database.go
Normal file
43
internal/database/database.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB is the database instance
|
||||||
|
var DB *sqlx.DB
|
||||||
|
|
||||||
|
// Initialize creates and configures the database connection
|
||||||
|
func Initialize(dsn string, maxOpenConns, maxIdleConns int, connMaxLifetime time.Duration) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Connect to the database
|
||||||
|
DB, err = sqlx.Connect("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure connection pool
|
||||||
|
DB.SetMaxOpenConns(maxOpenConns)
|
||||||
|
DB.SetMaxIdleConns(maxIdleConns)
|
||||||
|
DB.SetConnMaxLifetime(connMaxLifetime)
|
||||||
|
|
||||||
|
// Check connection
|
||||||
|
if err := DB.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func Close() error {
|
||||||
|
if DB != nil {
|
||||||
|
return DB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
### Local OHIF Proxy Test File
|
### Local OHIF Proxy Test File
|
||||||
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMiIsImVtYWlsIjoicGF0aWVudCIsInJvbGUiOiJwYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImV4cCI6MTc0NjM2NDczMiwiaWF0IjoxNzQ2MzYyOTMyfQ.4IGGV77jnewQVXOCuFWmcx4X7EMMxx341j6DeNKYcFY
|
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJBZG1pbiBVc2VyIiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImhvbWVfdXJsIjoiLyIsInN0dWR5X2xpc3QiOiJlbmFibGVkIiwiZXhwIjoxNzQ3NDcyMTE5LCJpYXQiOjE3NDczODU3MTl9.rLB8q2Wwt2aL813lf-GwuS14dO5WlJPPS3sP5OGJdO0
|
||||||
@baseUrl = http://localhost:5555
|
@baseUrl = http://localhost:5555
|
||||||
|
|
||||||
# @baseUrl = http://devone.aplikasi.web.id:5555
|
# @baseUrl = http://devone.aplikasi.web.id:5555
|
||||||
@@ -10,23 +10,6 @@
|
|||||||
GET {{baseUrl}}/health
|
GET {{baseUrl}}/health
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
### Login Success
|
|
||||||
POST {{baseUrl}}/auth/login
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"email": "patient",
|
|
||||||
"password": "patient"
|
|
||||||
}
|
|
||||||
|
|
||||||
### Refresh TOken
|
|
||||||
POST {{baseUrl}}/auth/refresh
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"refresh"
|
|
||||||
}
|
|
||||||
|
|
||||||
### 2. QIDO-RS: Search for Studies
|
### 2. QIDO-RS: Search for Studies
|
||||||
# Returns all studies (should return a list of DICOM studies if any exist)
|
# Returns all studies (should return a list of DICOM studies if any exist)
|
||||||
GET {{baseUrl}}/dicomWeb/studies
|
GET {{baseUrl}}/dicomWeb/studies
|
||||||
@@ -37,11 +20,13 @@ Authorization: Bearer {{token}}
|
|||||||
# Returns studies matching patient name (replace SMITH with a name in your dataset)
|
# Returns studies matching patient name (replace SMITH with a name in your dataset)
|
||||||
GET {{baseUrl}}/dicomWeb/studies?limit=10&offset=0&fuzzymatching=true&includefield=00081030%2C00080060%2C00080090&00080090=DR.%20HERWINDO%20RIDWAN%2C%20SP.OT
|
GET {{baseUrl}}/dicomWeb/studies?limit=10&offset=0&fuzzymatching=true&includefield=00081030%2C00080060%2C00080090&00080090=DR.%20HERWINDO%20RIDWAN%2C%20SP.OT
|
||||||
Accept: application/dicom+json
|
Accept: application/dicom+json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
### 4. QIDO-RS: Search for Studies with Date Range
|
### 4. QIDO-RS: Search for Studies with Date Range
|
||||||
# Returns studies within a date range
|
# Returns studies within a date range
|
||||||
GET {{baseUrl}}/dicomWeb/studies?StudyDate=20250301-20250415
|
GET {{baseUrl}}/dicomWeb/studies?StudyDate=20250301-20250415
|
||||||
Accept: application/dicom+json
|
Accept: application/dicom+json
|
||||||
|
Authorization: Bearer {{ token }}
|
||||||
|
|
||||||
### 5. QIDO-RS: Search for Series in a Study
|
### 5. QIDO-RS: Search for Series in a Study
|
||||||
# Replace STUDY_INSTANCE_UID with an actual Study UID from your data
|
# Replace STUDY_INSTANCE_UID with an actual Study UID from your data
|
||||||
@@ -91,4 +76,12 @@ Accept: image/jpeg
|
|||||||
####
|
####
|
||||||
GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
||||||
|
|
||||||
|
### 13. DELETE Study
|
||||||
|
# Deletes a specific study
|
||||||
|
DELETE {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.0.1252.1.20250516.144411.1639014.3
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
###
|
||||||
|
curl -X DELETE \
|
||||||
|
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
|
||||||
|
"https://healthcare.googleapis.com/v1/projects/ohifproxy/locations/asia-southeast2/datasets/sas-storage/dicomStores/store-1/dicomWeb/studies/1.2.826.0.1.3680043.0.1252.1.20250516.144411.1639014.3"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMiIsImVtYWlsIjoicGF0aWVudCIsInJvbGUiOiJwYXRpZW50IiwidXNlcl9uYW1lIjoiUGF0aWVudCBVc2VyIiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsInBhdGllbnRfaWQiOiIwMDIxMTYyMiIsInBhdGllbnRfbmFtZSI6IkRJRElUIFNVWUFUTkFeUi4xMDA0OS4xOCIsImFjY2Vzc2lvbl9udW1iZXIiOiJDUi4xODA3MTMuMDM2Iiwic3R1ZHlfaXVpZCI6IjEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAxODA3MTMwMzYiLCJob21lX3VybCI6InZpZXdlcj9TdHVkeUluc3RhbmNlVUlEcz0xLjIuODI2LjAuMS4zNjgwMDQzLjkuNzMwNy4xLjIwMTgwNzEzMDM2Iiwic3R1ZHlfbGlzdCI6ImRpc2FibGVkIiwiZXhwIjoxNzQ2ODUyMDU2LCJpYXQiOjE3NDY3NjU2NTZ9.wGKCU77toQ5D27DRcNJE_XQjoJmxxzqoPbmf5N7D0cw
|
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJBZG1pbiBVc2VyIiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImhvbWVfdXJsIjoiLyIsInN0dWR5X2xpc3QiOiJlbmFibGVkIiwiZXhwIjoxNzQ3Mzg0MDE2LCJpYXQiOjE3NDcyOTc2MTZ9.Ak1DECP1MXzQAPyU-AJM6Tsu6-sw04UtWYvY37-SaT4
|
||||||
@token_exp_doctor = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJleHAiOjE3NDY1MDQ1MTYsImlhdCI6MTc0NjQxODExNn0.vlDrns1oPFXHE5--TmWqwzvzxnfcCPcV2UW8_4GwDwE
|
@token_exp_doctor = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJleHAiOjE3NDY1MDQ1MTYsImlhdCI6MTc0NjQxODExNn0.vlDrns1oPFXHE5--TmWqwzvzxnfcCPcV2UW8_4GwDwE
|
||||||
@baseUrl = http://localhost:5555
|
@baseUrl = http://localhost:5555
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ POST {{baseUrl}}/auth/login
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"email": "patient",
|
"email": "admin@example.com",
|
||||||
"password": "patient"
|
"password": "password123"
|
||||||
}
|
}
|
||||||
|
|
||||||
### Login Admin / Exp_doctor (ALL ACCESS)
|
### Login Admin / Exp_doctor (ALL ACCESS)
|
||||||
@@ -25,11 +25,11 @@ Content-Type: application/json
|
|||||||
# Destination URL / OHIF Viewer URL: http://152.42.173.210:3000/viewer?StudyInstanceUIDs=1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
# Destination URL / OHIF Viewer URL: http://152.42.173.210:3000/viewer?StudyInstanceUIDs=1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
||||||
|
|
||||||
### Study where StudyIUID
|
### Study where StudyIUID
|
||||||
GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.20180713036
|
GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.20180530066
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
### Series List
|
### Series List
|
||||||
GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01/series?includefield=00080021,00080031,0008103E,00200011
|
GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.20180530066/series?includefield=00080021,00080031,0008103E,00200011
|
||||||
Authorization: Bearer {{token}}
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
### Semua Study dari Patient ID
|
### Semua Study dari Patient ID
|
||||||
|
|||||||
62
test/http/pydicom-upload.http
Normal file
62
test/http/pydicom-upload.http
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 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
|
||||||
|
# }
|
||||||
|
|
||||||
|
POST {{baseUrl}}/uploaded_dicom
|
||||||
|
X-PYDICOM-API-KEY: {{pydicomApiKey}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "randomuser@example.com",
|
||||||
|
"password": "securepassword456",
|
||||||
|
"name": "John Doe",
|
||||||
|
"role": "patient",
|
||||||
|
"patient": {
|
||||||
|
"patient_id": "MR00000789",
|
||||||
|
"patient_name": "John Doe",
|
||||||
|
"date_of_birth": "1990-07-15"
|
||||||
|
},
|
||||||
|
"studies": [
|
||||||
|
{
|
||||||
|
"study_instance_uid": "1.2.826.0.1.3680043.9.1234.1.202507151234.01",
|
||||||
|
"accession_number": "CR.150720.1234.01",
|
||||||
|
"study_date": "2025-07-15",
|
||||||
|
"study_description": "CT Scan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
178
test/http/register-flow.http
Normal file
178
test/http/register-flow.http
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
### Register new user, patient, and doctor tests
|
||||||
|
|
||||||
|
# Base URL for the API
|
||||||
|
@baseUrl = http://localhost:5555
|
||||||
|
|
||||||
|
|
||||||
|
### Register an admin user
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "Admin User",
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Register a new patient with multiple studies
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "patient1@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "DIDIT SUYATNA^R.10049.18",
|
||||||
|
"role": "patient",
|
||||||
|
"patient": {
|
||||||
|
"patient_id": "00211622",
|
||||||
|
"patient_name": "DIDIT SUYATNA^R.10049.18",
|
||||||
|
"date_of_birth": "1980-01-01"
|
||||||
|
},
|
||||||
|
"studies": [
|
||||||
|
{
|
||||||
|
"study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.20180530066",
|
||||||
|
"accession_number": "CR.180530.066",
|
||||||
|
"study_date": "2018-05-30",
|
||||||
|
"study_description": "Chest X-Ray"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
###
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "patient1@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "DIDIT SUYATNA^R.10049.18",
|
||||||
|
"role": "patient",
|
||||||
|
"patient": {
|
||||||
|
"patient_id": "00211622",
|
||||||
|
"patient_name": "DIDIT SUYATNA^R.10049.18",
|
||||||
|
"date_of_birth": "1980-01-01"
|
||||||
|
},
|
||||||
|
"studies": [
|
||||||
|
{
|
||||||
|
"study_instance_uid": "1.2.826.0.1.3680043.9.7307.1.20180713036",
|
||||||
|
"accession_number": "CR.180713.036",
|
||||||
|
"study_date": "2018-07-13",
|
||||||
|
"study_description": "Follow-up X-Ray"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
### Register another patient with one study
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
### Register another referring doctor
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "doctor@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "DR. HERWINDO RIDWAN, SP.OT",
|
||||||
|
"role": "ref_doctor",
|
||||||
|
"doctor": {
|
||||||
|
"doctor_id": "DOC002",
|
||||||
|
"doctor_name": "DR. HERWINDO RIDWAN, SP.OT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
### Register another referring doctor
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "doctor2@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "Referring^Physician",
|
||||||
|
"role": "ref_doctor",
|
||||||
|
"doctor": {
|
||||||
|
"doctor_id": "DOC003",
|
||||||
|
"doctor_name": "Referring^Physician"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
### Register a new expertise doctor
|
||||||
|
POST {{baseUrl}}/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "expert@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "Dr. Expertise",
|
||||||
|
"role": "expertise_doctor",
|
||||||
|
"doctor": {
|
||||||
|
"doctor_id": "EX456",
|
||||||
|
"doctor_name": "Dr. Expertise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
### Login with registered user
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "patient1@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Login with admin user
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Login with patient user
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "patient2@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Login with doctor user
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "doctor@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
@refresh_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNCIsImVtYWlsIjoiZG9jdG9yQGV4YW1wbGUuY29tIiwicm9sZSI6InJlZl9kb2N0b3IiLCJ1c2VyX25hbWUiOiJEUi4gSEVSV0lORE8gUklEV0FOLCBTUC5PVCIsInRva2VuX3R5cGUiOiJyZWZyZXNoIiwiaG9tZV91cmwiOiIvIiwic3R1ZHlfbGlzdCI6ImVuYWJsZWQiLCJmaWx0ZXJfdXJsIjoic3R1ZGllcz9saW1pdD0xMDFcdTAwMjZvZmZzZXQ9MFx1MDAyNmZ1enp5bWF0Y2hpbmc9ZmFsc2VcdTAwMjZpbmNsdWRlZmllbGQ9MDAwODEwMzAsMDAwODAwNjAsMDAwODAwOTBcdTAwMjYwMDA4MDA5MD1EUi4rSEVSV0lORE8rUklEV0FOJTJDK1NQLk9UIiwiZXhwIjoxNzQ3OTAxMzI5LCJpYXQiOjE3NDcyOTY1Mjl9.M7khgg8eZHk20qGsdPDmUojdBvItXOE-834R6t_AF9Q"
|
||||||
|
|
||||||
|
### Refresh TOken
|
||||||
|
POST {{baseUrl}}/auth/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"refresh_token" : {{refresh_token}}
|
||||||
|
}
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
### Shortlink Authentication Test File
|
### Shortlink Authentication Test File
|
||||||
@baseUrl = http://localhost:5555
|
@baseUrl = http://localhost:5555
|
||||||
@adminToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInVzZXJfbmFtZSI6IkFkbWluIFVzZXIiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiaG9tZV91cmwiOiIvIiwic3R1ZHlfbGlzdCI6ImVuYWJsZWQiLCJleHAiOjE3NDcyMDY5NTAsImlhdCI6MTc0NzEyMDU1MH0.M3fhlKB8MX-NxGdEgnaA9-AhMXnXjUjRsWYOBXntJe4
|
@adminToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNiIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJBZG1pbiBVc2VyIiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImhvbWVfdXJsIjoiLyIsInN0dWR5X2xpc3QiOiJlbmFibGVkIiwiZXhwIjoxNzQ3MzY0NjgxLCJpYXQiOjE3NDcyNzgyODF9.bkzxV8f26wN_r6uI5T6o58TNX4U0z-Wel1hCAl5-8ag
|
||||||
|
|
||||||
### 0. Login as Admin and take the token
|
### 1. Generate Short Link Single Study (Tidak ganti untuk 1 patient sampai expired)
|
||||||
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
|
POST {{baseUrl}}/generate-link
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{adminToken}}
|
Authorization: Bearer {{adminToken}}
|
||||||
@@ -24,27 +14,54 @@ Authorization: Bearer {{adminToken}}
|
|||||||
"expires_in": 48
|
"expires_in": 48
|
||||||
}
|
}
|
||||||
|
|
||||||
### 2. Authenticate with Short Link and DOB
|
### 2. Generate Short Link Multiple Studies
|
||||||
# Use the short_token from the response of request #1
|
POST {{baseUrl}}/generate-link
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{adminToken}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"patient_id": "00211622",
|
||||||
|
"study_uid": "1.2.826.0.1.3680043.9.7307.1.20180713036",
|
||||||
|
"dob": "1980-01-01",
|
||||||
|
"expires_in": 48
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
POST {{baseUrl}}/generate-link
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{adminToken}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"patient_id": "00211622",
|
||||||
|
"study_uid": "1.2.826.0.1.3680043.9.7307.1.20180530066",
|
||||||
|
"dob": "1980-01-01",
|
||||||
|
"expires_in": 48
|
||||||
|
}
|
||||||
|
|
||||||
|
### Authenticate correct
|
||||||
POST {{baseUrl}}/auth/shortlink
|
POST {{baseUrl}}/auth/shortlink
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"short_token": "oP-tLWeu",
|
"short_token": "DZDWF",
|
||||||
"dob": "2001-10-07"
|
"dob": "2001-10-07"
|
||||||
}
|
}
|
||||||
|
|
||||||
### 3. Try authentication with incorrect DOB
|
### Authenticate with Incorrect DOB
|
||||||
POST {{baseUrl}}/auth/shortlink
|
POST {{baseUrl}}/auth/shortlink
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"short_token": "erB5xT5S",
|
"short_token": "HNHh_zem",
|
||||||
"dob": "1985-04-16"
|
"dob": "2001-10-00"
|
||||||
}
|
}
|
||||||
|
|
||||||
### 4. Access DICOM data with the JWT from successful shortlink auth
|
### Recycle shortcode from expired or revoked token
|
||||||
# Replace with token from successful auth in request #2
|
POST {{baseUrl}}/recycle-shortcode
|
||||||
GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01
|
Authorization: Bearer {{adminToken}}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2hvcnRsaW5rX3NsXzgzZGNmNzQ1NGYwZiIsImVtYWlsIjoicGF0aWVudF9zbF84M2RjZjc0NTRmMGZAc2hvcnRsaW5rLmxvY2FsIiwicm9sZSI6InBhdGllbnQiLCJ1c2VyX25hbWUiOiJQYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsInBhdGllbnRfaWQiOiJNUjAwMDAwMzU5IiwicGF0aWVudF9uYW1lIjoiUGF0aWVudCIsInN0dWR5X2l1aWRzIjpbIjEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIl0sImhvbWVfdXJsIjoidmlld2VyP1N0dWR5SW5zdGFuY2VVSURzPTEuMi44MjYuMC4xLjM2ODAwNDMuOS43MzA3LjEuMjAyNTAzMTk2MzkzLjAxIiwic3R1ZHlfbGlzdCI6ImRpc2FibGVkIiwiZXhwIjoxNzQ3MjA3MDI4LCJpYXQiOjE3NDcxMjA2Mjh9.RMGF9ParYAmOXbJqd0DP2kl0X6O0n8j_LI6FF9el4qM
|
### Check the response from recycling
|
||||||
|
# The response should include:
|
||||||
|
# - status: "success"
|
||||||
|
# - message: "Shortcodes recycled successfully"
|
||||||
|
# - count: number of recycled shortcodes (integer)
|
||||||
|
|||||||
Reference in New Issue
Block a user