diff --git a/cmd/server/main.go b/cmd/server/main.go index 24e2467..9667182 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "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/database" "go.uber.org/zap" ) @@ -35,6 +36,30 @@ func main() { } 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 router := api.SetupRouter(cfg, l) diff --git a/config/config.go b/config/config.go index 5767182..df348f3 100644 --- a/config/config.go +++ b/config/config.go @@ -40,11 +40,14 @@ type Config struct { } `mapstructure:"shortlink"` Database struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - Name string `mapstructure:"name"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + 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"` AllowedOrigins []string `mapstructure:"allowed_origins"` diff --git a/config/config.yaml b/config/config.yaml index 6683926..b4fb7af 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -17,7 +17,7 @@ auth: jwt_secret: "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" # Change this in production! access_token_expiry: 1440 # minutes (24 hours) 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 shortlink: base_url: "http://localhost:3333" # The base URL for generated OHIF Auth shortlinks @@ -27,9 +27,12 @@ shortlink: database: host: "localhost" port: 3306 - user: "dbuser" - password: "dbpassword" # Consider using environment variables for sensitive data + user: "root" + password: "alfandi102938" # Change this to your MariaDB password name: "ohif_proxy" + max_open_conns: 10 + max_idle_conns: 5 + conn_max_lifetime_mins: 60 allowed_origins: - "*" # For development; restrict this in production \ No newline at end of file diff --git a/internal/api/models/doctor.go b/internal/api/models/doctor.go new file mode 100644 index 0000000..5623a79 --- /dev/null +++ b/internal/api/models/doctor.go @@ -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" +} diff --git a/internal/api/models/patient.go b/internal/api/models/patient.go new file mode 100644 index 0000000..dd628e7 --- /dev/null +++ b/internal/api/models/patient.go @@ -0,0 +1,18 @@ +package models + +// 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"` +} + +// 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"` +} diff --git a/internal/api/models/shortlink.go b/internal/api/models/shortlink.go new file mode 100644 index 0000000..adedc15 --- /dev/null +++ b/internal/api/models/shortlink.go @@ -0,0 +1,52 @@ +package models + +// 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"` +} diff --git a/internal/api/models/user.go b/internal/api/models/user.go index 5bb7822..32f5568 100644 --- a/internal/api/models/user.go +++ b/internal/api/models/user.go @@ -21,21 +21,6 @@ type RefreshToken struct { 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 type LoginRequest struct { Email string `json:"email"` @@ -59,54 +44,3 @@ type RefreshRequest struct { type RefreshResponse struct { 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"` -} diff --git a/internal/api/repository/doctor.go b/internal/api/repository/doctor.go new file mode 100644 index 0000000..b689b91 --- /dev/null +++ b/internal/api/repository/doctor.go @@ -0,0 +1,44 @@ +package repository + +import ( + "fmt" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" +) + +// 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 +} diff --git a/internal/api/repository/patient.go b/internal/api/repository/patient.go new file mode 100644 index 0000000..bfccd82 --- /dev/null +++ b/internal/api/repository/patient.go @@ -0,0 +1,62 @@ +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" +) + +// 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 +} diff --git a/internal/api/repository/repository.go b/internal/api/repository/repository.go new file mode 100644 index 0000000..f984247 --- /dev/null +++ b/internal/api/repository/repository.go @@ -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 +} diff --git a/internal/api/repository/shortlink.go b/internal/api/repository/shortlink.go new file mode 100644 index 0000000..633b116 --- /dev/null +++ b/internal/api/repository/shortlink.go @@ -0,0 +1,141 @@ +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" +) + +// DBShortLink represents a shortlink from the database +type DBShortLink struct { + ShortlinkID int `db:"ShortlinkID"` + ShortlinKCode string `db:"ShortlinKCode"` + Shortlink_PatientID string `db:"Shortlink_PatientID"` + Shortlink_Study_IUID string `db:"Shortlink_Study_IUID"` + ShortlinkHashDoB string `db:"ShortlinkHashDoB"` + ShortlinkExpiredAt time.Time `db:"ShortlinkExpiredAt"` + ShortlinkIsRevoked bool `db:"ShortlinkIsRevoked"` + ShortlinkRemainingTries int `db:"ShortlinkRemainingTries"` + ShortlinkCreatedAt time.Time `db:"ShortlinkCreatedAt"` + ShortlinkCreate_UserID int `db:"ShortlinkCreate_UserID"` +} + +// 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 { + return &models.ShortLink{ + ID: fmt.Sprintf("%d", s.ShortlinkID), + Token: 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: fmt.Sprintf("%d", s.ShortlinkCreate_UserID), + } +} + +// 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 +} + +// CreateShortLink stores a new shortlink in the database +func (r *ShortLinkRepository) CreateShortLink(shortLink *models.ShortLink) error { + query := `INSERT INTO shortlink ( + ShortlinKCode, + Shortlink_PatientID, + Shortlink_Study_IUID, + ShortlinkHashDoB, + ShortlinkExpiredAt, + ShortlinkIsRevoked, + ShortlinkRemainingTries, + ShortlinkCreatedAt, + ShortlinkCreate_UserID) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)` + + createdByID, err := strconv.Atoi(shortLink.CreatedByID) + if err != nil { + return fmt.Errorf("invalid created by ID: %w", err) + } + + expiresAt, err := time.Parse(time.RFC3339, shortLink.ExpiresAt) + if err != nil { + return fmt.Errorf("invalid expiration date: %w", err) + } + + _, err = database.DB.Exec( + query, + shortLink.Token, + 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 +} + +// UpdateShortLink updates an existing shortlink in the database +func (r *ShortLinkRepository) UpdateShortLink(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 = database.DB.Exec( + query, + shortLink.IsRevoked, + shortLink.RemainingTries, + expiresAt, + shortLink.Token, + ) + + if err != nil { + return fmt.Errorf("database error updating shortlink: %w", err) + } + + return nil +} diff --git a/internal/api/repository/study.go b/internal/api/repository/study.go new file mode 100644 index 0000000..cfc10d8 --- /dev/null +++ b/internal/api/repository/study.go @@ -0,0 +1,69 @@ +package repository + +import ( + "fmt" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/database" +) + +// DBStudy represents a study from the database +type DBStudy struct { + ID int `db:"id"` + Study_PatientID string `db:"Study_PatientID"` + Study_IUID string `db:"Study_IUID"` + Study_AccessionNumber string `db:"Study_AccessionNumber"` + 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.Study_IUID) + if study.Study_AccessionNumber != "" { + accessionNumbers = append(accessionNumbers, study.Study_AccessionNumber) + } + } + + 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 Study_IUID = ?` + err := database.DB.Get(&study, query, studyUID) + + if err != nil { + return nil, fmt.Errorf("database error getting study: %w", err) + } + + return &study, nil +} diff --git a/internal/api/repository/user.go b/internal/api/repository/user.go new file mode 100644 index 0000000..536c13c --- /dev/null +++ b/internal/api/repository/user.go @@ -0,0 +1,140 @@ +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" +) + +// 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 +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 86374fc..5fc6b51 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -53,7 +53,8 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { // Initialize JWT auth service jwtSecret := cfg.Auth.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 @@ -62,6 +63,8 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { // Create JWT manager with config values jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry) + + // Initialize services with domain-specific repositories authService := service.NewAuthService(jwtManager) // Initialize shortlink service with config values diff --git a/internal/api/service/auth_service.go b/internal/api/service/auth_service.go index 5f96ef2..dc1f7a5 100644 --- a/internal/api/service/auth_service.go +++ b/internal/api/service/auth_service.go @@ -4,10 +4,12 @@ import ( "errors" "fmt" "net/url" + "time" "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/repository" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth" ) @@ -18,26 +20,37 @@ var ( // AuthService handles authentication operations type AuthService struct { - jwtManager *auth.JWTManager - // When you implement database connection, add a db client here - // db *sqlx.DB + jwtManager *auth.JWTManager + userRepo *repository.UserRepository + patientRepo *repository.PatientRepository } // NewAuthService creates a new authentication service func NewAuthService(jwtManager *auth.JWTManager) *AuthService { return &AuthService{ - jwtManager: jwtManager, + jwtManager: jwtManager, + userRepo: repository.NewUserRepository(), + patientRepo: repository.NewPatientRepository(), } } // Login authenticates a user and generates tokens func (s *AuthService) Login(email, password string) (*models.LoginResponse, error) { - // Find user in mock data - user := models.FindUserByCredentials(email, password) + // Find user in database + user, err := s.userRepo.GetUserByEmail(email) + if err != nil { + return nil, fmt.Errorf("error finding user: %w", err) + } + if user == nil { return nil, ErrInvalidCredentials } + // Verify password + if err := CheckPassword(password, user.Password); err != nil { + return nil, ErrInvalidCredentials + } + // Create token claims based on user role additionalClaims := make(map[string]interface{}) var redirectURL string @@ -45,7 +58,11 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro switch user.Role { case "patient": // 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 { return nil, ErrUserNotFound } @@ -53,15 +70,15 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro // Set patient-specific claims additionalClaims["patient_id"] = patientData.PatientID additionalClaims["patient_name"] = patientData.PatientName - additionalClaims["study_iuids"] = patientData.StudyIUIDs + additionalClaims["study_iuids"] = patientData.StudyInstanceUIDs additionalClaims["accession_numbers"] = patientData.AccessionNumbers // For redirectURL and home_url, use first study for simplicity - if len(patientData.StudyIUIDs) > 0 { - additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0]) - redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyIUIDs[0]) + if len(patientData.StudyInstanceUIDs) > 0 { + additionalClaims["home_url"] = fmt.Sprintf("viewer?StudyInstanceUIDs=%s", patientData.StudyInstanceUIDs[0]) + redirectURL = fmt.Sprintf("/viewer?StudyInstanceUIDs=%s", patientData.StudyInstanceUIDs[0]) } else { - // Fallback for empty studies array (shouldn't happen) + // Fallback for empty studies array additionalClaims["home_url"] = "/" redirectURL = "/" } @@ -79,7 +96,7 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro redirectURL = "/" - case "expertise_doctor": + case "expertise_doctor", "admin": // Expertise doctors have full access additionalClaims["home_url"] = "/" additionalClaims["study_list"] = "enabled" @@ -98,6 +115,12 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro 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{ AccessToken: accessToken, RefreshToken: refreshToken, @@ -108,6 +131,27 @@ func (s *AuthService) Login(email, password string) (*models.LoginResponse, erro // RefreshToken generates a new access token using a refresh token 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 claims, err := s.jwtManager.ValidateToken(refreshToken) if err != nil { @@ -148,7 +192,13 @@ func (s *AuthService) RefreshToken(refreshToken string) (string, error) { } // 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 { return "", err } @@ -161,6 +211,11 @@ func (s *AuthService) ValidateToken(token string) (*auth.CustomClaims, error) { 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 func HashPassword(password string) (string, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -174,17 +229,3 @@ func HashPassword(password string) (string, error) { func CheckPassword(password, hash string) error { 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 -} diff --git a/internal/api/service/db_repository.go b/internal/api/service/db_repository.go deleted file mode 100644 index 8a64cf7..0000000 --- a/internal/api/service/db_repository.go +++ /dev/null @@ -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() -} diff --git a/internal/api/service/shortlink_service.go b/internal/api/service/shortlink_service.go index 5041657..f16bafd 100644 --- a/internal/api/service/shortlink_service.go +++ b/internal/api/service/shortlink_service.go @@ -11,6 +11,7 @@ import ( "time" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/repository" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth" "go.uber.org/zap" ) @@ -38,14 +39,14 @@ var ( // ShortLinkService handles operations related to short links type ShortLinkService struct { - jwtManager *auth.JWTManager - logger *zap.Logger + jwtManager *auth.JWTManager + logger *zap.Logger + shortLinkRepo *repository.ShortLinkRepository + patientRepo *repository.PatientRepository // Configuration settings baseURL string defaultExpiryTime time.Duration 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 @@ -66,10 +67,11 @@ func NewShortLinkService(jwtManager *auth.JWTManager, logger *zap.Logger, baseUR return &ShortLinkService{ jwtManager: jwtManager, logger: logger, + shortLinkRepo: repository.NewShortLinkRepository(), + patientRepo: repository.NewPatientRepository(), baseURL: baseURL, defaultExpiryTime: time.Duration(defaultExpiryHours) * time.Hour, maxAttempts: maxAttempts, - shortLinks: make(map[string]*models.ShortLink), } } @@ -123,8 +125,12 @@ func (s *ShortLinkService) GenerateShortLink(req *models.GenerateShortLinkReques RemainingTries: s.maxAttempts, } - // Store the short link (in production, this would be in a database) - s.shortLinks[token] = shortLink + // Store the short link in the database + err = s.shortLinkRepo.CreateShortLink(shortLink) + if err != nil { + s.logger.Error("Failed to store shortlink in database", zap.Error(err)) + return nil, ErrCreationFailed + } // Generate the full URL using the configured base URL fullURL := fmt.Sprintf("%s/short-auth?short=%s", s.baseURL, token) @@ -141,9 +147,14 @@ func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) ( // Get the token using the helper method that handles both field names token := req.GetToken() - // Find the short link - shortLink, exists := s.shortLinks[token] - if !exists { + // Find the short link in the database + shortLink, err := s.shortLinkRepo.GetShortLinkByToken(token) + if err != nil { + s.logger.Error("Error retrieving shortlink", zap.Error(err), zap.String("token", token)) + return nil, ErrShortLinkNotFound + } + + if shortLink == nil { return nil, ErrShortLinkNotFound } @@ -168,6 +179,13 @@ func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) ( if !isValidDOBFormat(dob) { // Decrement remaining tries on invalid format shortLink.RemainingTries-- + + // Update the shortlink in the database + err = s.shortLinkRepo.UpdateShortLink(shortLink) + if err != nil { + s.logger.Error("Failed to update shortlink tries", zap.Error(err)) + } + return nil, errors.New("invalid date of birth format, expected YYYY-MM-DD") } @@ -177,12 +195,25 @@ func (s *ShortLinkService) ValidateShortLink(req *models.ShortLinkAuthRequest) ( if hashedDOB != shortLink.HashedDOB { // Decrement remaining tries on failed verification shortLink.RemainingTries-- + + // Update the shortlink in the database + err = s.shortLinkRepo.UpdateShortLink(shortLink) + if err != nil { + s.logger.Error("Failed to update shortlink tries", zap.Error(err)) + } + return nil, ErrInvalidDOB } // DOB verified, reset tries count as successful login shortLink.RemainingTries = s.maxAttempts + // Update the shortlink in the database + err = s.shortLinkRepo.UpdateShortLink(shortLink) + if err != nil { + s.logger.Error("Failed to update shortlink tries after successful validation", zap.Error(err)) + } + return shortLink, nil } diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..47cf5df --- /dev/null +++ b/internal/database/database.go @@ -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 +}