# Go mkiso Server — Architecture & Design

## Overview

A Go HTTP server (stdlib only, no frameworks) that replaces the PHP mkiso scripts. It handles DICOM retrieval from PACS (via dcmtk CLI), ISO creation (via genisoimage), and streaming download — all behind JWT authentication.

---

## Project Structure

```
mkiso-server/
├── config.yaml              # Configuration (PACS, AE, binaries, JWT)
├── config.example.yaml      # Template config without secrets
├── go.mod
├── main.go                  # Entrypoint: load config, wire deps, start server
├── internal/
│   ├── config/
│   │   └── config.go        # Config struct, YAML loading
│   ├── route/
│   │   └── route.go         # Route registration, middleware chain
│   ├── middleware/
│   │   └── auth.go          # JWT middleware (slog, context injection)
│   ├── handler/
│   │   ├── auth.go          # POST /api/auth/login
│   │   ├── iso.go           # ISO download handlers
│   │   ├── print.go         # Print ISO (stub)
│   │   └── health.go        # GET /api/health
│   ├── service/
│   │   ├── dicom.go         # DICOM operations (storescp + movescu orchestration)
│   │   ├── iso.go           # ISO creation + cleanup
│   │   └── auth.go          # JWT generation + validation
│   └── repo/
│       └── patient.go       # HTTP client to patient-data API
├── pkg/
│   └── dicom/
│       └── command.go       # Low-level dcmtk binary execution wrapper
└── testdata/
    └── ...                  # Test fixtures
```

---

## External Dependencies

| Binary | Package | Config Key |
|--------|---------|------------|
| `storescp` | dcmtk | `dcmtk.storescp` |
| `movescu` | dcmtk | `dcmtk.movescu` |
| `genisoimage` | `genisoimage` (apt) | `tools.genisoimage` |

**All binary paths are in `config.yaml`.** No hardcoded paths.

---

## config.yaml Design

```yaml
server:
  port: 8080
  read_timeout: 300s       # ISO generation can take minutes
  write_timeout: 600s

auth:
  enabled: false           # Stage 1: false (no auth). Stage 2: true
  api_key: ""              # Stage 2: shared key, checked via X-API-Key header

dcmtk:
  storescp: "/usr/local/bin/storescp"
  movescu:  "/usr/local/bin/movescu"
  storescu: "/usr/local/bin/storescu"

tools:
  genisoimage: "/usr/bin/genisoimage"

pacs:
  ae_title: "ABPACS"
  host: "localhost"
  port: 11112

our_ae:
  ae_title: "CDRECORD"
  base_port: 10104          # storescp listen port range start
  port_range: 100           # 10104-10203 (100 unique ports for concurrent requests)

patient_api:
  base_url: "http://his-server/api"  # External patient data API
  timeout: 10s

cd_publisher:
  host: "172.16.0.120"    # CD Publisher server (for print/relay)
  port: 104               # DICOM port on CD Publisher

iso:
  microdicom_path: "/var/www/html/microdicom"  # DICOM viewer bootstrap files
  temp_dir: "/tmp"                              # Working directory for dicomdir_*
```

---

## API Endpoints

### Stage 1 — All public (auth.enabled = false)

| Method | Path | Description | Replaces |
|--------|------|-------------|----------|
| `GET` | `/api/health` | Health check + dependency status | — |
| `GET` | `/api/iso/download?accession_number=X` | Single accession → ISO download | `mkiso.php` |
| `GET` | `/api/iso/download-multiple?accession_numbers=X,Y,Z` | Multi-accession → ISO download with patient-name filename | `mkiso_multiple.php` |
| `GET` | `/api/iso/print?accession_number=X` | DICOM relay → CD Publisher (auto-detects comma → multi) | `send_rimage_multiple.php` |
| `GET` | `/api/iso/print?accession_number=X,Y,Z` | (same endpoint — comma triggers multi) | `send_rimage_multiple.php` |

> **Note:** `print` and `print-multiple` are merged into one endpoint. The handler checks for commas in `accession_number` to decide single vs multi mode. This matches the PHP behavior where `send_rimage_multiple.php` handles both via `explode(",", ...)`.

### Stage 2 — Authenticated (auth.enabled = true)

Same endpoints, but `/api/iso/*` requires `X-API-Key` header. `/api/health` stays public.

```
GET /api/iso/download?accession_number=X
X-API-Key: <shared-key-from-config>

GET /api/iso/print?accession_number=X,Y,Z
X-API-Key: <shared-key-from-config>
```

---

## Authentication (Stage 2)

### Recommended: API Key via nginx proxy injection

**Why:** Zero changes to the HIS `pacs_downloadiso` module. The existing `window.open()` calls continue to work because nginx injects the auth header before proxying to Go.

```
Browser (HIS)                    nginx (PACS server)              Go Server
────────────                     ───────────────────              ─────────
window.open("/mkiso.php?..")
  ────── GET ──────→  add X-API-Key header
                       ───── GET + key ──────→  validate key → 200 OK
                       ←─── ISO stream ───────
  ←── ISO download ──
```

