feat: base go mkiso
This commit is contained in:
173
internal/config/config.go
Normal file
173
internal/config/config.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the top-level configuration structure.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
DCMTK DCMTKConfig `yaml:"dcmtk"`
|
||||
PACS PACSConfig `yaml:"pacs"`
|
||||
OurAE OurAEConfig `yaml:"our_ae"`
|
||||
PatientAPI PatientAPIConfig `yaml:"patient_api"`
|
||||
CDPublisher CDPublisherConfig `yaml:"cd_publisher"`
|
||||
ISO ISOConfig `yaml:"iso"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
type DCMTKConfig struct {
|
||||
Storescp string `yaml:"storescp"`
|
||||
Movescu string `yaml:"movescu"`
|
||||
Storescu string `yaml:"storescu"`
|
||||
}
|
||||
|
||||
type PACSConfig struct {
|
||||
AETitle string `yaml:"ae_title"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type OurAEConfig struct {
|
||||
AETitle string `yaml:"ae_title"`
|
||||
BasePort int `yaml:"base_port"`
|
||||
PortRange int `yaml:"port_range"`
|
||||
}
|
||||
|
||||
type PatientAPIConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
AuthType string `yaml:"auth_type"`
|
||||
AuthHeader string `yaml:"auth_header"`
|
||||
AuthToken string `yaml:"auth_token"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Retry int `yaml:"retry"`
|
||||
RetryBackoff time.Duration `yaml:"retry_backoff"`
|
||||
}
|
||||
|
||||
type CDPublisherConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type ISOConfig struct {
|
||||
MicrodicomPath string `yaml:"microdicom_path"`
|
||||
TempDir string `yaml:"temp_dir"`
|
||||
}
|
||||
|
||||
// Load reads and parses the config file from the given path.
|
||||
// If path is empty, it reads from the MKISO_CONFIG env var, falling back
|
||||
// to ./config.yaml.
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
path = os.Getenv("MKISO_CONFIG")
|
||||
}
|
||||
if path == "" {
|
||||
path = "./config.yaml"
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
}
|
||||
if c.Server.ReadTimeout == 0 {
|
||||
c.Server.ReadTimeout = 300 * time.Second
|
||||
}
|
||||
if c.Server.WriteTimeout == 0 {
|
||||
c.Server.WriteTimeout = 600 * time.Second
|
||||
}
|
||||
|
||||
if c.DCMTK.Storescp == "" {
|
||||
return fmt.Errorf("dcmtk.storescp is required")
|
||||
}
|
||||
if c.DCMTK.Movescu == "" {
|
||||
return fmt.Errorf("dcmtk.movescu is required")
|
||||
}
|
||||
if c.DCMTK.Storescu == "" {
|
||||
return fmt.Errorf("dcmtk.storescu is required")
|
||||
}
|
||||
|
||||
if c.PACS.Host == "" {
|
||||
c.PACS.Host = "localhost"
|
||||
}
|
||||
if c.PACS.Port == 0 {
|
||||
c.PACS.Port = 11112
|
||||
}
|
||||
if c.PACS.AETitle == "" {
|
||||
c.PACS.AETitle = "ABPACS"
|
||||
}
|
||||
|
||||
if c.OurAE.AETitle == "" {
|
||||
c.OurAE.AETitle = "CDRECORD"
|
||||
}
|
||||
if c.OurAE.BasePort == 0 {
|
||||
c.OurAE.BasePort = 10104
|
||||
}
|
||||
if c.OurAE.PortRange == 0 {
|
||||
c.OurAE.PortRange = 100
|
||||
}
|
||||
|
||||
if c.PatientAPI.BaseURL == "" {
|
||||
c.PatientAPI.BaseURL = "http://localhost:8090"
|
||||
}
|
||||
if c.PatientAPI.Endpoint == "" {
|
||||
c.PatientAPI.Endpoint = "/patient/by-accession"
|
||||
}
|
||||
if c.PatientAPI.Timeout == 0 {
|
||||
c.PatientAPI.Timeout = 10 * time.Second
|
||||
}
|
||||
if c.PatientAPI.Retry == 0 {
|
||||
c.PatientAPI.Retry = 3
|
||||
}
|
||||
if c.PatientAPI.RetryBackoff == 0 {
|
||||
c.PatientAPI.RetryBackoff = 500 * time.Millisecond
|
||||
}
|
||||
|
||||
if c.CDPublisher.Host == "" {
|
||||
c.CDPublisher.Host = "172.16.0.120"
|
||||
}
|
||||
if c.CDPublisher.Port == 0 {
|
||||
c.CDPublisher.Port = 104
|
||||
}
|
||||
|
||||
if c.ISO.MicrodicomPath == "" {
|
||||
c.ISO.MicrodicomPath = "/var/www/html/microdicom"
|
||||
}
|
||||
if c.ISO.TempDir == "" {
|
||||
c.ISO.TempDir = "/tmp"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
69
internal/handler/health.go
Normal file
69
internal/handler/health.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// Health returns a handler that checks and reports the status of
|
||||
// all external dependencies.
|
||||
func Health(cfg *config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
deps := []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
}{
|
||||
{"storescp", cfg.DCMTK.Storescp, ""},
|
||||
{"movescu", cfg.DCMTK.Movescu, ""},
|
||||
{"storescu", cfg.DCMTK.Storescu, ""},
|
||||
}
|
||||
|
||||
allOK := true
|
||||
for i, dep := range deps {
|
||||
if dep.Path == "" {
|
||||
deps[i].Status = "not configured"
|
||||
allOK = false
|
||||
continue
|
||||
}
|
||||
if dicom.FileExists(dep.Path) {
|
||||
// Quick check: can we execute it?
|
||||
cmd := exec.Command(dep.Path, "--version")
|
||||
if err := cmd.Run(); err == nil {
|
||||
deps[i].Status = "ok"
|
||||
} else {
|
||||
deps[i].Status = "executable not found"
|
||||
allOK = false
|
||||
}
|
||||
} else {
|
||||
deps[i].Status = "file not found"
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check microdicom path
|
||||
microdicomOK := dicom.DirExists(cfg.ISO.MicrodicomPath)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"dependencies": deps,
|
||||
"microdicom_path": cfg.ISO.MicrodicomPath,
|
||||
"microdicom_exists": microdicomOK,
|
||||
"auth_enabled": cfg.Auth.Enabled,
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if !allOK {
|
||||
response["status"] = "degraded"
|
||||
statusCode = http.StatusOK // still return 200, status field indicates degraded
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
}
|
||||
133
internal/handler/iso.go
Normal file
133
internal/handler/iso.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"mkiso-server/internal/service"
|
||||
)
|
||||
|
||||
// writeJSON is a helper to write a JSON response.
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// DownloadSingle handles GET /api/iso/download?accession_number=X
|
||||
// Returns an ISO file as application/octet-stream.
|
||||
func DownloadSingle(isoSvc *service.ISOService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
acc := r.URL.Query().Get("accession_number")
|
||||
if acc == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_number"})
|
||||
return
|
||||
}
|
||||
acc = strings.TrimSpace(acc)
|
||||
|
||||
slog.Info("handling download single", "accession", acc)
|
||||
|
||||
item, err := isoSvc.GenerateISO(r.Context(), acc)
|
||||
if err != nil {
|
||||
slog.Error("ISO generation failed", "accession", acc, "error", err)
|
||||
status := http.StatusInternalServerError
|
||||
msg := "ISO creation failed"
|
||||
if strings.Contains(err.Error(), "no DICOM data") {
|
||||
status = http.StatusNotFound
|
||||
msg = "no DICOM data for accession_number"
|
||||
}
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": msg,
|
||||
"detail": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer item.Cleanup()
|
||||
|
||||
// Stream ISO file
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.Filename))
|
||||
|
||||
data, err := os.ReadFile(item.Path)
|
||||
if err != nil {
|
||||
slog.Error("read ISO file failed", "path", item.Path, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read ISO"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(data)
|
||||
slog.Info("ISO download completed", "accession", acc, "size", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadMultiple handles GET /api/iso/download-multiple?accession_numbers=X,Y,Z
|
||||
// Returns an ISO file as application/octet-stream.
|
||||
func DownloadMultiple(isoSvc *service.ISOService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := r.URL.Query().Get("accession_numbers")
|
||||
if raw == "" {
|
||||
// Also check for "accession_number" with comma (backward compat)
|
||||
raw = r.URL.Query().Get("accession_number")
|
||||
}
|
||||
if raw == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_numbers"})
|
||||
return
|
||||
}
|
||||
|
||||
accs := parseAccessions(raw)
|
||||
if len(accs) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty accession_number list"})
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handling download multiple", "accessions", accs)
|
||||
|
||||
item, err := isoSvc.GenerateISOMultiple(r.Context(), accs)
|
||||
if err != nil {
|
||||
slog.Error("ISO generation failed", "accessions", accs, "error", err)
|
||||
status := http.StatusInternalServerError
|
||||
msg := "ISO creation failed"
|
||||
if strings.Contains(err.Error(), "no DICOM data") {
|
||||
status = http.StatusNotFound
|
||||
msg = "no DICOM data for accession_numbers"
|
||||
}
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": msg,
|
||||
"detail": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer item.Cleanup()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, item.Filename))
|
||||
|
||||
data, err := os.ReadFile(item.Path)
|
||||
if err != nil {
|
||||
slog.Error("read ISO file failed", "path", item.Path, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read ISO"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(data)
|
||||
slog.Info("ISO download completed (multiple)", "accessions", accs, "size", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// parseAccessions parses a comma-separated list of accession numbers,
|
||||
// trimming whitespace and filtering empty entries.
|
||||
func parseAccessions(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
63
internal/handler/print.go
Normal file
63
internal/handler/print.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mkiso-server/internal/service"
|
||||
)
|
||||
|
||||
// PrintISO handles GET /api/iso/print?accession_number=X
|
||||
// This single endpoint handles both single and multi-accession relay.
|
||||
// If accession_number contains commas, it auto-detects multi mode.
|
||||
// Returns JSON response with relay results.
|
||||
func PrintISO(relaySvc *service.RelayService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
acc := r.URL.Query().Get("accession_number")
|
||||
if acc == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_number"})
|
||||
return
|
||||
}
|
||||
|
||||
accs := parseAccessions(acc)
|
||||
if len(accs) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty accession_number"})
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handling print/relay",
|
||||
"accessions", accs,
|
||||
"mode", map[bool]string{true: "multi", false: "single"}[len(accs) > 1],
|
||||
)
|
||||
|
||||
result, err := relaySvc.RelayToCDPublisher(r.Context(), accs)
|
||||
if err != nil {
|
||||
slog.Error("print ISO relay failed", "accessions", accs, "error", err)
|
||||
status := http.StatusBadGateway
|
||||
msg := "DICOM relay failed"
|
||||
|
||||
if strings.Contains(err.Error(), "no DICOM data") {
|
||||
status = http.StatusNotFound
|
||||
msg = "no DICOM data for accession_number"
|
||||
} else if strings.Contains(err.Error(), "storescu relay failed") {
|
||||
msg = "CD Publisher unreachable"
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": msg,
|
||||
"detail": err.Error(),
|
||||
"destination": relaySvc.Destination(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"accessions_sent": result.AccessionsSent,
|
||||
"patient_name": result.PatientName,
|
||||
"destination": result.Destination,
|
||||
"files_sent": result.FilesSent,
|
||||
})
|
||||
}
|
||||
}
|
||||
131
internal/isobuilder/builder.go
Normal file
131
internal/isobuilder/builder.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package isobuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
diskfs "github.com/diskfs/go-diskfs"
|
||||
"github.com/diskfs/go-diskfs/disk"
|
||||
"github.com/diskfs/go-diskfs/filesystem"
|
||||
"github.com/diskfs/go-diskfs/filesystem/iso9660"
|
||||
)
|
||||
|
||||
// BuildFromDirectory creates an ISO 9660 image from a source directory.
|
||||
// Equivalent to: genisoimage -iso-level 4 -r -J -V <label> -o <isoPath> <srcDir>
|
||||
func BuildFromDirectory(srcDir, isoPath, volumeLabel string) error {
|
||||
// Step 1: Calculate total size (sum of all files + 10% overhead)
|
||||
totalSize, err := dirSize(srcDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("calculate directory size: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Create disk image
|
||||
mydisk, err := diskfs.Create(isoPath, totalSize, diskfs.SectorSizeDefault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create disk image: %w", err)
|
||||
}
|
||||
mydisk.LogicalBlocksize = 2048
|
||||
|
||||
// Step 3: Create ISO 9660 filesystem
|
||||
fs, err := mydisk.CreateFilesystem(disk.FilesystemSpec{
|
||||
Partition: 0,
|
||||
FSType: filesystem.TypeISO9660,
|
||||
VolumeLabel: volumeLabel,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create ISO filesystem: %w", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
// Step 4: Walk source dir and copy files
|
||||
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if relPath == "." {
|
||||
return nil // skip root
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := fs.Mkdir(relPath); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", relPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular file — copy contents
|
||||
rw, err := fs.OpenFile(relPath, os.O_CREATE|os.O_RDWR)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file %q: %w", relPath, err)
|
||||
}
|
||||
defer rw.Close()
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source %q: %w", path, err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
if _, err := io.Copy(rw, srcFile); err != nil {
|
||||
return fmt.Errorf("copy %q: %w", relPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk source directory: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Finalize with Rock Ridge + Joliet extensions
|
||||
iso, ok := fs.(*iso9660.FileSystem)
|
||||
if !ok {
|
||||
return fmt.Errorf("not an ISO 9660 filesystem")
|
||||
}
|
||||
if err := iso.Finalize(iso9660.FinalizeOptions{
|
||||
RockRidge: true,
|
||||
Joliet: true,
|
||||
DeepDirectories: true,
|
||||
VolumeIdentifier: volumeLabel,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("finalize ISO: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dirSize calculates total size of all files in a directory tree,
|
||||
// with 10% overhead margin for ISO metadata.
|
||||
func dirSize(dir string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Add 10% overhead for ISO 9660 metadata
|
||||
total = total + total/10
|
||||
// Minimum 10MB (some PACS studies are small)
|
||||
if total < 10*1024*1024 {
|
||||
total = 10 * 1024 * 1024
|
||||
}
|
||||
// Round up to next 2048-byte sector
|
||||
if total%2048 != 0 {
|
||||
total = total + (2048 - total%2048)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
79
internal/isobuilder/builder_test.go
Normal file
79
internal/isobuilder/builder_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package isobuilder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildFromDirectory(t *testing.T) {
|
||||
srcDir := "/tmp/build_iso/xcdrom"
|
||||
isoPath := "/tmp/dicom_purego_test.iso"
|
||||
|
||||
// Check if source exists (might need to be created first)
|
||||
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
|
||||
t.Skipf("source dir %s does not exist, skipping", srcDir)
|
||||
}
|
||||
|
||||
// Clean up any previous test ISO
|
||||
os.Remove(isoPath)
|
||||
|
||||
// Build ISO using pure Go
|
||||
err := BuildFromDirectory(srcDir, isoPath, "DICOM")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildFromDirectory failed: %v", err)
|
||||
}
|
||||
defer os.Remove(isoPath)
|
||||
|
||||
// Verify ISO file exists and has reasonable size
|
||||
info, err := os.Stat(isoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot stat ISO: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("ISO is empty")
|
||||
}
|
||||
t.Logf("ISO created: %s (%.2f MB)", isoPath, float64(info.Size())/1024/1024)
|
||||
|
||||
// Verify it's a valid ISO 9660 using file command
|
||||
cmd := exec.Command("file", isoPath)
|
||||
output, _ := cmd.Output()
|
||||
t.Logf("file type: %s", string(output))
|
||||
|
||||
// Verify contents using isoinfo
|
||||
cmd = exec.Command("isoinfo", "-l", "-i", isoPath)
|
||||
output, _ = cmd.Output()
|
||||
t.Logf("ISO listing:\n%s", string(output))
|
||||
}
|
||||
|
||||
func TestDirSize(t *testing.T) {
|
||||
// Create a temp dir with known file sizes
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a 1KB file
|
||||
f1 := filepath.Join(tmpDir, "file1.bin")
|
||||
os.WriteFile(f1, make([]byte, 1024), 0644)
|
||||
|
||||
// Create a subdirectory with a 2KB file
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
os.Mkdir(subDir, 0755)
|
||||
f2 := filepath.Join(subDir, "file2.bin")
|
||||
os.WriteFile(f2, make([]byte, 2048), 0644)
|
||||
|
||||
size, err := dirSize(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("dirSize failed: %v", err)
|
||||
}
|
||||
|
||||
// Expected: (1024 + 2048) = 3072 + 10% = 3379,
|
||||
// minimum 10MB = 10485760, round up to 2048: 10485760
|
||||
// Since 3072 < 10MB, should be 10485760 (already aligned)
|
||||
if size < 10*1024*1024 {
|
||||
t.Fatalf("expected minimum 10MB, got %d", size)
|
||||
}
|
||||
if size%2048 != 0 {
|
||||
t.Fatalf("expected 2048-aligned, got %d", size)
|
||||
}
|
||||
t.Logf("dirSize(%s) = %d bytes (%.2f MB)", tmpDir, size, float64(size)/1024/1024)
|
||||
}
|
||||
21
internal/middleware/auth.go
Normal file
21
internal/middleware/auth.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// APIKey returns middleware that validates the X-API-Key header
|
||||
// against the expected key. Returns 401 if missing or invalid.
|
||||
func APIKey(expectedKey string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-API-Key") != expectedKey {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
12
internal/middleware/chain.go
Normal file
12
internal/middleware/chain.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Chain applies middlewares to a handler in order.
|
||||
// The first middleware is the outermost wrapper.
|
||||
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
handler = middlewares[i](handler)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
133
internal/repo/patient.go
Normal file
133
internal/repo/patient.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PatientData holds the response from the patient-data API.
|
||||
type PatientData struct {
|
||||
AccessionNumber string `json:"accession_number"`
|
||||
MedrecID string `json:"medrec_id"`
|
||||
RegID string `json:"reg_id"`
|
||||
PatientName string `json:"patient_name"`
|
||||
Modality string `json:"modality"`
|
||||
StudyDescription string `json:"study_description"`
|
||||
}
|
||||
|
||||
// PatientRepo is an HTTP client for the external patient-data API.
|
||||
type PatientRepo struct {
|
||||
baseURL string
|
||||
endpoint string
|
||||
httpClient *http.Client
|
||||
authHeader string
|
||||
authToken string
|
||||
retryCount int
|
||||
retrySleep time.Duration
|
||||
}
|
||||
|
||||
// NewPatientRepo creates a new PatientRepo.
|
||||
func NewPatientRepo(baseURL, endpoint, authHeader, authToken string, timeout time.Duration, retry int, retryBackoff time.Duration) *PatientRepo {
|
||||
return &PatientRepo{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
endpoint: endpoint,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
authHeader: authHeader,
|
||||
authToken: authToken,
|
||||
retryCount: retry,
|
||||
retrySleep: retryBackoff,
|
||||
}
|
||||
}
|
||||
|
||||
// ByAccessionNumber fetches patient data for the given accession number.
|
||||
// Returns (nil, nil) if:
|
||||
// - patient API is not configured (empty base URL)
|
||||
// - accession number is not found (HTTP 404)
|
||||
// This allows callers to gracefully degrade when patient info is unavailable.
|
||||
func (r *PatientRepo) ByAccessionNumber(ctx context.Context, acc string) (*PatientData, error) {
|
||||
if r.baseURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(r.baseURL + r.endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid patient API URL: %w", err)
|
||||
}
|
||||
u.RawQuery = url.Values{"accession_number": {acc}}.Encode()
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= r.retryCount; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(r.retrySleep):
|
||||
}
|
||||
}
|
||||
|
||||
data, err := r.fetchPatientData(ctx, u.String(), acc)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if !isRetryable(err) {
|
||||
return nil, lastErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// fetchPatientData performs a single HTTP request to the patient API.
|
||||
func (r *PatientRepo) fetchPatientData(ctx context.Context, requestURL, acc string) (*PatientData, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
if r.authHeader != "" && r.authToken != "" {
|
||||
req.Header.Set(r.authHeader, r.authToken)
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patient API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
var data PatientData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("decode patient API response: %w", err)
|
||||
}
|
||||
return &data, nil
|
||||
|
||||
case http.StatusNotFound:
|
||||
return nil, nil // not found is not an error — gracefully degrade
|
||||
|
||||
default:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("patient API returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
}
|
||||
|
||||
// isRetryable returns true if the error is a transient network error
|
||||
// that might succeed on retry.
|
||||
func isRetryable(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "timeout") ||
|
||||
strings.Contains(msg, "connection refused") ||
|
||||
strings.Contains(msg, "no such host") ||
|
||||
strings.Contains(msg, "connection reset") ||
|
||||
strings.Contains(msg, "Temporary failure")
|
||||
}
|
||||
122
internal/route/route.go
Normal file
122
internal/route/route.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/internal/handler"
|
||||
"mkiso-server/internal/middleware"
|
||||
"mkiso-server/internal/service"
|
||||
)
|
||||
|
||||
// Setup creates the HTTP handler with all routes wired.
|
||||
// Stage 1: no auth.
|
||||
func Setup(cfg *config.Config, isoSvc *service.ISOService, relaySvc *service.RelayService) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check — always public
|
||||
mux.HandleFunc("GET /api/health", handler.Health(cfg))
|
||||
|
||||
// ISO download endpoints
|
||||
mux.HandleFunc("GET /api/iso/download", handler.DownloadSingle(isoSvc))
|
||||
mux.HandleFunc("GET /api/iso/download-multiple", handler.DownloadMultiple(isoSvc))
|
||||
|
||||
// Print/relay endpoint (single endpoint handles both single and multi via comma detection)
|
||||
mux.HandleFunc("GET /api/iso/print", handler.PrintISO(relaySvc))
|
||||
|
||||
// Wrap with middleware chain: Recovery → RequestID → Logging
|
||||
return middleware.Chain(
|
||||
mux,
|
||||
recoveryMiddleware,
|
||||
requestIDMiddleware,
|
||||
loggingMiddleware,
|
||||
)
|
||||
}
|
||||
|
||||
// SetupSecure creates the HTTP handler with API key authentication on /api/iso/*.
|
||||
// Stage 2: auth enabled.
|
||||
func SetupSecure(cfg *config.Config, isoSvc *service.ISOService, relaySvc *service.RelayService) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check — always public
|
||||
mux.HandleFunc("GET /api/health", handler.Health(cfg))
|
||||
|
||||
// Protected routes
|
||||
apiKeyMw := middleware.APIKey(cfg.Auth.APIKey)
|
||||
mux.Handle("GET /api/iso/download", apiKeyMw(http.HandlerFunc(handler.DownloadSingle(isoSvc))))
|
||||
mux.Handle("GET /api/iso/download-multiple", apiKeyMw(http.HandlerFunc(handler.DownloadMultiple(isoSvc))))
|
||||
mux.Handle("GET /api/iso/print", apiKeyMw(http.HandlerFunc(handler.PrintISO(relaySvc))))
|
||||
|
||||
return middleware.Chain(
|
||||
mux,
|
||||
recoveryMiddleware,
|
||||
requestIDMiddleware,
|
||||
loggingMiddleware,
|
||||
)
|
||||
}
|
||||
|
||||
// recoveryMiddleware catches panics, logs the stack trace, and returns 500.
|
||||
func recoveryMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
slog.Error("handler panic",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"panic", rec,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requestIDMiddleware adds a unique request ID to each request context.
|
||||
func requestIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rid := r.Header.Get("X-Request-ID")
|
||||
if rid == "" {
|
||||
rid = generateRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", rid)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// loggingMiddleware logs each request with method, path, status, and duration.
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(lrw, r)
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"query", r.URL.RawQuery,
|
||||
"status", lrw.statusCode,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// loggingResponseWriter wraps http.ResponseWriter to capture the status code.
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// generateRequestID returns a simple unique request ID.
|
||||
func generateRequestID() string {
|
||||
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||
}
|
||||
222
internal/service/dicom.go
Normal file
222
internal/service/dicom.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// DicomService handles DICOM retrieval from PACS via storescp + movescu.
|
||||
type DicomService struct {
|
||||
cfg *config.Config
|
||||
portAllocMgr *portManager
|
||||
}
|
||||
|
||||
// NewDicomService creates a new DicomService.
|
||||
func NewDicomService(cfg *config.Config) *DicomService {
|
||||
return &DicomService{
|
||||
cfg: cfg,
|
||||
portAllocMgr: newPortManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchDICOM retrieves all DICOM files for a single accession number.
|
||||
// It starts storescp, runs movescu, then stops storescp.
|
||||
// Returns the number of files retrieved, or an error.
|
||||
func (s *DicomService) FetchDICOM(ctx context.Context, accessionNumber, destDir string) (filesCount int, err error) {
|
||||
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
|
||||
if port == 0 {
|
||||
return 0, fmt.Errorf("no available port for storescp")
|
||||
}
|
||||
defer release()
|
||||
|
||||
return s.fetchDICOMWithPort(ctx, accessionNumber, destDir, port)
|
||||
}
|
||||
|
||||
// FetchDICOMMultiple retrieves DICOM files for multiple accession numbers.
|
||||
// It starts storescp ONCE, runs movescu for each accession, then stops storescp.
|
||||
// Returns the total number of files retrieved, or an error.
|
||||
func (s *DicomService) FetchDICOMMultiple(ctx context.Context, accessionNumbers []string, destDir string) (totalFiles int, err error) {
|
||||
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
|
||||
if port == 0 {
|
||||
return 0, fmt.Errorf("no available port for storescp")
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Start storescp once
|
||||
storescpCtx, cancelStorescp := context.WithCancel(ctx)
|
||||
defer cancelStorescp()
|
||||
|
||||
resultCh, stop, err := dicom.StartStoresCP(
|
||||
storescpCtx,
|
||||
s.cfg.DCMTK.Storescp,
|
||||
s.cfg.OurAE.AETitle,
|
||||
port,
|
||||
destDir,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("start storescp: %w", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
// Wait for storescp to be ready
|
||||
if err := waitForStorescpReady(resultCh); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Run movescu for each accession
|
||||
for _, acc := range accessionNumbers {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return totalFiles, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
exitCode, _, stderr, err := dicom.RunMoveSCU(
|
||||
ctx,
|
||||
s.cfg.DCMTK.Movescu,
|
||||
s.cfg.OurAE.AETitle,
|
||||
s.cfg.PACS.AETitle,
|
||||
s.cfg.PACS.Host,
|
||||
s.cfg.PACS.Port,
|
||||
port,
|
||||
acc,
|
||||
300, // 5 min timeout
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("movescu failed for accession",
|
||||
"accession", acc,
|
||||
"exit_code", exitCode,
|
||||
"stderr", stderr,
|
||||
"error", err,
|
||||
)
|
||||
// Continue with next accession — partial success is acceptable
|
||||
continue
|
||||
}
|
||||
slog.Info("movescu completed",
|
||||
"accession", acc,
|
||||
"exit_code", exitCode,
|
||||
)
|
||||
}
|
||||
|
||||
// Count files retrieved
|
||||
filesCount, err := countFiles(destDir)
|
||||
if err != nil {
|
||||
slog.Warn("count files failed", "dir", destDir, "error", err)
|
||||
}
|
||||
|
||||
if filesCount == 0 {
|
||||
return 0, fmt.Errorf("no DICOM data retrieved")
|
||||
}
|
||||
|
||||
return filesCount, nil
|
||||
}
|
||||
|
||||
// fetchDICOMWithPort uses a specific port for storescp + movescu.
|
||||
func (s *DicomService) fetchDICOMWithPort(ctx context.Context, accessionNumber, destDir string, port int) (filesCount int, err error) {
|
||||
storescpCtx, cancelStorescp := context.WithCancel(ctx)
|
||||
defer cancelStorescp()
|
||||
|
||||
resultCh, stop, err := dicom.StartStoresCP(
|
||||
storescpCtx,
|
||||
s.cfg.DCMTK.Storescp,
|
||||
s.cfg.OurAE.AETitle,
|
||||
port,
|
||||
destDir,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("start storescp: %w", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
// Wait for storescp to be ready
|
||||
if err := waitForStorescpReady(resultCh); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Run movescu
|
||||
exitCode, _, stderr, err := dicom.RunMoveSCU(
|
||||
ctx,
|
||||
s.cfg.DCMTK.Movescu,
|
||||
s.cfg.OurAE.AETitle,
|
||||
s.cfg.PACS.AETitle,
|
||||
s.cfg.PACS.Host,
|
||||
s.cfg.PACS.Port,
|
||||
port,
|
||||
accessionNumber,
|
||||
300, // 5 min timeout
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("movescu failed (exit %d): %s (stderr: %s)", exitCode, err, stderr)
|
||||
}
|
||||
|
||||
slog.Info("movescu completed",
|
||||
"accession", accessionNumber,
|
||||
"exit_code", exitCode,
|
||||
)
|
||||
|
||||
// Count files
|
||||
filesCount, err = countFiles(destDir)
|
||||
if err != nil {
|
||||
slog.Warn("count files failed", "dir", destDir, "error", err)
|
||||
}
|
||||
|
||||
if filesCount == 0 {
|
||||
return 0, fmt.Errorf("no DICOM data for accession %q", accessionNumber)
|
||||
}
|
||||
|
||||
return filesCount, nil
|
||||
}
|
||||
|
||||
// waitForStorescpReady waits for storescp to start or detects early failure.
|
||||
func waitForStorescpReady(resultCh <-chan dicom.StorescpResult) error {
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
return fmt.Errorf("storescp exited prematurely (pid %d, err: %v, stderr: %s)",
|
||||
result.PID, result.Err, result.Stderr)
|
||||
case <-time.After(2 * time.Second):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// countFiles counts regular files in a directory (non-recursive).
|
||||
func countFiles(dir string) (int, error) {
|
||||
return dicom.CountFiles(dir)
|
||||
}
|
||||
|
||||
// portManager manages a pool of allocated ports with mutex safety.
|
||||
type portManager struct {
|
||||
mu sync.Mutex
|
||||
portsInUse map[int]bool
|
||||
}
|
||||
|
||||
func newPortManager() *portManager {
|
||||
return &portManager{
|
||||
portsInUse: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// allocate picks a free port from [base, base+size). Returns 0 if none available.
|
||||
func (pm *portManager) allocate(base, size int) (int, func()) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
port := base + i
|
||||
if !pm.portsInUse[port] {
|
||||
pm.portsInUse[port] = true
|
||||
return port, func() {
|
||||
pm.mu.Lock()
|
||||
delete(pm.portsInUse, port)
|
||||
pm.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
185
internal/service/iso.go
Normal file
185
internal/service/iso.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/internal/isobuilder"
|
||||
"mkiso-server/internal/repo"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// ISOService handles ISO creation from DICOM data.
|
||||
type ISOService struct {
|
||||
cfg *config.Config
|
||||
dicomSvc *DicomService
|
||||
patientRepo *repo.PatientRepo
|
||||
}
|
||||
|
||||
// NewISOService creates a new ISOService.
|
||||
func NewISOService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *ISOService {
|
||||
return &ISOService{
|
||||
cfg: cfg,
|
||||
dicomSvc: dicomSvc,
|
||||
patientRepo: patientRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// ISOItem is the result of ISO generation.
|
||||
type ISOItem struct {
|
||||
Path string // Full path to the generated ISO file
|
||||
Cleanup func() // Call to remove temp files
|
||||
Filename string // Suggested download filename
|
||||
}
|
||||
|
||||
// GenerateISO creates an ISO for a single accession number.
|
||||
func (s *ISOService) GenerateISO(ctx context.Context, accessionNumber string) (*ISOItem, error) {
|
||||
// Create temp working directory
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(tempDir) }
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files
|
||||
if err := s.copyMicrodicom(tempDir); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("copy microdicom: %w", err)
|
||||
}
|
||||
|
||||
// Fetch DICOM from PACS
|
||||
_, err = s.dicomSvc.FetchDICOM(ctx, accessionNumber, dicomDir)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate ISO
|
||||
isoName := sanitizeFilename(accessionNumber) + ".iso"
|
||||
isoPath := filepath.Join(s.cfg.ISO.TempDir, isoName)
|
||||
|
||||
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
|
||||
cleanup()
|
||||
os.Remove(isoPath)
|
||||
return nil, fmt.Errorf("ISO creation failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("ISO created",
|
||||
"path", isoPath,
|
||||
"accession", accessionNumber,
|
||||
)
|
||||
|
||||
return &ISOItem{
|
||||
Path: isoPath,
|
||||
Cleanup: func() { cleanup(); os.Remove(isoPath) },
|
||||
Filename: isoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateISOMultiple creates an ISO for multiple accession numbers.
|
||||
// It tries to get the patient name from the patient API for a descriptive filename.
|
||||
// If the patient API is unavailable, it falls back to the accession list as filename.
|
||||
func (s *ISOService) GenerateISOMultiple(ctx context.Context, accessionNumbers []string) (*ISOItem, error) {
|
||||
// Create temp working directory
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(tempDir) }
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files
|
||||
if err := s.copyMicrodicom(tempDir); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("copy microdicom: %w", err)
|
||||
}
|
||||
|
||||
// Fetch DICOM from PACS
|
||||
_, err = s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Determine filename: try patient API, fall back to accession list
|
||||
filename := buildMultiFilename(accessionNumbers)
|
||||
|
||||
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
|
||||
if err != nil {
|
||||
slog.Warn("patient API lookup failed, using accession-based filename",
|
||||
"accession", accessionNumbers[0],
|
||||
"error", err,
|
||||
)
|
||||
} else if patient != nil && patient.PatientName != "" {
|
||||
name := sanitizeFilename(patient.PatientName)
|
||||
accPart := sanitizeFilename(accessionList(accessionNumbers))
|
||||
filename = fmt.Sprintf("%s-%s.iso", name, accPart)
|
||||
slog.Info("using patient name for ISO filename",
|
||||
"patient", patient.PatientName,
|
||||
"filename", filename,
|
||||
)
|
||||
}
|
||||
|
||||
isoPath := filepath.Join(s.cfg.ISO.TempDir, filename)
|
||||
|
||||
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
|
||||
cleanup()
|
||||
os.Remove(isoPath)
|
||||
return nil, fmt.Errorf("ISO creation failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("ISO created (multiple)",
|
||||
"path", isoPath,
|
||||
"accessions", accessionNumbers,
|
||||
)
|
||||
|
||||
return &ISOItem{
|
||||
Path: isoPath,
|
||||
Cleanup: func() { cleanup(); os.Remove(isoPath) },
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// copyMicrodicom copies the microdicom viewer files into the temp directory.
|
||||
func (s *ISOService) copyMicrodicom(destDir string) error {
|
||||
if !dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
|
||||
slog.Warn("microdicom path does not exist, skipping copy",
|
||||
"path", s.cfg.ISO.MicrodicomPath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return dicom.CopyDir(s.cfg.ISO.MicrodicomPath, destDir)
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters not safe for filenames.
|
||||
func sanitizeFilename(s string) string {
|
||||
reg := regexp.MustCompile(`[^a-zA-Z0-9\-\.]+`)
|
||||
return strings.TrimRight(reg.ReplaceAllString(strings.ToUpper(s), ""), ".-")
|
||||
}
|
||||
|
||||
// accessionList joins accession numbers with "-".
|
||||
func accessionList(accs []string) string {
|
||||
return strings.Join(accs, "-")
|
||||
}
|
||||
|
||||
// buildMultiFilename creates a default filename from accession numbers.
|
||||
func buildMultiFilename(accs []string) string {
|
||||
return sanitizeFilename(accessionList(accs)) + ".iso"
|
||||
}
|
||||
120
internal/service/relay.go
Normal file
120
internal/service/relay.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/internal/repo"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// RelayService handles DICOM relay (print) to the CD Publisher.
|
||||
type RelayService struct {
|
||||
cfg *config.Config
|
||||
dicomSvc *DicomService
|
||||
patientRepo *repo.PatientRepo
|
||||
}
|
||||
|
||||
// NewRelayService creates a new RelayService.
|
||||
func NewRelayService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *RelayService {
|
||||
return &RelayService{
|
||||
cfg: cfg,
|
||||
dicomSvc: dicomSvc,
|
||||
patientRepo: patientRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Destination returns the CD Publisher address as "host:port".
|
||||
func (s *RelayService) Destination() string {
|
||||
return fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
|
||||
}
|
||||
|
||||
// RelayResult describes the outcome of a DICOM relay operation.
|
||||
type RelayResult struct {
|
||||
AccessionsSent []string `json:"accessions_sent"`
|
||||
PatientName string `json:"patient_name"`
|
||||
Destination string `json:"destination"`
|
||||
FilesSent int `json:"files_sent"`
|
||||
}
|
||||
|
||||
// RelayToCDPublisher fetches DICOM studies from PACS and forwards them
|
||||
// to the CD Publisher via storescu (C-STORE).
|
||||
func (s *RelayService) RelayToCDPublisher(ctx context.Context, accessionNumbers []string) (*RelayResult, error) {
|
||||
// Step 1: Try to get patient data from API (graceful if unavailable)
|
||||
var patientName string
|
||||
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
|
||||
if err != nil {
|
||||
slog.Warn("patient API lookup failed for relay",
|
||||
"accession", accessionNumbers[0],
|
||||
"error", err,
|
||||
)
|
||||
} else if patient != nil {
|
||||
patientName = patient.PatientName
|
||||
slog.Info("patient data retrieved for relay",
|
||||
"accession", accessionNumbers[0],
|
||||
"patient", patientName,
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Create temp dir
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // guaranteed cleanup
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files (optional — CD Publisher doesn't need it,
|
||||
// but matches PHP behavior of copying before fetch)
|
||||
if dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
|
||||
if err := dicom.CopyDir(s.cfg.ISO.MicrodicomPath, tempDir); err != nil {
|
||||
slog.Warn("copy microdicom for relay failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Fetch DICOM from PACS
|
||||
filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
slog.Info("DICOM fetched for relay",
|
||||
"accessions", accessionNumbers,
|
||||
"files", filesRetrieved,
|
||||
)
|
||||
|
||||
// Step 4: Relay to CD Publisher via storescu
|
||||
destination := fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
|
||||
exitCode, stdout, stderr, err := dicom.RunStoreSCU(
|
||||
ctx,
|
||||
s.cfg.DCMTK.Storescu,
|
||||
s.cfg.OurAE.AETitle,
|
||||
s.cfg.CDPublisher.Host,
|
||||
s.cfg.CDPublisher.Port,
|
||||
dicomDir,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storescu relay failed (exit %d): %w (stderr: %s)", exitCode, err, stderr)
|
||||
}
|
||||
_ = stdout
|
||||
|
||||
slog.Info("DICOM relayed to CD Publisher",
|
||||
"accessions", accessionNumbers,
|
||||
"destination", destination,
|
||||
"files", filesRetrieved,
|
||||
)
|
||||
|
||||
return &RelayResult{
|
||||
AccessionsSent: accessionNumbers,
|
||||
PatientName: patientName,
|
||||
Destination: destination,
|
||||
FilesSent: filesRetrieved,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user