feat: base go mkiso

This commit is contained in:
2026-06-05 08:11:44 +07:00
commit 983667a76a
63 changed files with 5322 additions and 0 deletions

173
internal/config/config.go Normal file
View 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
}

View 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
View 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
View 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,
})
}
}

View 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
}

View 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)
}

View 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)
})
}
}

View 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
View 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
View 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
View 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
View 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
View 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
}