**Config:**
```yaml
auth:
  enabled: true           # Stage 1: false, Stage 2: true
  api_key: "changeme"     # Shared key — keep secret
```

**Middleware (~10 lines, stdlib only):**
```go
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)
        })
    }
}
```

**nginx reverse proxy config:**
```nginx
# Map old PHP paths to Go API + inject auth
location = /mkiso.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";
    proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args;
}
location = /mkiso_multiple.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";
    proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args;
}
location = /send_rimage_multiple.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";
    proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args;
}
```

**Route wiring (Stage 2):**
```go
mux.HandleFunc("GET /api/health", handler.Health(cfg))  // always public

apiKey := middleware.APIKey(cfg.Auth.APIKey)
mux.Handle("GET /api/iso/download", apiKey(handler.DownloadSingle(isoSvc)))
mux.Handle("GET /api/iso/download-multiple", apiKey(handler.DownloadMultiple(isoSvc)))
// ... same for print endpoints
```

---

## DICOM Flow (Go — replaces Java dcmqr)

### Single Accession (`/api/iso/download`)

```
1. handler.iso.go: DownloadSingle()
   ├─ Parse accession_number from query
   ├─ Create temp dir: /tmp/dicomdir_<uuid>/
   ├─ service.iso.go: GenerateISO()
   │  ├─ service.dicom.go: FetchDICOM()
   │  │  ├─ pkg/dicom: StartStoresCP()    → storescp as child process
   │  │  │   storescp <port> -aet CDRECORD -od <dir> +xa
   │  │  ├─ Wait for storescp ready (poll / retry)
   │  │  ├─ pkg/dicom: RunMoveSCU()       → movescu as child process
   │  │  │   movescu -aet CDRECORD -aec ABPACS -aem CDRECORD
   │  │  │     localhost 11112 -S -k 0008,0050=<acc>
   │  │  │     -k 0010,0020= --no-port
   │  │  ├─ Wait for movescu (with timeout)
   │  │  └─ pkg/dicom: StopStoresCP()     → SIGTERM
   │  ├─ Copy microdicom viewer files into temp dir
   │  ├─ Run genisoimage
   │  │   genisoimage -iso-level 4 -r -V DICOM -o <acc>.iso <dir>
   │  └─ Return ISO path
   ├─ Stream ISO to response (Content-Type: application/octet-stream)
   ├─ Cleanup temp dir
   └─ Return
```

### Multiple Accessions (`/api/iso/download-multiple`)

Same as single but:
1. Start `storescp` ONCE for all accessions
2. Run `movescu` in a loop for each accession number
3. Call **patient API** to get `Nama` for filename
4. ISO filename: `{PATIENT_NAME}-{ACC_LIST}.iso`
5. Cleanup after everything

### Print / DICOM Relay (`/api/iso/print`, `/api/iso/print-multiple`)

Replaces `send_rimage_multiple.php`. Same DICOM fetch as download, then C-STORE relay instead of ISO. Uses patient API for validation (same as download-multiple).

```
1. handler.print.go: PrintISO()
   ├─ Parse accession_number(s) from query
   ├─ Call patient API → validate accession, get patient_name
   ├─ Create temp dir: /tmp/dicomdir_<uuid>/
   ├─ service.relay.go: RelayToCDPublisher()
   │  ├─ Copy microdicom viewer files into temp dir
   │  ├─ service.dicom.go: FetchDICOM()  → storescp + movescu loop
   │  ├─ pkg/dicom: RunStoreSCU()        → storescu to CD Publisher
   │  │   storescu -aet <our_ae> +sd +r <cd_host> <cd_port> <dir>
   │  ├─ Count files sent
   │  └─ Return relay result with patient_name
   ├─ Respond JSON: { status, accessions_sent, patient_name, destination, files_sent }
   ├─ Cleanup temp dir
   └─ Return
```

**Key difference from download:** No genisoimage, no streaming. Instead `storescu +sd +r` (scan directory + recurse) sends all DICOM files to the CD Publisher server. Response is JSON (success/failure), not an ISO file. Patient API is used for accession validation, not just filename.

### Concurrent Safety

```
Port allocation: base_port + hash(request_id) % port_range
Temp dir:        /tmp/dicomdir_<uuid>/
```

Multiple incoming requests each get their own `storescp` port and temp directory. No collisions.

---

## Patient Data API Specification

The Go server does NOT access the database directly. It calls an external API for patient data. This is defined in `docs/patient-api-spec.md`.

### Endpoint: `GET /patient/by-accession`

```
Request:
  GET {patient_api.base_url}/patient/by-accession?accession_number=MR.2024.001
  Authorization: Bearer <service-token>   (or API key header)

Response 200:
  {
    "accession_number": "MR.2024.001",
    "medrec_id": "00012345",
    "reg_id": "REG-2024-00123",
    "patient_name": "JOHN DOE",
    "modality": "MR",
    "study_description": "MRI Brain"
  }

Response 404:
  { "error": "accession_number not found" }
```

This API must be provided by the HIS team (or deploy a thin proxy). The Go server calls it with HTTP GET.

---

## Error Handling

| Scenario | HTTP Status | Response |
|----------|-------------|----------|
| Invalid/missing accession_number | `400` | `{"error": "invalid accession_number"}` |
| PACS unreachable | `502` | `{"error": "PACS unreachable", "detail": "..."}` |
| movescu failed (accession not found) | `404` | `{"error": "no DICOM data for accession_number"}` |
| Patient API unavailable | `502` | `{"error": "patient API unavailable"}` |
| genisoimage failed | `500` | `{"error": "ISO creation failed"}` |
| Print ISO — CD Publisher unreachable | `502` | `{"error": "CD Publisher unreachable", "destination": "..."}` |
| Concurrent port exhaustion | `503` | `{"error": "too many concurrent requests"}` |
| Timeout (C-MOVE) | `504` | `{"error": "PACS move timed out"}` |

**Stage 2 additions:**

| Scenario | HTTP Status | Response |
|----------|-------------|----------|
| Missing/invalid API key | `401` | `{"error": "unauthorized"}` |

---

## Logging (slog)

All components use `log/slog`. Key log points:

```go
slog.Info("starting storescp", "port", port, "dir", dir, "pid", pid)
slog.Info("running movescu", "accession", acc, "pacs", pacs)
slog.Info("iso created", "path", isoPath, "size_bytes", size, "duration_ms", d)
slog.Error("movescu failed", "accession", acc, "exit_code", code, "stderr", err)
slog.Warn("patient API slow", "latency_ms", lat)
```

---

## Route Wiring (stdlib net/http)

```go
// internal/route/route.go

// Stage 1 — no auth
func Setup(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /api/health", handler.Health(cfg))
    mux.HandleFunc("GET /api/iso/download", handler.DownloadSingle(isoSvc))
    mux.HandleFunc("GET /api/iso/download-multiple", handler.DownloadMultiple(isoSvc))
    mux.HandleFunc("GET /api/iso/print", handler.PrintISO(relaySvc))  // auto-detects single vs multi

    return middleware.Recovery(middleware.RequestID(mux))
}

// Stage 2 — API key auth on /api/iso/*
func SetupSecure(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /api/health", handler.Health(cfg))  // always public

    apiKey := middleware.APIKey(cfg.Auth.APIKey)
    mux.Handle("GET /api/iso/download", apiKey(http.HandlerFunc(handler.DownloadSingle(isoSvc))))
    mux.Handle("GET /api/iso/download-multiple", apiKey(http.HandlerFunc(handler.DownloadMultiple(isoSvc))))
    mux.Handle("GET /api/iso/print", apiKey(http.HandlerFunc(handler.PrintISO(relaySvc))))

    return middleware.Recovery(middleware.RequestID(mux))
}
```

---

## Repository Pattern Boundaries

```
┌─────────────────────────────────────────────────────────┐
│  handler/          HTTP layer                           │
│    Parse request, call service, write response          │
│    No business logic                                    │
├─────────────────────────────────────────────────────────┤
│  service/          Business logic                       │
│    Orchestrate DICOM fetch + ISO creation + cleanup     │
│    Calls repo for external data, pkg/dicom for commands │
│    Uses slog for logging                                │
├─────────────────────────────────────────────────────────┤
│  repo/             External data access                  │
│    patient.go: HTTP client to patient-data API          │
│    Uses net/http with timeout + retry                   │
├─────────────────────────────────────────────────────────┤
│  pkg/dicom/        DICOM command execution              │
│    Low-level: spawn storescp/movescu, monitor, kill     │
│    Returns (filesRetrieved int, error)                  │
│    Pure os/exec, no business logic                      │
└─────────────────────────────────────────────────────────┘
```

---

## Replacement Mapping

| Old (PHP on PACS server) | New (Go on PACS server) |
|--------------------------|-------------------------|
| `mkiso.php?accession_number=X` | `GET /api/iso/download?accession_number=X` |
| `mkiso_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/download-multiple?accession_numbers=X,Y,Z` |
| `send_rimage_multiple.php?accession_number=X` | `GET /api/iso/print?accession_number=X` |
| `send_rimage_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/print?accession_number=X,Y,Z` (same endpoint, comma auto-detect) |
| Direct DB: `pacsdb_his` → `pacs_result_series`, `medrec` | HTTP: patient API |

### HIS Module Impact

The `pacs_downloadiso` module JS opens `window.open("http://{PACS_HOST}/mkiso.php?accession_number=X")`.

**Recommended: nginx reverse proxy** — zero HIS code changes, works in both stages:

```nginx
# Map old PHP paths to new Go API paths

# mkiso.php → /api/iso/download
location = /mkiso.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";   # Stage 2 only
    proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args;
}

# mkiso_multiple.php → /api/iso/download-multiple
location = /mkiso_multiple.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";
    proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args;
}

# send_rimage_multiple.php → /api/iso/print
# (single handler auto-detects commas for multi mode)
location = /send_rimage_multiple.php {
    proxy_set_header X-API-Key "${MKISO_API_KEY}";
    proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args;
}
```
