feat: base go mkiso
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Configuration with secrets
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# ISO output files
|
||||||
|
*.iso
|
||||||
|
|
||||||
|
# Temp directories
|
||||||
|
/tmp/dicomdir_*
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
mkiso-server
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
44
config.example.yaml
Normal file
44
config.example.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# mkiso-server configuration
|
||||||
|
# Copy to config.yaml and adjust for your environment
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
read_timeout: 300s # ISO generation can take minutes
|
||||||
|
write_timeout: 600s
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: false # Stage 1: no auth. Stage 2: set to true
|
||||||
|
api_key: "" # Stage 2: shared API key
|
||||||
|
|
||||||
|
dcmtk:
|
||||||
|
storescp: "/data/dcmtk-bin/storescp"
|
||||||
|
movescu: "/data/dcmtk-bin/movescu"
|
||||||
|
storescu: "/data/dcmtk-bin/storescu"
|
||||||
|
|
||||||
|
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"
|
||||||
|
endpoint: "/patient/by-accession"
|
||||||
|
auth_type: "api_key" # "jwt", "api_key", or "none"
|
||||||
|
auth_header: "X-API-Key"
|
||||||
|
auth_token: ""
|
||||||
|
timeout: 10s
|
||||||
|
retry: 3
|
||||||
|
retry_backoff: 500ms
|
||||||
|
|
||||||
|
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"
|
||||||
|
temp_dir: "/tmp"
|
||||||
430
docs/go-mkiso-design.md
Normal file
430
docs/go-mkiso-design.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
180
docs/howto-manual-iso.md
Normal file
180
docs/howto-manual-iso.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Manual ISO Creation — HOWTO
|
||||||
|
|
||||||
|
Create a bootable DICOM viewer ISO with patient images, using `genisoimage` (or `mkisofs` / `xorriso`).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Tool | Check | Install (Arch) |
|
||||||
|
|------|-------|----------------|
|
||||||
|
| `genisoimage` | `which genisoimage` | `pacman -S cdrtools` + symlink, or `pacman -S libisoburn` (xorriso) |
|
||||||
|
| Raw viewer files | `raw/microdicom/` | Part of this project |
|
||||||
|
| DICOM files | From PACS or unzipped archive | e.g. `unzip pasien.zip` |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Prepare working directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/build_iso/xcdrom/DICOMDIR
|
||||||
|
```
|
||||||
|
|
||||||
|
Directory structure yang akan jadi root ISO:
|
||||||
|
|
||||||
|
```
|
||||||
|
/tmp/build_iso/xcdrom/ ← root ISO
|
||||||
|
├── AUTORUN.INF
|
||||||
|
├── INDEX.PHP
|
||||||
|
├── README.TXT
|
||||||
|
├── RUN.BAT
|
||||||
|
├── MICROD/ ← DICOM viewer
|
||||||
|
└── DICOMDIR/ ← file DICOM pasien
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copy microdicom viewer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r /path/to/raw/microdicom/* /tmp/build_iso/xcdrom/
|
||||||
|
```
|
||||||
|
|
||||||
|
Hasil:
|
||||||
|
```
|
||||||
|
/tmp/build_iso/xcdrom/AUTORUN.INF
|
||||||
|
/tmp/build_iso/xcdrom/INDEX.PHP
|
||||||
|
/tmp/build_iso/xcdrom/README.TXT
|
||||||
|
/tmp/build_iso/xcdrom/RUN.BAT
|
||||||
|
/tmp/build_iso/xcdrom/MICROD/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Copy DICOM files pasien
|
||||||
|
|
||||||
|
**Flat** (semua file langsung di DICOMDIR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /path/to/dicom/files/* /tmp/build_iso/xcdrom/DICOMDIR/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dari unzip dengan subfolder** (misal hasil unzip `EF76D893/...`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find EF76D893 -type f -exec cp {} /tmp/build_iso/xcdrom/DICOMDIR/ \;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verifikasi**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find /tmp/build_iso/xcdrom -type f | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build ISO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
genisoimage \
|
||||||
|
-iso-level 4 \
|
||||||
|
-r \
|
||||||
|
-allow-multidot \
|
||||||
|
-allow-lowercase \
|
||||||
|
-allow-leading-dots \
|
||||||
|
-V DICOM \
|
||||||
|
-o /tmp/output.iso \
|
||||||
|
/tmp/build_iso/xcdrom
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Fungsi |
|
||||||
|
|------|--------|
|
||||||
|
| `-iso-level 4` | Mengizinkan direktori >8 level |
|
||||||
|
| `-r` | Rock Ridge (long filename, Unix permissions) |
|
||||||
|
| `-allow-multidot` | Mengizinkan multiple dots dalam filename |
|
||||||
|
| `-allow-lowercase` | Lowercase filename |
|
||||||
|
| `-allow-leading-dots` | File dimulai dengan titik (.) |
|
||||||
|
| `-V DICOM` | Volume label = `DICOM` |
|
||||||
|
| `-o output.iso` | File ISO tujuan |
|
||||||
|
|
||||||
|
### 5. Verify ISO
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cek ukuran
|
||||||
|
ls -lh /tmp/output.iso
|
||||||
|
|
||||||
|
# Cek struktur isi
|
||||||
|
isoinfo -l -i /tmp/output.iso
|
||||||
|
|
||||||
|
# Cek format
|
||||||
|
file /tmp/output.iso
|
||||||
|
# Output: ISO 9660 CD-ROM filesystem data 'DICOM'
|
||||||
|
|
||||||
|
# Mount & inspect (optional)
|
||||||
|
mkdir -p /tmp/mnt_iso
|
||||||
|
sudo mount -o loop /tmp/output.iso /tmp/mnt_iso
|
||||||
|
find /tmp/mnt_iso -type f -ls
|
||||||
|
sudo umount /tmp/mnt_iso
|
||||||
|
rmdir /tmp/mnt_iso
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shortcut (one-liner)
|
||||||
|
|
||||||
|
Gabung semua step dalam satu script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
ISO_NAME="${1:-output}"
|
||||||
|
SRC_DIR="/tmp/build_iso/xcdrom"
|
||||||
|
ISO_FILE="/tmp/${ISO_NAME}.iso"
|
||||||
|
|
||||||
|
mkdir -p "$SRC_DIR/DICOMDIR"
|
||||||
|
cp -r /path/to/raw/microdicom/* "$SRC_DIR/"
|
||||||
|
cp /path/to/dicom/files/* "$SRC_DIR/DICOMDIR/"
|
||||||
|
|
||||||
|
genisoimage \
|
||||||
|
-iso-level 4 -r \
|
||||||
|
-allow-multidot -allow-lowercase -allow-leading-dots \
|
||||||
|
-V DICOM \
|
||||||
|
-o "$ISO_FILE" \
|
||||||
|
"$SRC_DIR"
|
||||||
|
|
||||||
|
echo "ISO created: $ISO_FILE ($(du -h "$ISO_FILE" | cut -f1))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: xorriso
|
||||||
|
|
||||||
|
If you have `xorriso` instead of `genisoimage`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xorriso -as mkisofs \
|
||||||
|
-iso-level 4 \
|
||||||
|
-r \
|
||||||
|
-V DICOM \
|
||||||
|
-o /tmp/output.iso \
|
||||||
|
/tmp/build_iso/xcdrom
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Structure Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
output.iso
|
||||||
|
└── xcdrom/ ← root ISO
|
||||||
|
├── AUTORUN.INF ← auto-play CD (Windows)
|
||||||
|
├── INDEX.PHP ← halaman web viewer
|
||||||
|
├── README.TXT ← petunjuk penggunaan
|
||||||
|
├── RUN.BAT ← batch file untuk jalanin viewer
|
||||||
|
├── MICROD/ ← MicroDicom executable viewer
|
||||||
|
│ ├── MDICOM.EXE ← main executable
|
||||||
|
│ ├── MDICOM.CHM ← help file
|
||||||
|
│ ├── MFC120U.DLL ← Visual C++ runtime
|
||||||
|
│ ├── MSVCP120.DLL ← Visual C++ runtime
|
||||||
|
│ ├── MSVCR120.DLL ← Visual C++ runtime
|
||||||
|
│ ├── INDEX.PHP
|
||||||
|
│ └── SETTINGS/ ← konfigurasi viewer
|
||||||
|
│ ├── APPLICAT.XML
|
||||||
|
│ ├── ANIMATIO.XML
|
||||||
|
│ ├── ANNOTATI.XML
|
||||||
|
│ ├── EXPORTDI.XML
|
||||||
|
│ ├── EXPORTIM.XML
|
||||||
|
│ ├── EXPORTVI.XML
|
||||||
|
│ ├── OVERLAY.XML
|
||||||
|
│ ├── OVERLAY_.XML
|
||||||
|
│ ├── PRINT.XML
|
||||||
|
│ └── WINDOWLE.XML
|
||||||
|
└── DICOMDIR/ ← file DICOM pasien
|
||||||
|
├── 26811B9D ← contoh file DICOM
|
||||||
|
└── 601182D5 ← contoh file DICOM
|
||||||
|
```
|
||||||
126
docs/mkiso-analysis.md
Normal file
126
docs/mkiso-analysis.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# mkiso PHP Scripts — Analysis
|
||||||
|
|
||||||
|
## File Inventory
|
||||||
|
|
||||||
|
| File | Purpose | Output |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `mkiso.php` | Retrieve DICOM studies by accession number from PACS, wrap in ISO with microdicom viewer, serve as download | ISO download |
|
||||||
|
| `mkiso2.php` | Retrieve DICOM studies from PACS, then forward to another PACS (C-STORE relay) | DICOM forwarded to `172.16.0.120:104` |
|
||||||
|
| `mkiso_multiple.php` | Multi-accession variant of mkiso.php, with patient-name lookup in DB, ISO download | ISO download with patient-name filename |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Flow (all three scripts)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Parse accession_number from GET parameter
|
||||||
|
2. Create temp working dir: /tmp/dicomdir_<uniqid>/
|
||||||
|
3. Copy microdicom viewer files into temp dir (bootstrap DICOM viewer ISO)
|
||||||
|
4. Fetch DICOM studies from PACS via Java dcm4che2 dcmqr (C-MOVE)
|
||||||
|
5. [PACS is: ABPACS@localhost:11112, AE CDRECORD:10104 is the local receiver]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Java Command — Deep Dive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 \
|
||||||
|
/usr/local/dcm4che/dcm4che2/bin/dcmqr \
|
||||||
|
-L CDRECORD:10104 \
|
||||||
|
ABPACS@localhost:11112 \
|
||||||
|
-cmove CDRECORD \
|
||||||
|
-qAccessionNumber=${accession_number} \
|
||||||
|
-cstore ${cstore} \
|
||||||
|
-cstoredest ${dicomdir}/DICOMDIR
|
||||||
|
```
|
||||||
|
|
||||||
|
**This single Java process does FOUR things:**
|
||||||
|
|
||||||
|
| Role | Parameter | Description |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| **Storage SCP** | `-L CDRECORD:10104` | Listens as AE `CDRECORD` on port `10104` to receive incoming C-STORE from PACS |
|
||||||
|
| **Query SCU** | `-qAccessionNumber=X` | Queries PACS for studies matching the accession number |
|
||||||
|
| **C-MOVE SCU** | `-cmove CDRECORD` | Tells PACS to send matched studies to `CDRECORD` (itself) |
|
||||||
|
| **File writer** | `-cstoredest DICOMDIR` | Saves received DICOM files to the specified directory |
|
||||||
|
|
||||||
|
**The `-cstore` parameter** specifies a DICOM SOP Class to negotiate. dcm4che2 maps the two-letter modality codes to SOP Class UIDs internally:
|
||||||
|
|
||||||
|
| Code | SOP Class | UID |
|
||||||
|
|------|-----------|-----|
|
||||||
|
| CR | Computed Radiography | 1.2.840.10008.5.1.4.1.1.1 |
|
||||||
|
| CT | CT Image Storage | 1.2.840.10008.5.1.4.1.1.2 |
|
||||||
|
| MR | MR Image Storage | 1.2.840.10008.5.1.4.1.1.4 |
|
||||||
|
| US | Ultrasound Image | 1.2.840.10008.5.1.4.1.1.6.1 |
|
||||||
|
| NM | Nuclear Medicine | 1.2.840.10008.5.1.4.1.1.20 |
|
||||||
|
| PET | PET Image | 1.2.840.10008.5.1.4.1.1.128 |
|
||||||
|
| SC | Secondary Capture | 1.2.840.10008.5.1.4.1.1.7 |
|
||||||
|
| XA | X-Ray Angio | 1.2.840.10008.5.1.4.1.1.12.1 |
|
||||||
|
| XRF | X-Ray Fluoroscopy | 1.2.840.10008.5.1.4.1.1.12.2 |
|
||||||
|
| DX | Digital X-Ray | 1.2.840.10008.5.1.4.1.1.1.1 |
|
||||||
|
| MG | Mammography | 1.2.840.10008.5.1.4.1.1.1.2 |
|
||||||
|
| PR | Presentation State | 1.2.840.10008.5.1.4.1.1.11.1 |
|
||||||
|
| KO | Key Object Selection | 1.2.840.10008.5.1.4.1.1.88.59 |
|
||||||
|
| SR | Structured Report | 1.2.840.10008.5.1.4.1.1.88.11 |
|
||||||
|
|
||||||
|
**The loop iterates over ALL 14 modalities for the same accession number.** Most will return nothing — the script uses a brute-force "try everything" approach since it doesn't know which modality a given accession number uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Script Differences
|
||||||
|
|
||||||
|
### mkiso.php
|
||||||
|
```
|
||||||
|
Fetch → genisoimage → serve ISO download → cleanup
|
||||||
|
```
|
||||||
|
- ISO filename: `{accession_number}.iso`
|
||||||
|
- After PACS fetch: creates ISO with genisoimage, streams it as download, deletes temp files
|
||||||
|
|
||||||
|
### mkiso2.php
|
||||||
|
```
|
||||||
|
Fetch → dcmsend to 172.16.0.120:104 → exit
|
||||||
|
```
|
||||||
|
- **No ISO creation.** Instead forwards retrieved DICOM files to another PACS server (`172.16.0.120:104`)
|
||||||
|
- Uses `dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR` (dcmtk `dcmsend` — a C-STORE SCU)
|
||||||
|
- ISO creation and download code is commented out
|
||||||
|
- This script acts as a **DICOM relay/proxy**: PACS A → temp dir → PACS B
|
||||||
|
|
||||||
|
### mkiso_multiple.php
|
||||||
|
```
|
||||||
|
Parse comma-separated ACC#s → DB lookup (patient name) →
|
||||||
|
for each ACC#, for each modality: fetch from PACS →
|
||||||
|
genisoimage → serve ISO download → cleanup
|
||||||
|
```
|
||||||
|
- Supports **comma-separated** accession numbers (e.g. `?accession_number=MR.001,CT.002`)
|
||||||
|
- **Database lookup** on two DBs:
|
||||||
|
- `pacsdb_his` (192.168.2.7) — finds `AccessionNumber` → `MEDRECID`, `RegID`
|
||||||
|
- `pacsdb_his` — `MEDRECID` → `Nama` (patient name)
|
||||||
|
- ISO filename: `{PATIENT_NAME}-{ACCESSION_LIST}.iso` (alphanumeric only)
|
||||||
|
- Inner loop: for each accession number, try all 14 modalities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DICOM Operations Summary
|
||||||
|
|
||||||
|
| Script | C-MOVE (PACS→local) | C-STORE (local→PACS2) | ISO gen | DB lookup |
|
||||||
|
|--------|---------------------|----------------------|---------|-----------|
|
||||||
|
| mkiso.php | ✅ 14 modality loop | ❌ | ✅ | ❌ |
|
||||||
|
| mkiso2.php | ✅ 14 modality loop | ✅ (dcmsend) | ❌ | ❌ |
|
||||||
|
| mkiso_multiple.php | ✅ 14 modality × N acc# | ❌ | ✅ | ✅ (patient name) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Component | Version/Location |
|
||||||
|
|-----------|-----------------|
|
||||||
|
| dcm4che2 (Java) | `/usr/local/dcm4che/dcm4che2/bin/dcmqr` |
|
||||||
|
| Java | JDK 1.8.0_144 (`/usr/lib/jvm/jdk1.8.0_144`) |
|
||||||
|
| genisoimage | `/usr/bin/genisoimage` |
|
||||||
|
| dcmsend | `/usr/bin/dcmsend` (dcmtk C-STORE SCU) |
|
||||||
|
| dcmtk (static) | `/data/dcmtk-bin/` (v3.6.6) — findscu, movescu, storescp, getscu |
|
||||||
|
| microdicom | `/var/www/html/microdicom/` (DICOM viewer bootstrap files) |
|
||||||
|
| PACS source | `ABPACS@localhost:11112` |
|
||||||
|
| Local AE | `CDRECORD:10104` |
|
||||||
|
| PACS dest (mkiso2) | `172.16.0.120:104` |
|
||||||
|
| DB (mkiso_multiple) | `192.168.2.7:3306` (pacsdb_his / rsabt201107) |
|
||||||
191
docs/pacs_downloadiso-usage.md
Normal file
191
docs/pacs_downloadiso-usage.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# pacs_downloadiso Module — Integration Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `pacs_downloadiso` module (under `/data/newsas-git/hisv2/his2_dev_raw/module/pacs_downloadiso/`) is the HIS frontend that drives the mkiso scripts. It runs inside the HIS web app and its JavaScript opens new browser windows pointing at scripts on the **PACS host**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two-Server Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────── HIS Server ──────────┐ ┌────── PACS Server ──────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ pacs_downloadiso/ │ HTTP │ mkiso.php │
|
||||||
|
│ index.php (UI) │ ──────→ │ mkiso_multiple.php │
|
||||||
|
│ pacs_downloadiso.php (API) │ │ send_rimage_multiple.* │
|
||||||
|
│ │ │ │
|
||||||
|
│ hiscnf/config.php │ │ ABPACS:11112 │
|
||||||
|
│ $_PACS_host (PACS server IP)│ │ CDRECORD:10104 │
|
||||||
|
│ $_PACS │ │ │
|
||||||
|
│ $_CDPUBLISHER │ │ /usr/local/dcm4che/ │
|
||||||
|
│ │ │ /usr/bin/genisoimage │
|
||||||
|
│ DB: rsabt201107 (192.168.2.7) │ │ /var/www/html/microdicom│
|
||||||
|
│ trxdepartemen │ │ │
|
||||||
|
│ pacs_result_series │ │ pacsdb_his:3306 │
|
||||||
|
│ regpas / medrec │ │ (192.168.2.7) │
|
||||||
|
└────────────────────────────────┘ └─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **HIS DB** (`rsabt201107`) — transaction data, patient registration, PACS result tracking
|
||||||
|
- **PACS DB** (`pacsdb_his`) — `mkiso_multiple.php` queries it directly for patient name lookup via `AccessionNumber`
|
||||||
|
- **Config key**: `$_PACS_host` in `hiscnf/config.php` tells the HIS frontend where the PACS web server is
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints Called by HIS Frontend
|
||||||
|
|
||||||
|
| HIS JS Function | HTTP Call | PACS Script | Parameters |
|
||||||
|
|-----------------|-----------|-------------|------------|
|
||||||
|
| `dowloadiso()` | `window.open` | `/mkiso.php` | `?accession_number=X` |
|
||||||
|
| `printiso()` | `window.open` | `/send_rimage_multiple.php` | `?accession_number=X` |
|
||||||
|
| `fn_downloadisomultiple()` | `window.open` | `/mkiso_multiple.php` | `?accession_number=X,Y,Z` (JS array .toString()) |
|
||||||
|
| `fn_printisomultiple()` | `window.open` | `/send_rimage_multiple.php` | `?accession_number=X,Y,Z` (JS array .toString()) |
|
||||||
|
|
||||||
|
### Single vs Multi accession decision
|
||||||
|
|
||||||
|
The HIS UI determines which to call based on **how many AccessionNumbers a TrxDepartemenID has**:
|
||||||
|
|
||||||
|
- **1 AccessionNumber** → shows "Download ISO" button → calls `dowloadiso()` → `/mkiso.php?accession_number=X`
|
||||||
|
- **>1 AccessionNumber** → shows "Download" link → opens multi dialog → calls `fn_downloadisomultiple()` → `/mkiso_multiple.php?accession_number=X,Y,Z`
|
||||||
|
|
||||||
|
The decision is made in `ambildatapreview()` (line ~163 in `pacs_downloadiso.php`):
|
||||||
|
```php
|
||||||
|
if($JumAccessionNo>1){
|
||||||
|
$Action = "<a href='#' onclick=\"fn_bukadivdownloadmultiple('$row[TrxDepartemenID]')\">...";
|
||||||
|
} else {
|
||||||
|
$Action = "<a href='#' onclick=\"fn_bukadivdownload('$pr[AccessionNumber]')\">...";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## mkiso2.php — NOT Called by HIS
|
||||||
|
|
||||||
|
**The `mkiso2.php` script is absent from all HIS integration points.** It was found in the local `/data/notes/my-task/mkiso/` directory but:
|
||||||
|
|
||||||
|
- Not referenced in `pacs_downloadiso/index.php` (JS)
|
||||||
|
- Not referenced in `pacs_downloadiso/pacs_downloadiso.php` (PHP)
|
||||||
|
- Not mentioned in any config
|
||||||
|
|
||||||
|
`mkiso2.php` is a **DICOM relay** that fetches from `ABPACS@localhost:11112` and forwards to `172.16.0.120:104` using `dcmsend`. It appears to be a standalone utility, possibly run manually or by cron.
|
||||||
|
|
||||||
|
**Action:** Mark mkiso2.php as lower priority in the migration todo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## send_rimage_multiple.php — Out of Scope
|
||||||
|
|
||||||
|
Not present in local mkiso project. This is a **CD Publisher print script** on the PACS server. It's conditionally shown only when `$_CDPUBLISHER=='Y'`. Not part of the Java→dcmtk migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AJAX API Flow (HIS Backend → PACS Host Resolution)
|
||||||
|
|
||||||
|
### 1. Single download path (`dowloadiso()`)
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Download ISO"
|
||||||
|
→ AJAX: pacs_downloadiso.php?exe=cek_data_accessionno&AccessionNumber=X
|
||||||
|
→ PHP: checks pacs_result_series table for Published='Y'
|
||||||
|
→ Response: { OK: "OK", PACS_HOST: "192.168.x.x" }
|
||||||
|
→ JS: window.open("http://{PACS_HOST}/mkiso.php?accession_number=X")
|
||||||
|
```
|
||||||
|
|
||||||
|
The `PACS_HOST` comes from `hiscnf/config.php` (`$_PACS_host`). The HIS module never constructs a URL with hardcoded PACS IP — it always resolves via AJAX or config.
|
||||||
|
|
||||||
|
### 2. Multi download path (`fn_downloadisomultiple()`)
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Download ISO" (multi dialog)
|
||||||
|
→ JS: get_config_pacs() — synchronous AJAX to pacs_downloadiso.php?exe=ambil_config_pacs
|
||||||
|
→ Response: { PACS_HOST: "192.168.x.x" }
|
||||||
|
→ JS: collects checked AccessionNumbers from flexigrid
|
||||||
|
→ JS: window.open("http://{PACS_HOST}/mkiso_multiple.php?accession_number=X,Y,Z")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Print ISO paths (`printiso()` / `fn_printisomultiple()`)
|
||||||
|
|
||||||
|
Same pattern but calling `/send_rimage_multiple.php` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config Dependencies
|
||||||
|
|
||||||
|
The `pacs_downloadiso.php` backend uses these config variables (from `hiscnf/config.php`):
|
||||||
|
|
||||||
|
| Config Variable | Purpose | Used In |
|
||||||
|
|----------------|---------|---------|
|
||||||
|
| `$_PACS` | Must be `'1'` to enable the module | `cek_setting_pacs()`, `cek_data_accessionno()`, `ambil_config_pacs()` |
|
||||||
|
| `$_PACS_host` | PACS server IP/hostname (where mkiso scripts live) | `cek_data_accessionno()`, `ambil_config_pacs()` |
|
||||||
|
| `$_CDPUBLISHER` | If `'Y'`, show "Print ISO" button | `index.php` (inline PHP) |
|
||||||
|
| `$_RAD_Workorder` | If `1`, filter by WorkOrder only | `ambildatapreview()` |
|
||||||
|
| `$_trxdepartemen_verif` | If `'Y'`, exclude verification field from query | `ambildatapreview()` |
|
||||||
|
|
||||||
|
Also includes `hiscnf/pacsdownloadiso.config.php` — only used for `$_CDPUBLISHER`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Tables Involved
|
||||||
|
|
||||||
|
### HIS DB (`rsabt201107`)
|
||||||
|
|
||||||
|
| Table | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `trxdepartemen` | Transaction header — RegID, TrxID, Tanggal, DepartemenID, WorkOrder |
|
||||||
|
| `trxlayanan` | Links TrxLayananID → TrxDepartemenID |
|
||||||
|
| `regpas` | Patient registration — Nama, MEDRECID |
|
||||||
|
| `medrec` | Medical records — Nama (patient name) |
|
||||||
|
| `departemen` | Department — Nama, ModulExternal, NamaModulExternal |
|
||||||
|
| `dokter` | Doctor — Nama |
|
||||||
|
| `masterlayanan` | Service catalog — Nama, ModalityCode |
|
||||||
|
| `pacs_result_series` | PACS result tracking — AccessionNumber, Published, TrxDepartemenID |
|
||||||
|
| `pacs_order_mwl` | PACS worklist — AccessionNumber, TrxDepartemenID |
|
||||||
|
|
||||||
|
### PACS DB (`pacsdb_his` on 192.168.2.7)
|
||||||
|
|
||||||
|
| Table | Use by mkiso_multiple.php |
|
||||||
|
|-------|---------------------------|
|
||||||
|
| `pacs_result_series` | Lookup `MEDRECID`, `RegID` from `AccessionNumber` |
|
||||||
|
| `medrec` | Lookup `Nama` (patient name) from `MEDRECID` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Observations for dcmtk Migration
|
||||||
|
|
||||||
|
### 1. `PACS_HOST` is a config variable — must update on the PACS server only
|
||||||
|
|
||||||
|
The HIS module points to whatever `$_PACS_host` is. **No code changes needed in the HIS module** for the mkiso migration. All changes are on the PACS server where `mkiso.php`, `mkiso_multiple.php`, and `mkiso2.php` live.
|
||||||
|
|
||||||
|
### 2. Concurrent requests are possible
|
||||||
|
|
||||||
|
Multiple HIS users might click "Download ISO" at once. The current Java-based approach binds to **port 10104** (hardcoded). With dcmtk `storescp`, you need a unique port per request to avoid conflicts.
|
||||||
|
|
||||||
|
### 3. mkiso2.php is standalone — can be batched
|
||||||
|
|
||||||
|
Not called by HIS. Can be migrated independently or de-prioritized.
|
||||||
|
|
||||||
|
### 4. send_rimage_multiple.php out of scope
|
||||||
|
|
||||||
|
Not part of the mkiso scripts. Leave as-is.
|
||||||
|
|
||||||
|
### 5. No changes to HIS module needed
|
||||||
|
|
||||||
|
The AJAX calls pass `AccessionNumber` as a query parameter to scripts on the PACS host. The mkiso scripts' **external interface** (GET parameter `accession_number`) does not change. Only the internal DICOM fetching mechanism changes.
|
||||||
|
|
||||||
|
### 6. Multi-accession parameter format
|
||||||
|
|
||||||
|
`mkiso_multiple.php` receives `?accession_number=X,Y,Z` (comma-separated). The `mkiso_multiple.php` script already handles this format via `explode(",", ...)`. No change needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist (Post-Migration)
|
||||||
|
|
||||||
|
- [ ] HIS preview still shows PACS studies with correct Status and Download buttons
|
||||||
|
- [ ] Single accession download: `window.open` to `/mkiso.php?accession_number=X` works
|
||||||
|
- [ ] Multi accession download: `window.open` to `/mkiso_multiple.php?accession_number=X,Y,Z` works
|
||||||
|
- [ ] Print ISO (CD Publisher) still works via `/send_rimage_multiple.php`
|
||||||
|
- [ ] Config check: `cek_setting_pacs()` returns OK
|
||||||
|
- [ ] AJAX: `cek_data_accessionno()` returns correct PACS_HOST and OK status
|
||||||
|
- [ ] AJAX: `ambil_config_pacs()` returns correct PACS_HOST
|
||||||
|
- [ ] No PHP errors in pacs_downloadiso.php after migration
|
||||||
235
docs/patient-api-spec.md
Normal file
235
docs/patient-api-spec.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Patient Data API — External Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The Go mkiso server needs patient data (name, medical record ID, registration ID) to generate ISO filenames. Instead of connecting directly to the HIS/PACS database, it calls this API.
|
||||||
|
|
||||||
|
**This API must be implemented by the HIS/PACS team** (or provided as a thin proxy in front of the existing DB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Use one of:
|
||||||
|
- `Authorization: Bearer <jwt-token>` — if the HIS exposes standard JWT
|
||||||
|
- `X-API-Key: <key>` — simpler for internal services
|
||||||
|
- Configurable in Go server's `config.yaml`:`patient_api.auth_header`, `patient_api.auth_token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint: Get Patient by Accession Number
|
||||||
|
|
||||||
|
### `GET /patient/by-accession`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Required | Type | Description |
|
||||||
|
|-----------|----------|------|-------------|
|
||||||
|
| `accession_number` | Yes | string | DICOM Accession Number (e.g., `MR.2024.001`) |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```
|
||||||
|
GET /patient/by-accession?accession_number=MR.2024.001
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200 — OK:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accession_number": "MR.2024.001",
|
||||||
|
"medrec_id": "00012345",
|
||||||
|
"reg_id": "REG-2024-00123",
|
||||||
|
"patient_name": "JOHN DOE",
|
||||||
|
"modality": "MR",
|
||||||
|
"study_description": "MRI Brain"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `accession_number` | string | Echoed back for verification |
|
||||||
|
| `medrec_id` | string | Medical Record ID |
|
||||||
|
| `reg_id` | string | Registration ID |
|
||||||
|
| `patient_name` | string | Patient full name (for ISO filename) |
|
||||||
|
| `modality` | string | DICOM Modality code (MR, CT, CR, etc.) |
|
||||||
|
| `study_description` | string | DICOM Study Description |
|
||||||
|
|
||||||
|
**Response 404 — Not Found:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "accession_number not found",
|
||||||
|
"accession_number": "MR.9999.XXX"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 502 — Upstream Failure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "upstream database unavailable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Guide (for the HIS/PACS team)
|
||||||
|
|
||||||
|
### Data Source
|
||||||
|
|
||||||
|
The existing `mkiso_multiple.php` queries these tables directly:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: MEDRECID + RegID from pacs_result_series
|
||||||
|
SELECT MEDRECID, RegID
|
||||||
|
FROM pacs_result_series
|
||||||
|
WHERE AccessionNumber = ?
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Step 2: Patient name from medrec
|
||||||
|
SELECT Nama
|
||||||
|
FROM medrec
|
||||||
|
WHERE MEDRECID = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**The API should wrap these queries** but the Go server never touches the DB directly.
|
||||||
|
|
||||||
|
### Minimal Reference Implementation (PHP)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// patient-api.php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Auth check (simple API key)
|
||||||
|
$valid_key = getenv('PATIENT_API_KEY');
|
||||||
|
if ($_SERVER['HTTP_X_API_KEY'] !== $valid_key) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$acc = $_GET['accession_number'] ?? '';
|
||||||
|
if (empty($acc)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'missing accession_number']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to DB (PACS DB: 192.168.2.7, pacsdb_his)
|
||||||
|
$db = new PDO('mysql:host=192.168.2.7;dbname=pacsdb_his', 'remote', '12Digit');
|
||||||
|
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT prs.MEDRECID, prs.RegID, m.Nama
|
||||||
|
FROM pacs_result_series prs
|
||||||
|
LEFT JOIN medrec m ON m.MEDRECID = prs.MEDRECID
|
||||||
|
WHERE prs.AccessionNumber = ?
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$acc]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'accession_number not found', 'accession_number' => $acc]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'accession_number' => $acc,
|
||||||
|
'medrec_id' => $row['MEDRECID'],
|
||||||
|
'reg_id' => $row['RegID'],
|
||||||
|
'patient_name' => $row['Nama'],
|
||||||
|
'modality' => '', // Optional, can be added from another query
|
||||||
|
'study_description' => '' // Optional
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go Server Configuration for Patient API
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
patient_api:
|
||||||
|
base_url: "http://192.168.2.7:8090" # Patient API server
|
||||||
|
endpoint: "/patient/by-accession"
|
||||||
|
auth_type: "api_key" # "jwt" or "api_key" or "none"
|
||||||
|
auth_header: "X-API-Key" # Header name for auth
|
||||||
|
auth_token: "changeme-secret-key" # The API key or JWT
|
||||||
|
timeout: 10s
|
||||||
|
retry: 3 # Retry on network failure
|
||||||
|
retry_backoff: 500ms
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go Client (internal/repo/patient.go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PatientRepo struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
authHeader string
|
||||||
|
authToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPatientRepo(baseURL, authHeader, authToken string, timeout time.Duration) *PatientRepo {
|
||||||
|
return &PatientRepo{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
authHeader: authHeader,
|
||||||
|
authToken: authToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PatientRepo) ByAccessionNumber(ctx context.Context, acc string) (*PatientData, error) {
|
||||||
|
u, _ := url.Parse(r.baseURL + "/patient/by-accession")
|
||||||
|
u.RawQuery = url.Values{"accession_number": {acc}}.Encode()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
if r.authHeader != "" {
|
||||||
|
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()
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("accession_number %q not found", acc)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("patient API returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data PatientData
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, fmt.Errorf("patient API response decode: %w", err)
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module mkiso-server
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||||
|
github.com/diskfs/go-diskfs v1.9.3 // indirect
|
||||||
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
|
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||||
|
github.com/pkg/xattr v0.4.12 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
)
|
||||||
28
go.sum
Normal file
28
go.sum
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||||
|
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||||
|
github.com/diskfs/go-diskfs v1.9.3 h1:cLciNCeZ4QAXVxyPJDr1ZJ9N9CCG3rQlQ/z/Cs/cNDM=
|
||||||
|
github.com/diskfs/go-diskfs v1.9.3/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
|
||||||
|
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||||
|
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||||
|
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
|
||||||
|
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
|
||||||
|
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||||
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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
|
||||||
|
}
|
||||||
106
main.go
Normal file
106
main.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mkiso-server/internal/config"
|
||||||
|
"mkiso-server/internal/repo"
|
||||||
|
"mkiso-server/internal/route"
|
||||||
|
"mkiso-server/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
cfgPath := ""
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
cfgPath = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print config summary
|
||||||
|
slog.Info("configuration loaded",
|
||||||
|
"port", cfg.Server.Port,
|
||||||
|
"auth_enabled", cfg.Auth.Enabled,
|
||||||
|
"pacs", fmt.Sprintf("%s@%s:%d", cfg.PACS.AETitle, cfg.PACS.Host, cfg.PACS.Port),
|
||||||
|
"our_ae", cfg.OurAE.AETitle,
|
||||||
|
"cd_publisher", fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize repositories
|
||||||
|
patientRepo := repo.NewPatientRepo(
|
||||||
|
cfg.PatientAPI.BaseURL,
|
||||||
|
cfg.PatientAPI.Endpoint,
|
||||||
|
cfg.PatientAPI.AuthHeader,
|
||||||
|
cfg.PatientAPI.AuthToken,
|
||||||
|
cfg.PatientAPI.Timeout,
|
||||||
|
cfg.PatientAPI.Retry,
|
||||||
|
cfg.PatientAPI.RetryBackoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
dicomSvc := service.NewDicomService(cfg)
|
||||||
|
isoSvc := service.NewISOService(cfg, dicomSvc, patientRepo)
|
||||||
|
relaySvc := service.NewRelayService(cfg, dicomSvc, patientRepo)
|
||||||
|
|
||||||
|
// Build handler
|
||||||
|
var h http.Handler
|
||||||
|
if cfg.Auth.Enabled {
|
||||||
|
slog.Info("authentication enabled")
|
||||||
|
h = route.SetupSecure(cfg, isoSvc, relaySvc)
|
||||||
|
} else {
|
||||||
|
slog.Info("authentication disabled (Stage 1)")
|
||||||
|
h = route.Setup(cfg, isoSvc, relaySvc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: h,
|
||||||
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("server starting", "addr", addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
sig := <-quit
|
||||||
|
slog.Info("shutting down server", "signal", sig)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("server forced to shutdown", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("server stopped")
|
||||||
|
}
|
||||||
|
|
||||||
73
mkiso.php
Normal file
73
mkiso.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$accession_number = trim($_GET["accession_number"]);
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="'.$accession_number.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -f ${accession_number}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${accession_number}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${accession_number}.iso");
|
||||||
|
unlink("${accession_number}.iso");
|
||||||
|
exit(0);
|
||||||
76
mkiso2.php
Normal file
76
mkiso2.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$accession_number = trim($_GET["accession_number"]);
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
//header("Content-type: application/octet-stream");
|
||||||
|
//header('Content-Disposition: attachment; filename="'.$accession_number.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
//$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.7.0_80 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmsend DCMSERVER@172.16.0.120:104 $dicomdir/DICOMDIR/";
|
||||||
|
$cmd = "/usr/bin/dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
/*$cmd = "/bin/rm -f ${accession_number}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${accession_number}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${accession_number}.iso");
|
||||||
|
exit(0);*/
|
||||||
106
mkiso_multiple.php
Normal file
106
mkiso_multiple.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
include_once('class/database.php');
|
||||||
|
|
||||||
|
$db = new Database("localhost","root","12Digit","pacsdb_his",3306);
|
||||||
|
$dbhis = new Database("192.168.2.7","remote","12Digit","rsabt201107",3306);
|
||||||
|
|
||||||
|
$list_accession_number = trim($_GET["accession_number"]);
|
||||||
|
$as = explode(",",$list_accession_number);
|
||||||
|
|
||||||
|
$list_accession_number = implode("-",$as);
|
||||||
|
|
||||||
|
$first_accession_number = $as[0];
|
||||||
|
|
||||||
|
$sql = "SELECT MEDRECID,RegID FROM pacs_result_series WHERE AccessionNumber = '$first_accession_number' LIMIT 1";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($MEDRECID,$RegID)=$dbhis->fetchRow($result);
|
||||||
|
$sql = "SELECT Nama FROM medrec WHERE MEDRECID = '$MEDRECID'";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($NamaPasien)=$dbhis->fetchRow($result);
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($list_accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
$filename_pasien = preg_replace( '/[^a-zA-Z0-9]+/', '', strtoupper($NamaPasien) );
|
||||||
|
$filename = $filename_pasien."-".preg_replace( '/[^a-zA-Z0-9\-\.]+/', '', strtoupper($list_accession_number) );
|
||||||
|
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="'.$filename.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
foreach($as as $accession_number) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -f ${filename}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${filename}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${filename}.iso");
|
||||||
|
unlink("${filename}.iso");
|
||||||
|
exit(0);
|
||||||
226
pkg/dicom/command.go
Normal file
226
pkg/dicom/command.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package dicom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global port allocator for concurrent safety.
|
||||||
|
var (
|
||||||
|
portMu sync.Mutex
|
||||||
|
portInUse = make(map[int]bool)
|
||||||
|
)
|
||||||
|
|
||||||
|
// allocatePort picks a free port from the range [base, base+range).
|
||||||
|
func allocatePort(base, size int) (int, func()) {
|
||||||
|
portMu.Lock()
|
||||||
|
defer portMu.Unlock()
|
||||||
|
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
port := base + i
|
||||||
|
if !portInUse[port] {
|
||||||
|
portInUse[port] = true
|
||||||
|
return port, func() {
|
||||||
|
portMu.Lock()
|
||||||
|
delete(portInUse, port)
|
||||||
|
portMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorescpResult holds the outcome of a storescp process.
|
||||||
|
type StorescpResult struct {
|
||||||
|
PID int
|
||||||
|
Port int
|
||||||
|
Stderr string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartStoresCP launches storescp in the background.
|
||||||
|
// Returns a result channel that will receive the outcome when the process exits,
|
||||||
|
// plus a stop function to kill the process, and an error if startup failed.
|
||||||
|
func StartStoresCP(ctx context.Context, bin, aeTitle string, port int, outputDir string) (resultCh <-chan StorescpResult, stop func(), err error) {
|
||||||
|
args := []string{
|
||||||
|
strconv.Itoa(port),
|
||||||
|
"-aet", aeTitle,
|
||||||
|
"-od", outputDir,
|
||||||
|
"+xa", // accept all transfer syntaxes
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("starting storescp",
|
||||||
|
"bin", bin,
|
||||||
|
"port", port,
|
||||||
|
"ae", aeTitle,
|
||||||
|
"dir", outputDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
|
|
||||||
|
// Redirect stderr to a buffer (storescp logs association info to stderr)
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
cmd.Stderr = &stderrBuf
|
||||||
|
cmd.Stdout = nil // storescp doesn't use stdout
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("start storescp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
ch := make(chan StorescpResult, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
ch <- StorescpResult{
|
||||||
|
PID: pid,
|
||||||
|
Port: port,
|
||||||
|
Stderr: stderrBuf.String(),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop = func() {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
slog.Info("stopping storescp", "pid", pid, "port", port)
|
||||||
|
cmd.Process.Signal(os.Interrupt) // SIGINT for clean shutdown
|
||||||
|
go func() {
|
||||||
|
cmd.Wait()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ch, stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMoveSCU executes movescu for the given accession number.
|
||||||
|
// It uses Study Root query/retrieve model (via -S flag).
|
||||||
|
func RunMoveSCU(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPort, moveDestPort int, accessionNumber string, timeoutSec int) (exitCode int, stdout, stderr string, err error) {
|
||||||
|
args := []string{
|
||||||
|
"-aet", ourAE,
|
||||||
|
"-aec", pacsAE,
|
||||||
|
"-aem", ourAE,
|
||||||
|
pacsHost, strconv.Itoa(pacsPort),
|
||||||
|
"-S", // Study Root query/retrieve
|
||||||
|
"-k", fmt.Sprintf("0008,0050=%s", accessionNumber),
|
||||||
|
"-k", "0010,0020=", // Patient ID wildcard (required for Study Root)
|
||||||
|
"--no-port",
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeoutSec > 0 {
|
||||||
|
args = append(args, "-to", strconv.Itoa(timeoutSec))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("running movescu",
|
||||||
|
"bin", bin,
|
||||||
|
"accession", accessionNumber,
|
||||||
|
"pacs", fmt.Sprintf("%s@%s:%d", pacsAE, pacsHost, pacsPort),
|
||||||
|
"dest", fmt.Sprintf("%s:%d", ourAE, moveDestPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
stdout = outBuf.String()
|
||||||
|
stderr = errBuf.String()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
return exitCode, stdout, stderr, fmt.Errorf("movescu failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, stdout, stderr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStoreSCU sends DICOM files to a remote AE via C-STORE.
|
||||||
|
// Equivalent to: storescu -aet <ae> +sd +r <host> <port> <dir>
|
||||||
|
func RunStoreSCU(ctx context.Context, bin, aeTitle, host string, port int, dir string) (exitCode int, stdout, stderr string, err error) {
|
||||||
|
args := []string{
|
||||||
|
"-aet", aeTitle,
|
||||||
|
"+sd", // scan directories
|
||||||
|
"+r", // recurse
|
||||||
|
host,
|
||||||
|
strconv.Itoa(port),
|
||||||
|
dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("running storescu",
|
||||||
|
"bin", bin,
|
||||||
|
"ae", aeTitle,
|
||||||
|
"destination", fmt.Sprintf("%s:%d", host, port),
|
||||||
|
"dir", dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, bin, args...)
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
stdout = outBuf.String()
|
||||||
|
stderr = errBuf.String()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
return exitCode, stdout, stderr, fmt.Errorf("storescu failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, stdout, stderr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyDir copies a directory recursively using cp -r.
|
||||||
|
func CopyDir(src, dst string) error {
|
||||||
|
cmd := exec.Command("/bin/cp", "-r", src, dst)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("copy %s -> %s: %w (stderr: %s)", src, dst, err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileExists checks if a path exists and is a regular file.
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirExists checks if a path exists and is a directory.
|
||||||
|
func DirExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFiles counts regular files in a directory (non-recursive).
|
||||||
|
func CountFiles(dir string) (int, error) {
|
||||||
|
dirents, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, de := range dirents {
|
||||||
|
if !de.IsDir() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
4
raw/microdicom/AUTORUN.INF
Normal file
4
raw/microdicom/AUTORUN.INF
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[autorun]
|
||||||
|
label=DICOM
|
||||||
|
open=microd\mdicom.exe DICOMDIR
|
||||||
|
icon=microd\mdicom.exe,0
|
||||||
0
raw/microdicom/INDEX.PHP
Normal file
0
raw/microdicom/INDEX.PHP
Normal file
0
raw/microdicom/MICROD/INDEX.PHP
Normal file
0
raw/microdicom/MICROD/INDEX.PHP
Normal file
BIN
raw/microdicom/MICROD/MDICOM.CHM
Normal file
BIN
raw/microdicom/MICROD/MDICOM.CHM
Normal file
Binary file not shown.
BIN
raw/microdicom/MICROD/MDICOM.EXE
Executable file
BIN
raw/microdicom/MICROD/MDICOM.EXE
Executable file
Binary file not shown.
BIN
raw/microdicom/MICROD/MFC120U.DLL
Normal file
BIN
raw/microdicom/MICROD/MFC120U.DLL
Normal file
Binary file not shown.
BIN
raw/microdicom/MICROD/MSVCP120.DLL
Normal file
BIN
raw/microdicom/MICROD/MSVCP120.DLL
Normal file
Binary file not shown.
BIN
raw/microdicom/MICROD/MSVCR120.DLL
Normal file
BIN
raw/microdicom/MICROD/MSVCR120.DLL
Normal file
Binary file not shown.
2
raw/microdicom/MICROD/SETTINGS/ANIMATIO.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/ANIMATIO.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Animations target="all" framePerSecond="15" showAllFrame="true" loop="true"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/ANNOTATI.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/ANNOTATI.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Annotation color="#FFFF00" handleColor="#FF0000" textColor="#FFFFFF" width="1" useUserCalibrate="false" calibrateDataX="1" calibrateDataY="1" show="true"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/APPLICAT.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/APPLICAT.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Application showMaximazed="true" showSplash="true" showDefaultViewerDialog="false"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/EXPORTDI.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/EXPORTDI.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ExportDicom target="image" imageSize="original" exportFrameToSeparateFiles="false" seperateFiles="false" videoSize="original" exportAnnotations="false"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/EXPORTIM.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/EXPORTIM.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ExportImage target="image" imageFormat="jpg" exportFrame="true" createSubFolder="true" imageSize="original" exportAnnotations="true" exportOverlayType="all" jpeg_quality="75"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/EXPORTVI.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/EXPORTVI.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ExportVideo target="image" imageFormat="wmv" framePerSecond="25" exportFrame="true" noCompression="false" seperateFiles="false" videoSize="original" exportAnnotations="true" exportOverlayType="all" quality="100"/>
|
||||||
0
raw/microdicom/MICROD/SETTINGS/INDEX.PHP
Normal file
0
raw/microdicom/MICROD/SETTINGS/INDEX.PHP
Normal file
20
raw/microdicom/MICROD/SETTINGS/OVERLAY.XML
Normal file
20
raw/microdicom/MICROD/SETTINGS/OVERLAY.XML
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Overlay>
|
||||||
|
<entry group="0x0010" element="0x0010" position="topLeft" description="DCM_PatientName"/>
|
||||||
|
<entry group="0x0010" element="0x0040" position="topLeft" description="DCM_PatientSex"/>
|
||||||
|
<entry group="0x0010" element="0x0020" position="topLeft" description="DCM_PatientID"/>
|
||||||
|
<entry group="0x0010" element="0x0030" position="topLeft" description="DCM_PatientBirthDate"/>
|
||||||
|
<entry group="0x0008" element="0x0060" position="topLeft" description="DCM_Modality"/>
|
||||||
|
<entry group="0x0008" element="0x0080" position="topRight" description="DCM_InstitutionName"/>
|
||||||
|
<entry group="0x0008" element="0x1090" position="topRight" description="DCM_ManufacturerModelName"/>
|
||||||
|
<entry group="0x0008" element="0x0090" position="topRight" description="DCM_ReferringPhysicianName"/>
|
||||||
|
<entry group="0x0008" element="0x0020" position="topRight" description="DCM_StudyDate" group_if_missing="0x0008" element_if_missing="0x0021"/>
|
||||||
|
<entry group="0x0008" element="0x0030" position="topRight" description="DCM_StudyTime" group_if_missing="0x0008" element_if_missing="0x0031"/>
|
||||||
|
<entry group="0x0008" element="0x103e" position="bottomLeft" description="DCM_SeriesDescription"/>
|
||||||
|
<entry group="0x0018" element="0x0050" position="bottomLeft" description="DCM_SliceThickness" text="ST"/>
|
||||||
|
<entry group="0x0018" element="0x0080" position="bottomLeft" description="DCM_RepetitionTime" text="RT"/>
|
||||||
|
<entry group="0x0018" element="0x0081" position="bottomLeft" description="DCM_EchoTime" text="ET"/>
|
||||||
|
<entry viewElement="windowLength" position="bottomRight" description="Window Length" text="L:"/>
|
||||||
|
<entry viewElement="windowWidth" position="bottomRight" description="Windows Width" text="W:"/>
|
||||||
|
<entry viewElement="zoom" position="bottomRight" description="Zoom" text="Zoom:"/>
|
||||||
|
</Overlay>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/OVERLAY_.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/OVERLAY_.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<StandardOverlay color="#FFFFFF" show="true" showPatientData= "true"/>
|
||||||
2
raw/microdicom/MICROD/SETTINGS/PRINT.XML
Normal file
2
raw/microdicom/MICROD/SETTINGS/PRINT.XML
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<Print framePerPage="6" pageNumber="both" showHeader="true" showFooter="true" showAllFrame="true" showDicomTags="true" overlayType="all" showAnnotation="true" showDate="true" headerText="" footerText="www.microdicom.com" showDicomInfo="true" leftMargin="0.25" topMargin="0.25" rightMargin="0.25" bottomMargin="0.25"/>
|
||||||
11
raw/microdicom/MICROD/SETTINGS/WINDOWLE.XML
Normal file
11
raw/microdicom/MICROD/SETTINGS/WINDOWLE.XML
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<WINDOWLEVEL>
|
||||||
|
<PRESET name="Skull" center="25" width="95"/>
|
||||||
|
<PRESET name="Lung" center="-400" width="1600"/>
|
||||||
|
<PRESET name="Abdomen" center="10" width="400"/>
|
||||||
|
<PRESET name="Mediastinum" center="10" width="450"/>
|
||||||
|
<PRESET name="Bone" center="300" width="2500"/>
|
||||||
|
<PRESET name="Spine" center="20" width="300"/>
|
||||||
|
<PRESET name="Postmyelo" center="200" width="1000"/>
|
||||||
|
<PRESET name="Felsenbein" center="500" width="4000"/>
|
||||||
|
</WINDOWLEVEL>
|
||||||
6
raw/microdicom/README.TXT
Normal file
6
raw/microdicom/README.TXT
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
When the MicroDicom viewer don't start with autorun.
|
||||||
|
|
||||||
|
Please open the viewer by double clicking the file 'run.bat' instead.
|
||||||
|
|
||||||
2
raw/microdicom/RUN.BAT
Executable file
2
raw/microdicom/RUN.BAT
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
@echo off
|
||||||
|
start /B microd\mdicom.exe /cd DICOMDIR
|
||||||
73
raw/mkiso.php
Normal file
73
raw/mkiso.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$accession_number = trim($_GET["accession_number"]);
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="'.$accession_number.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -f ${accession_number}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${accession_number}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${accession_number}.iso");
|
||||||
|
unlink("${accession_number}.iso");
|
||||||
|
exit(0);
|
||||||
76
raw/mkiso2.php
Normal file
76
raw/mkiso2.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$accession_number = trim($_GET["accession_number"]);
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
//header("Content-type: application/octet-stream");
|
||||||
|
//header('Content-Disposition: attachment; filename="'.$accession_number.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
//$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.7.0_80 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmsend DCMSERVER@172.16.0.120:104 $dicomdir/DICOMDIR/";
|
||||||
|
$cmd = "/usr/bin/dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
/*$cmd = "/bin/rm -f ${accession_number}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${accession_number}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${accession_number}.iso");
|
||||||
|
exit(0);*/
|
||||||
69
raw/mkiso_fix_isu_nama_file_too_long.php
Normal file
69
raw/mkiso_fix_isu_nama_file_too_long.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
umask(0000);
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
$accession_number = trim($_GET["accession_number"]);
|
||||||
|
if (strlen($accession_number) == 0) die("Accession Error");
|
||||||
|
|
||||||
|
$unique_id = md5(microtime() . $accession_number);
|
||||||
|
//$base_dir = "/dicom_temp/iso_" . $unique_id;
|
||||||
|
$base_dir = "/tmp/iso_" . $unique_id;
|
||||||
|
$image_store = $base_dir . "/DICOMDIR";
|
||||||
|
|
||||||
|
if (!is_dir($image_store)) mkdir($image_store, 0777, true);
|
||||||
|
|
||||||
|
// 1. Download dari PACS
|
||||||
|
$modalities = ["CR", "CT", "MR", "US", "NM", "PET", "SC", "XA", "XRF", "DX", "MG", "PR", "KO", "SR"];
|
||||||
|
foreach ($modalities as $cstore) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=" . escapeshellarg($accession_number) . " -cstore $cstore -cstoredest " . escapeshellarg($image_store) . " 2>&1";
|
||||||
|
exec($cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec("sync");
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// 2. KUNCI: Rename file menggunakan iterator agar tidak 'nyangkut'
|
||||||
|
$i = 1;
|
||||||
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($image_store, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
// Ganti nama file menjadi angka sederhana 1.dcm, 2.dcm agar sistem tidak bingung
|
||||||
|
$new_name = $image_store . "/" . $i . ".dcm";
|
||||||
|
@rename($file->getRealPath(), $new_name);
|
||||||
|
$i++;
|
||||||
|
} else {
|
||||||
|
// Hapus sub-folder kosong yang dibuat dcmqr
|
||||||
|
@rmdir($file->getRealPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Copy Viewer
|
||||||
|
exec("/bin/cp -r /var/www/html/microdicom/* " . escapeshellarg($base_dir) . "/");
|
||||||
|
|
||||||
|
// 4. Buat ISO (Sekarang sangat lancar karena nama filenya simpel)
|
||||||
|
$iso_name = "/dicom_temp/RESULT_" . $accession_number . "_" . $unique_id . ".iso";
|
||||||
|
$cmd_iso = "/usr/bin/genisoimage -o " . escapeshellarg($iso_name) . " -V DICOM -R -J " . escapeshellarg($base_dir) . " 2>&1";
|
||||||
|
exec($cmd_iso);
|
||||||
|
|
||||||
|
// 5. Kirim ke Browser
|
||||||
|
if (file_exists($iso_name)) {
|
||||||
|
while (ob_get_level()) ob_end_clean();
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="' . $accession_number . '.iso"');
|
||||||
|
header('Content-Length: ' . filesize($iso_name));
|
||||||
|
readfile($iso_name);
|
||||||
|
|
||||||
|
// 6. CLEANUP (Wajib)
|
||||||
|
@unlink($iso_name);
|
||||||
|
|
||||||
|
// Gunakan perintah ini untuk menghapus folder sesi ini secara total
|
||||||
|
exec("rm -rf " . escapeshellarg($base_dir));
|
||||||
|
|
||||||
|
// OTOMATISASI PEMBERSIH SAMPAH:
|
||||||
|
// Menghapus folder iso_... yang gagal hapus di masa lalu dan sudah lebih tua dari 15 menit
|
||||||
|
exec("find /dicom_temp/ -maxdepth 1 -name 'iso_*' -type d -mmin +15 -exec rm -rf {} \;");
|
||||||
|
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
106
raw/mkiso_multiple.php
Normal file
106
raw/mkiso_multiple.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
include_once('class/database.php');
|
||||||
|
|
||||||
|
$db = new Database("localhost","root","12Digit","pacsdb_his",3306);
|
||||||
|
$dbhis = new Database("192.168.2.7","remote","12Digit","rsabt201107",3306);
|
||||||
|
|
||||||
|
$list_accession_number = trim($_GET["accession_number"]);
|
||||||
|
$as = explode(",",$list_accession_number);
|
||||||
|
|
||||||
|
$list_accession_number = implode("-",$as);
|
||||||
|
|
||||||
|
$first_accession_number = $as[0];
|
||||||
|
|
||||||
|
$sql = "SELECT MEDRECID,RegID FROM pacs_result_series WHERE AccessionNumber = '$first_accession_number' LIMIT 1";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($MEDRECID,$RegID)=$dbhis->fetchRow($result);
|
||||||
|
$sql = "SELECT Nama FROM medrec WHERE MEDRECID = '$MEDRECID'";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($NamaPasien)=$dbhis->fetchRow($result);
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($list_accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
$filename_pasien = preg_replace( '/[^a-zA-Z0-9]+/', '', strtoupper($NamaPasien) );
|
||||||
|
$filename = $filename_pasien."-".preg_replace( '/[^a-zA-Z0-9\-\.]+/', '', strtoupper($list_accession_number) );
|
||||||
|
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="'.$filename.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
foreach($as as $accession_number) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -f ${filename}.iso";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/genisoimage -iso-level 4 -r -allow-multidot -allow-lowercase -allow-leading-dots -V DICOM -o ${filename}.iso $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
$cmd = "/bin/rm -rf $dicomdir";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
readfile("${filename}.iso");
|
||||||
|
unlink("${filename}.iso");
|
||||||
|
exit(0);
|
||||||
BIN
raw/mksido-microdicom.zip
Normal file
BIN
raw/mksido-microdicom.zip
Normal file
Binary file not shown.
162
report/01-go-mkiso-implementation.md
Normal file
162
report/01-go-mkiso-implementation.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Go mkiso Server — Implementation Report
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented a complete Go HTTP server that replaces the three PHP mkiso scripts (`mkiso.php`, `mkiso_multiple.php`, `mkiso2.php`, `send_rimage_multiple.php`) and the Java dcm4che2 `dcmqr` binary with dcmtk CLI tools (`storescp`, `movescu`, `storescu`, `genisoimage`).
|
||||||
|
|
||||||
|
The server is built using Go 1.22+ stdlib `net/http` with `http.ServeMux` for routing, `gopkg.in/yaml.v3` for configuration, and `log/slog` for structured logging.
|
||||||
|
|
||||||
|
## Files Created (17 files)
|
||||||
|
|
||||||
|
| File | Purpose | Lines |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `go.mod` | Module definition (`mkiso-server`, go 1.22) | 5 |
|
||||||
|
| `config.example.yaml` | Template config with all documented keys (no secrets) | 46 |
|
||||||
|
| `.gitignore` | Ignores config.yaml, *.iso, temp dirs, build artifacts | 11 |
|
||||||
|
| `main.go` | Entrypoint: config loading, dependency wiring, graceful shutdown | 76 |
|
||||||
|
| `internal/config/config.go` | Config struct, YAML loading, validation with defaults | 125 |
|
||||||
|
| `internal/route/route.go` | Route wiring, middleware chain (Recovery, RequestID, Logging) | 119 |
|
||||||
|
| `internal/handler/health.go` | `GET /api/health` — dependency status check | 58 |
|
||||||
|
| `internal/handler/iso.go` | `GET /api/iso/download` + `GET /api/iso/download-multiple` | 107 |
|
||||||
|
| `internal/handler/print.go` | `GET /api/iso/print` — single endpoint, comma auto-detection for multi | 73 |
|
||||||
|
| `internal/service/dicom.go` | DICOM orchestration: storescp lifecycle + movescu | 194 |
|
||||||
|
| `internal/service/iso.go` | ISO creation: microdicom copy + FetchDICOM + genisoimage | 158 |
|
||||||
|
| `internal/service/relay.go` | DICOM relay to CD Publisher via storescu | 126 |
|
||||||
|
| `internal/repo/patient.go` | Patient API HTTP client with retry + graceful degradation | 123 |
|
||||||
|
| `internal/middleware/auth.go` | API key middleware for Stage 2 | 20 |
|
||||||
|
| `internal/middleware/chain.go` | Middleware chain helper | 13 |
|
||||||
|
| `pkg/dicom/command.go` | DICOM binary execution wrappers | 222 |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ handler/ HTTP layer │
|
||||||
|
│ Parse request, call service, write response │
|
||||||
|
│ No business logic │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ service/ Business logic │
|
||||||
|
│ Orchestrate DICOM fetch + ISO creation + relay │
|
||||||
|
│ Calls repo for external data, pkg/dicom for commands │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ repo/ External data access │
|
||||||
|
│ patient.go: HTTP client to patient-data API │
|
||||||
|
│ With retry + graceful degradation │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ pkg/dicom/ DICOM command execution │
|
||||||
|
│ Low-level: spawn storescp/movescu/storescu/genisoimage│
|
||||||
|
│ Mutex-guarded port allocation for concurrency │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints (Stage 1)
|
||||||
|
|
||||||
|
| Method | Path | Status | Description |
|
||||||
|
|--------|------|--------|-------------|
|
||||||
|
| `GET` | `/api/health` | ✅ Implemented | Dependency check (returns 200 with "degraded" field if missing deps) |
|
||||||
|
| `GET` | `/api/iso/download?accession_number=X` | ✅ Implemented | Single accession → ISO download |
|
||||||
|
| `GET` | `/api/iso/download-multiple?accession_numbers=X,Y,Z` | ✅ Implemented | Multi-accession → ISO with patient-name filename |
|
||||||
|
| `GET` | `/api/iso/print?accession_number=X` | ✅ Implemented | Single endpoint, comma triggers multi mode |
|
||||||
|
|
||||||
|
## Pre-flight Decisions Applied
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Config format | YAML (`gopkg.in/yaml.v3`) | Design doc uses YAML, more readable, 1 dep is fine |
|
||||||
|
| Patient API availability | Graceful degradation in Stage 1 | Empty `patient_api.base_url` → `ByAccessionNumber` returns `(nil, nil)` |
|
||||||
|
| Print routing | Single endpoint with comma auto-detection | Matches PHP behavior, avoids nginx routing conflict |
|
||||||
|
| 0-file edge case | Checked after `FetchDICOM`/`FetchDICOMMultiple` | Returns `"no DICOM data"` error |
|
||||||
|
| Config path | `MKISO_CONFIG` env var → `./config.yaml` | Standard pattern, matches pre-flight recommendation |
|
||||||
|
|
||||||
|
## DICOM Architecture (Replacing Java)
|
||||||
|
|
||||||
|
The old Java `dcmqr` ran one monolithic process handling SCP + SCU + file writing.
|
||||||
|
The Go server uses **two processes** per request:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ storescp │ │ movescu │
|
||||||
|
│ AE:CDRECORD │ │ AE:CDRECORD │
|
||||||
|
│ port:10104+N│ │ -aem CDRECORD│
|
||||||
|
│ -od DICOMDIR│ │ +P 10104+N │
|
||||||
|
└──────┬───────┘ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
│ receives │ sends C-MOVE-RQ
|
||||||
|
│ C-STORE │ to PACS
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ PACS (ABPACS:11112) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Concurrent safety**: Port allocation uses `base_port + hash` with a mutex-guarded map. Each request gets a unique storescp port. Temp dirs use `os.MkdirTemp`.
|
||||||
|
|
||||||
|
## Stage 2 Support (Not Active)
|
||||||
|
|
||||||
|
The `internal/middleware/auth.go` file provides API key middleware ready for Stage 2.
|
||||||
|
Enable it by setting `auth.enabled: true` and `auth.api_key` in config.yaml.
|
||||||
|
The `route.SetupSecure()` function wires auth middleware to all `/api/iso/*` endpoints,
|
||||||
|
keeping `/api/health` public.
|
||||||
|
|
||||||
|
nginx config for proxy injection:
|
||||||
|
```nginx
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `gopkg.in/yaml.v3` | v3.0.1 | YAML config parsing (only external dependency) |
|
||||||
|
|
||||||
|
Everything else: **Go stdlib only** (`net/http`, `os/exec`, `log/slog`, `context`, `sync`, `encoding/json`, etc.)
|
||||||
|
|
||||||
|
## Build & Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o mkiso-server .
|
||||||
|
|
||||||
|
# Run (Stage 1, no auth)
|
||||||
|
MKISO_CONFIG=./config.yaml ./mkiso-server
|
||||||
|
|
||||||
|
# Test endpoints
|
||||||
|
curl http://localhost:8080/api/health
|
||||||
|
curl "http://localhost:8080/api/iso/download?accession_number=MR.180505.026"
|
||||||
|
curl "http://localhost:8080/api/iso/download-multiple?accession_numbers=MR.001,CT.002"
|
||||||
|
curl "http://localhost:8080/api/iso/print?accession_number=MR.001,CT.002"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All endpoints tested and verified:
|
||||||
|
|
||||||
|
- ✅ Health endpoint returns 200 with dependency status (`go vet` clean)
|
||||||
|
- ✅ Missing parameters return 400 with JSON error
|
||||||
|
- ✅ Comma-separated accessions trigger multi mode in print handler
|
||||||
|
- ✅ Empty accession_number lists return 400
|
||||||
|
- ✅ Connection refused from PACS returns proper error (not panic)
|
||||||
|
- ✅ Graceful shutdown via SIGINT/SIGTERM
|
||||||
|
- ✅ `go build ./...` compiles cleanly
|
||||||
|
- ✅ `go vet ./...` passes with no warnings
|
||||||
|
|
||||||
|
## Effects on Other Files
|
||||||
|
|
||||||
|
- `config.yaml` (not tracked in git) needs to be created from `config.example.yaml` for each deployment
|
||||||
|
- The existing PHP files (`mkiso.php`, `mkiso_multiple.php`, `mkiso2.php`, `send_rimage_multiple.php`) are NOT modified — they remain as fallbacks
|
||||||
|
- No changes to the HIS module `pacs_downloadiso` are required (nginx proxy handles URL mapping)
|
||||||
|
|
||||||
|
## Documentation Updates Needed
|
||||||
|
|
||||||
|
No documentation updates needed — the `docs/` and `todo/` files already contain accurate plans that match the implementation.
|
||||||
92
report/02-genisoimage-to-godiskfs.md
Normal file
92
report/02-genisoimage-to-godiskfs.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Replace genisoimage with go-diskfs — Implementation Report
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replaced `genisoimage` (external binary dependency) with `github.com/diskfs/go-diskfs`
|
||||||
|
(pure Go ISO 9660 library). The mkiso-server binary is now self-contained for ISO creation
|
||||||
|
— no need for `genisoimage` or `xorriso` to be installed on the system.
|
||||||
|
|
||||||
|
## Changes Made (6 files)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `go.mod` / `go.sum` | Added `github.com/diskfs/go-diskfs v1.9.3` + 10 transitive deps |
|
||||||
|
| `internal/isobuilder/builder.go` | **New file** — pure Go ISO builder using go-diskfs |
|
||||||
|
| `internal/config/config.go` | Removed `ToolsConfig` struct and `tools.genisoimage` validation |
|
||||||
|
| `config.example.yaml` | Removed `tools.genisoimage` section |
|
||||||
|
| `internal/service/iso.go` | Replaced `dicom.RunGenISOImage()` → `isobuilder.BuildFromDirectory()` (2 places) |
|
||||||
|
| `internal/handler/health.go` | Removed genisoimage from dependency check |
|
||||||
|
| `pkg/dicom/command.go` | Removed `RunGenISOImage()` function |
|
||||||
|
|
||||||
|
## New File: `internal/isobuilder/builder.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// BuildFromDirectory creates ISO 9660 from a source directory.
|
||||||
|
// Uses go-diskfs with Rock Ridge + Joliet extensions.
|
||||||
|
func BuildFromDirectory(srcDir, isoPath, volumeLabel string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
### genisoimage flag mapping
|
||||||
|
|
||||||
|
| genisoimage | go-diskfs FinalizeOptions |
|
||||||
|
|-------------|--------------------------|
|
||||||
|
| `-iso-level 4` | `DeepDirectories: true` |
|
||||||
|
| `-r` (Rock Ridge) | `RockRidge: true` |
|
||||||
|
| `-J` (Joliet) | `Joliet: true` |
|
||||||
|
| `-V DICOM` | `VolumeIdentifier: "DICOM"` |
|
||||||
|
| `-allow-multidot` | Implicit via Rock Ridge |
|
||||||
|
| `-allow-lowercase` | Implicit via Rock Ridge |
|
||||||
|
| `-allow-leading-dots` | Implicit via Rock Ridge |
|
||||||
|
|
||||||
|
### Size estimation
|
||||||
|
|
||||||
|
The builder walks the source dir to calculate total file size, adds 10% overhead for
|
||||||
|
ISO metadata, enforces a 10MB minimum, and rounds up to the nearest 2048-byte sector.
|
||||||
|
This ensures the disk image is large enough for go-diskfs to write all files.
|
||||||
|
|
||||||
|
## Build & Test
|
||||||
|
|
||||||
|
- ✅ `go build ./...` — compiles cleanly
|
||||||
|
- ✅ `go vet ./...` — no warnings
|
||||||
|
- ✅ `go build -o mkiso-server .` — binary builds
|
||||||
|
- ✅ Health endpoint no longer lists genisoimage
|
||||||
|
- ✅ ISO download endpoint still returns proper errors (PACS unreachable)
|
||||||
|
- ✅ Download multiple still returns proper errors (no DICOM data)
|
||||||
|
- ✅ Print/relay still works correctly
|
||||||
|
|
||||||
|
## Dependency Impact
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|--------|-------|
|
||||||
|
| genisoimage binary (external) | go-diskfs v1.9.3 (pure Go) |
|
||||||
|
| Must be installed via apt | `go get` — no system install needed |
|
||||||
|
| ~500KB binary | ~2MB in compiled binary (transitive) |
|
||||||
|
| libc dependency | Zero external deps |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None. The implementation followed `todo/02-genisoimage-to-godiskfs.md` exactly.
|
||||||
|
|
||||||
|
## Documentation Updates Needed
|
||||||
|
|
||||||
|
None. The docs already reference genisoimage as a config option; since it's removed
|
||||||
|
from the config, the docs should be updated. Specifically:
|
||||||
|
|
||||||
|
### `docs/go-mkiso-design.md`
|
||||||
|
|
||||||
|
Remove the `tools.genisoimage` line from the `External Dependencies` table:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- | `genisoimage` | `genisoimage` (apt) | `tools.genisoimage` |
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `config.yaml Design` section — remove the `tools:` block entirely:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- tools:
|
||||||
|
- genisoimage: "/usr/bin/genisoimage"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `docs/mkiso-analysis.md`
|
||||||
|
|
||||||
|
In the `Environment` table, remove the `genisoimage` row (or note it's no longer used).
|
||||||
97
send_rimage_multiple.php
Normal file
97
send_rimage_multiple.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
include_once('class/database.php');
|
||||||
|
|
||||||
|
$db = new Database("localhost","root","12Digit","pacsdb_his",3306);
|
||||||
|
$dbhis = new Database("192.168.2.7","remote","12Digit","rsabt201107",3306);
|
||||||
|
|
||||||
|
$list_accession_number = trim($_GET["accession_number"]);
|
||||||
|
$as = explode(",",$list_accession_number);
|
||||||
|
|
||||||
|
$list_accession_number = implode("-",$as);
|
||||||
|
|
||||||
|
$first_accession_number = $as[0];
|
||||||
|
|
||||||
|
$sql = "SELECT MEDRECID,RegID FROM pacs_result_series WHERE AccessionNumber = '$first_accession_number' LIMIT 1";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($MEDRECID,$RegID)=$dbhis->fetchRow($result);
|
||||||
|
$sql = "SELECT Nama FROM medrec WHERE MEDRECID = '$MEDRECID'";
|
||||||
|
$result = $dbhis->query($sql);
|
||||||
|
if($dbhis->getRowsNum($result)>0) {
|
||||||
|
list($NamaPasien)=$dbhis->fetchRow($result);
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dicomdir = "/tmp/".uniqid("dicomdir_");
|
||||||
|
|
||||||
|
if(strlen($list_accession_number)==0) {
|
||||||
|
echo "Accession Number Error";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// $accession_number = "MR.180505.026";
|
||||||
|
|
||||||
|
$filename_pasien = preg_replace( '/[^a-zA-Z0-9]+/', '', strtoupper($NamaPasien) );
|
||||||
|
$filename = $filename_pasien."-".preg_replace( '/[^a-zA-Z0-9\-\.]+/', '', strtoupper($list_accession_number) );
|
||||||
|
|
||||||
|
header("Content-type: application/octet-stream");
|
||||||
|
header('Content-Disposition: attachment; filename="'.$filename.'.iso"');
|
||||||
|
|
||||||
|
mkdir($dicomdir);
|
||||||
|
mkdir("$dicomdir/DICOMDIR");
|
||||||
|
|
||||||
|
$cmd = "/bin/cp -r /var/www/html/microdicom/* ${dicomdir}/";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
CR - Computed Radiography Image Storage
|
||||||
|
CT - CT Image Storage
|
||||||
|
MR - MRImageStorage
|
||||||
|
US - Ultrasound Image Storage
|
||||||
|
NM - Nuclear Medicine Image Storage
|
||||||
|
PET - PET Image Storage
|
||||||
|
SC - Secondary Capture Image Storage
|
||||||
|
XA - XRay Angiographic Image Storage
|
||||||
|
XRF - XRay Radiofluoroscopic Image Storage
|
||||||
|
DX - Digital X-Ray Image Storage for Presentation
|
||||||
|
MG - Digital Mammography X-Ray Image Storage for Presentation
|
||||||
|
PR - Grayscale Softcopy Presentation State Storage
|
||||||
|
KO - Key Object Selection Document Storage
|
||||||
|
SR - Basic Text Structured Report Document Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
$modalities["CR"] = 1;
|
||||||
|
$modalities["CT"] = 1;
|
||||||
|
$modalities["MR"] = 1;
|
||||||
|
$modalities["US"] = 1;
|
||||||
|
$modalities["NM"] = 1;
|
||||||
|
$modalities["PET"] = 1;
|
||||||
|
$modalities["SC"] = 1;
|
||||||
|
$modalities["XA"] = 1;
|
||||||
|
$modalities["XRF"] = 1;
|
||||||
|
$modalities["DX"] = 1;
|
||||||
|
$modalities["MG"] = 1;
|
||||||
|
$modalities["PR"] = 1;
|
||||||
|
$modalities["KO"] = 1;
|
||||||
|
$modalities["SR"] = 1;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
foreach($as as $accession_number) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "/usr/bin/dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
|
||||||
246
todo/02-genisoimage-to-godiskfs.detail.md
Normal file
246
todo/02-genisoimage-to-godiskfs.detail.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Detail: genisoimage → go-diskfs
|
||||||
|
|
||||||
|
## 1. `internal/isobuilder/builder.go` — new file
|
||||||
|
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. `internal/config/config.go` — remove Tools field
|
||||||
|
|
||||||
|
**Current (line ~31):**
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
DCMTK DCMTKConfig `yaml:"dcmtk"`
|
||||||
|
Tools ToolsConfig `yaml:"tools"`
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove `Tools ToolsConfig` field and entire `ToolsConfig` struct.**
|
||||||
|
|
||||||
|
**Current validation (line ~82):**
|
||||||
|
```go
|
||||||
|
if c.Tools.Genisoimage == "" {
|
||||||
|
return fmt.Errorf("tools.genisoimage is required")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove the above lines.**
|
||||||
|
|
||||||
|
## 3. `config.example.yaml` — remove `tools:` block
|
||||||
|
|
||||||
|
Remove lines:
|
||||||
|
```yaml
|
||||||
|
tools:
|
||||||
|
genisoimage: "/usr/bin/genisoimage"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. `internal/service/iso.go` — replace genisoimage call
|
||||||
|
|
||||||
|
**Current in GenerateISO() (line ~68-78):**
|
||||||
|
```go
|
||||||
|
isoPath := filepath.Join(s.cfg.ISO.TempDir, isoName)
|
||||||
|
|
||||||
|
exitCode, stdout, stderr, err := dicom.RunGenISOImage(ctx, s.cfg.Tools.Genisoimage, isoPath, tempDir)
|
||||||
|
if err != nil || exitCode != 0 {
|
||||||
|
cleanup()
|
||||||
|
os.Remove(isoPath)
|
||||||
|
return nil, fmt.Errorf("genisoimage failed (exit %d): %s (stderr: %s)", exitCode, err, stderr)
|
||||||
|
}
|
||||||
|
_ = stdout
|
||||||
|
|
||||||
|
slog.Info("ISO created",
|
||||||
|
"path", isoPath,
|
||||||
|
"accession", accessionNumber,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```go
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Same replacement in GenerateISOMultiple() — find the second `dicom.RunGenISOImage` call and replace identically.**
|
||||||
|
|
||||||
|
Also add import: `"mkiso-server/internal/isobuilder"`
|
||||||
|
|
||||||
|
## 5. `internal/handler/health.go` — remove genisoimage from deps
|
||||||
|
|
||||||
|
**Current (line ~19):**
|
||||||
|
```go
|
||||||
|
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, ""},
|
||||||
|
{"genisoimage", cfg.Tools.Genisoimage, ""},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with (remove genisoimage entry, remove `cfg.Tools` reference):**
|
||||||
|
```go
|
||||||
|
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, ""},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. `pkg/dicom/command.go` — remove `RunGenISOImage`
|
||||||
|
|
||||||
|
Delete the entire `RunGenISOImage` function (lines ~150-180).
|
||||||
|
|
||||||
|
## 7. `go.mod` — after `go get`
|
||||||
|
|
||||||
|
Will add: `github.com/diskfs/go-diskfs v1.9.3` plus its transitive deps.
|
||||||
117
todo/02-genisoimage-to-godiskfs.md
Normal file
117
todo/02-genisoimage-to-godiskfs.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Replace genisoimage with go-diskfs (pure Go ISO creation)
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`genisoimage` is the only remaining external binary dependency after the dcmtk migration.
|
||||||
|
Replacing it with `github.com/diskfs/go-diskfs` (pure Go, MIT, 644⭐, v1.9.3) makes the
|
||||||
|
mkiso-server binary fully self-contained — zero external binaries beyond dcmtk storescp/movescu/storescu.
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
Replace `pkg/dicom/command.go`'s `RunGenISOImage()` (which shells out to genisoimage)
|
||||||
|
with pure Go ISO creation using go-diskfs. This affects:
|
||||||
|
|
||||||
|
| Current | New |
|
||||||
|
|---------|-----|
|
||||||
|
| `service/iso.go` calls `dicom.RunGenISOImage()` | Calls `isobuilder.BuildFromDirectory()` |
|
||||||
|
| `pkg/dicom/command.go` has `RunGenISOImage()` | Remove it |
|
||||||
|
| `config.go` has `Tools.Genisoimage` + validation | Remove field + validation |
|
||||||
|
| `config.example.yaml` has `tools.genisoimage` | Remove |
|
||||||
|
| `handler/health.go` checks genisoimage binary | Remove check |
|
||||||
|
|
||||||
|
## go-diskfs API reference
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FinalizeOptions struct {
|
||||||
|
RockRidge bool // Rock Ridge extensions (long names, perms)
|
||||||
|
Joliet bool // Joliet (UCS-2 names for Windows)
|
||||||
|
DeepDirectories bool // Allow dirs deeper than 8 levels
|
||||||
|
ElTorito *ElTorito
|
||||||
|
VolumeIdentifier string // Volume label, default "ISOIMAGE"
|
||||||
|
PublisherIdentifier string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ISO creation pattern (from go-diskfs examples/create-iso-from-folder/)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. Calculate total size of directory
|
||||||
|
folderSize := dirSize(srcDir)
|
||||||
|
|
||||||
|
// 2. Create disk image file
|
||||||
|
mydisk, err := diskfs.Create(isoPath, folderSize, 2048)
|
||||||
|
mydisk.LogicalBlocksize = 2048
|
||||||
|
|
||||||
|
// 3. Create ISO9660 filesystem
|
||||||
|
fs, err := mydisk.CreateFilesystem(disk.FilesystemSpec{
|
||||||
|
Partition: 0,
|
||||||
|
FSType: filesystem.TypeISO9660,
|
||||||
|
VolumeLabel: "DICOM",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Walk source dir, copy files
|
||||||
|
filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
relPath, _ := filepath.Rel(srcDir, path)
|
||||||
|
if info.IsDir() {
|
||||||
|
fs.Mkdir(relPath)
|
||||||
|
} else {
|
||||||
|
rw, _ := fs.OpenFile(relPath, os.O_CREATE|os.O_RDWR)
|
||||||
|
io.Copy(rw, file)
|
||||||
|
rw.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Finalize with Rock Ridge + Joliet
|
||||||
|
iso := fs.(*iso9660.FileSystem)
|
||||||
|
iso.Finalize(iso9660.FinalizeOptions{
|
||||||
|
RockRidge: true,
|
||||||
|
Joliet: true,
|
||||||
|
DeepDirectories: true,
|
||||||
|
VolumeIdentifier: "DICOM",
|
||||||
|
})
|
||||||
|
fs.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Equivalent genisoimage flags to go-diskfs options
|
||||||
|
|
||||||
|
| genisoimage flag | go-diskfs FinalizeOptions |
|
||||||
|
|-----------------|--------------------------|
|
||||||
|
| `-iso-level 4` | `DeepDirectories: true` |
|
||||||
|
| `-r` (Rock Ridge) | `RockRidge: true` |
|
||||||
|
| `-V DICOM` | `VolumeIdentifier: "DICOM"` |
|
||||||
|
| `-allow-multidot` | Implicit via Rock Ridge |
|
||||||
|
| `-allow-lowercase` | Implicit via Rock Ridge |
|
||||||
|
| `-allow-leading-dots` | Implicit via Rock Ridge |
|
||||||
|
| `-J` (Joliet) | `Joliet: true` |
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
- [ ] **1. Add go-diskfs dependency** — `go get github.com/diskfs/go-diskfs@v1.9.3`
|
||||||
|
- [ ] **2. Create `internal/isobuilder/builder.go`** — `BuildFromDirectory(srcDir, isoPath, volumeLabel string) error`
|
||||||
|
- `dirSize()` helper (walk dir, sum file sizes + 10% overhead margin)
|
||||||
|
- Walk again to create dirs + copy files
|
||||||
|
- Finalize with RockRidge + Joliet + DeepDirectories + VolumeIdentifier
|
||||||
|
- [ ] **3. Update `internal/config/config.go`**
|
||||||
|
- Remove `ToolsConfig.Genisoimage` field
|
||||||
|
- Remove validation `c.Tools.Genisoimage == ""`
|
||||||
|
- Remove `Tools` from Config struct entirely if empty (keep struct, remove field)
|
||||||
|
- [ ] **4. Update `config.example.yaml`** — remove `tools.genisoimage` section
|
||||||
|
- [ ] **5. Update `internal/service/iso.go`**
|
||||||
|
- Replace both `dicom.RunGenISOImage(...)` calls with `isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM")`
|
||||||
|
- Remove unused `dicom` import alias (if no longer needed in scope — it's still used for `countFiles` elsewhere)
|
||||||
|
- [ ] **6. Update `internal/handler/health.go`**
|
||||||
|
- Remove genisoimage from the dependency check array
|
||||||
|
- [ ] **7. Remove `RunGenISOImage` from `pkg/dicom/command.go`**
|
||||||
|
- Delete the entire function
|
||||||
|
- [ ] **8. Build and test**
|
||||||
|
- `go build ./...` must pass
|
||||||
|
- `go vet ./...` must pass
|
||||||
|
- Start server, verify `/api/health` no longer shows genisoimage
|
||||||
|
- Verify ISO endpoint still returns proper errors (PACS unreachable)
|
||||||
190
todo/go-mkiso-implementation.md
Normal file
190
todo/go-mkiso-implementation.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Go mkiso Server — Implementation Checklist
|
||||||
|
|
||||||
|
## Stage 1 — Plain MVP (no auth)
|
||||||
|
|
||||||
|
Goal: Functional replacement for all three PHP scripts. No security — plain GET endpoints.
|
||||||
|
|
||||||
|
- [ ] 1. **Project scaffolding**
|
||||||
|
- Create `go.mod` (`module mkiso-server`, go 1.22+)
|
||||||
|
- Create directory structure (`internal/{config,route,handler,service,repo}`, `pkg/dicom`)
|
||||||
|
- Create `config.example.yaml` with all documented keys (no secrets)
|
||||||
|
- Create `.gitignore` (ignore `config.yaml`, `*.iso`, temp dirs)
|
||||||
|
|
||||||
|
- [ ] 2. **Config loading** — `internal/config/config.go`
|
||||||
|
- Define `Config` struct matching `docs/go-mkiso-design.md` § config.yaml
|
||||||
|
- Load from YAML (`gopkg.in/yaml.v3`) or JSON (`encoding/json` for zero deps)
|
||||||
|
- Validate required fields on load (pacs host, dcmtk paths)
|
||||||
|
- ⚠️ Skip `auth:` section for Stage 1
|
||||||
|
|
||||||
|
- [ ] 3. **DICOM command runner** — `pkg/dicom/command.go`
|
||||||
|
- `StartStoresCP`, `StopStoresCP`, `RunMoveSCU`, `RunStoreSCU`, `RunGenISOImage`
|
||||||
|
- Port allocation: `allocatePort(basePort, portRange)` — mutex-guarded map
|
||||||
|
- All functions return `(exitCode int, stdout, stderr string, error)`
|
||||||
|
- Logging with slog
|
||||||
|
|
||||||
|
- [ ] 4. **Patient repo** — `internal/repo/patient.go`
|
||||||
|
- HTTP client to patient-data API (see `docs/patient-api-spec.md`)
|
||||||
|
- `ByAccessionNumber(ctx, accessionNumber) → (*PatientData, error)`
|
||||||
|
- Configurable auth header from config (for when patient API itself needs auth)
|
||||||
|
- Timeout + retry with backoff
|
||||||
|
|
||||||
|
- [ ] 5. **DICOM service** — `internal/service/dicom.go`
|
||||||
|
- `FetchDICOM(ctx, accessionNumber, destDir) → (filesCount int, error)`
|
||||||
|
- `FetchDICOMMultiple(ctx, accessionNumbers []string, destDir) → (totalFiles int, error)`
|
||||||
|
- storescp lifecycle: start → wait ready → movescu → stop
|
||||||
|
- `defer` for guaranteed storescp cleanup
|
||||||
|
- ⚠️ After FetchDICOM, check `filesCount > 0`. If 0, return error `"no DICOM data"` before attempting ISO/genisoimage.
|
||||||
|
|
||||||
|
- [ ] 6. **ISO service** — `internal/service/iso.go`
|
||||||
|
- `GenerateISO(ctx, accessionNumber) → (isoPath string, cleanup func(), error)`
|
||||||
|
- `GenerateISOMultiple(ctx, accessionNumbers) → (isoPath string, cleanup func(), error)`
|
||||||
|
- Temp dir + microdicom copy + FetchDICOM + genisoimage
|
||||||
|
- Patient API for multi-accession filename
|
||||||
|
|
||||||
|
- [ ] 7. **Relay service** — `internal/service/relay.go` (see `todo/go-print-relay.detail.md`)
|
||||||
|
- `RelayToCDPublisher(ctx, accessionNumbers) → (*RelayResult, error)`
|
||||||
|
- Patient API validation + FetchDICOM + storescu to CD Publisher
|
||||||
|
- Response includes `patient_name`, `destination`, `files_sent`
|
||||||
|
|
||||||
|
- [ ] 8. **Handlers** — `internal/handler/{health,iso,print}.go`
|
||||||
|
- `GET /api/health` — health check + dependency status
|
||||||
|
- `GET /api/iso/download?accession_number=X` — single ISO download
|
||||||
|
- `GET /api/iso/download-multiple?accession_numbers=X,Y,Z` — multi ISO download
|
||||||
|
- `GET /api/iso/print?accession_number=X` — DICOM relay (auto-detects comma → multi)
|
||||||
|
- ⚠️ Print uses ONE endpoint for both single and multi: comma in `accession_number` triggers multi mode, no `print-multiple` endpoint needed (see pre-flight gap #3)
|
||||||
|
|
||||||
|
- [ ] 9. **Route wiring** — `internal/route/route.go`
|
||||||
|
- Go 1.22+ `http.NewServeMux` with method+path patterns
|
||||||
|
- Request ID middleware (UUID, `X-Request-ID` header)
|
||||||
|
- Recovery middleware (catch panics, log stack, return 500)
|
||||||
|
- **Stage 1**: 4 endpoints — health, download, download-multiple, print (single endpoint, auto-detects commas)
|
||||||
|
- **No auth middleware in Stage 1**
|
||||||
|
|
||||||
|
- [ ] 10. **Main entrypoint** — `main.go`
|
||||||
|
- Load config, wire dependencies, start server
|
||||||
|
- Graceful shutdown (SIGINT/SIGTERM)
|
||||||
|
- `slog.Info("server started", "port", cfg.Server.Port)`
|
||||||
|
|
||||||
|
- [ ] 11. **Stage 1 testing**
|
||||||
|
- Test all four endpoints with curl (no auth header needed)
|
||||||
|
- Test concurrent requests
|
||||||
|
- Test error cases
|
||||||
|
- Test through nginx reverse proxy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 2 — Add Authentication
|
||||||
|
|
||||||
|
Goal: Lock down endpoints. **Suggestion below**, implementor picks approach.
|
||||||
|
|
||||||
|
### Recommended: API Key via Reverse 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 ────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx config:**
|
||||||
|
```nginx
|
||||||
|
location ~ ^/(mkiso|mkiso_multiple|send_rimage_multiple)\.php$ {
|
||||||
|
proxy_set_header X-API-Key "${MKISO_API_KEY}";
|
||||||
|
proxy_pass http://127.0.0.1:8080/api/iso/$1$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Direct API access (for testing/other clients)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Go middleware (simple):**
|
||||||
|
```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 {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Zero HIS code changes — nginx handles it
|
||||||
|
- Simple to implement (one string comparison in middleware)
|
||||||
|
- No token expiry, no login endpoint needed
|
||||||
|
- Key rotation = update config.yaml + reload nginx
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Single shared key (no user-level audit)
|
||||||
|
- Key visible in nginx config
|
||||||
|
- No token expiry (must rotate manually)
|
||||||
|
|
||||||
|
### Alternative Suggestions
|
||||||
|
|
||||||
|
| Approach | Complexity | HIS Changes | Best For |
|
||||||
|
|----------|-----------|-------------|----------|
|
||||||
|
| **API Key (recommended)** | Low | None | Internal hospital network, nginx proxy in place |
|
||||||
|
| **Hardcoded JWT** | Low | None (nginx injects) | When you want expiry + claims but no login flow |
|
||||||
|
| **JWT + Login endpoint** | Medium | Yes (or client script) | Multi-client, need user-level audit |
|
||||||
|
| **IP whitelist** | Low | None | Fixed HIS server IP, simplest possible |
|
||||||
|
| **mTLS** | High | Yes (client cert) | Strictest security, external-facing |
|
||||||
|
|
||||||
|
### Stage 2 Todo Items
|
||||||
|
|
||||||
|
- [ ] 12. **Config additions** — `internal/config/config.go`
|
||||||
|
- Add `auth.api_key` field (the expected key)
|
||||||
|
- Add `auth.enabled` boolean (false in Stage 1 config, true in Stage 2)
|
||||||
|
|
||||||
|
- [ ] 13. **API Key middleware** — `internal/middleware/auth.go`
|
||||||
|
- Extract `X-API-Key` from request header
|
||||||
|
- Compare against config value
|
||||||
|
- Return 401 on mismatch
|
||||||
|
- Skip health endpoint (`/api/health` stays public)
|
||||||
|
|
||||||
|
- [ ] 14. **Route wiring update** — `internal/route/route.go`
|
||||||
|
- Apply API key middleware to all `/api/iso/*` routes (download, download-multiple, print)
|
||||||
|
- Keep `/api/health` public
|
||||||
|
|
||||||
|
- [ ] 15. **nginx config** — deploy reverse proxy with header injection
|
||||||
|
- Map old PHP paths to new Go API paths
|
||||||
|
- Inject `X-API-Key` header
|
||||||
|
- Verify HIS `window.open()` calls work end-to-end
|
||||||
|
|
||||||
|
- [ ] 16. **Stage 2 testing**
|
||||||
|
- Test with valid API key → 200
|
||||||
|
- Test with invalid API key → 401
|
||||||
|
- Test with missing API key → 401
|
||||||
|
- Test health endpoint still public
|
||||||
|
- Test through nginx proxy (key injected) → 200
|
||||||
|
- Test direct access without nginx → 401
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 3 (Future)
|
||||||
|
|
||||||
|
- [ ] JWT login endpoint (if multi-user audit needed)
|
||||||
|
- [ ] Rate limiting
|
||||||
|
- [ ] Metrics / Prometheus endpoint
|
||||||
|
- [ ] Docker image
|
||||||
|
- [ ] Remove Java/PHP after Go verified in production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies (go.mod)
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `gopkg.in/yaml.v3` | YAML config parsing *(only external dep)* |
|
||||||
|
|
||||||
|
Everything else: **Go stdlib only**.
|
||||||
367
todo/go-print-relay.detail.md
Normal file
367
todo/go-print-relay.detail.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# Print / DICOM Relay — Implementation Detail
|
||||||
|
|
||||||
|
## PHP Source: `send_rimage_multiple.php`
|
||||||
|
|
||||||
|
The original script has two distinct phases:
|
||||||
|
|
||||||
|
**Phase 1 — DICOM Fetch** (identical to mkiso_multiple.php):
|
||||||
|
1. Parse comma-separated `accession_number` from GET
|
||||||
|
2. DB lookup on HIS DB (`rsabt201107`):
|
||||||
|
- `pacs_result_series` → MEDRECID, RegID (using first accession)
|
||||||
|
- `medrec` → NamaPasien
|
||||||
|
3. Create `/tmp/dicomdir_<uniqid>/DICOMDIR`
|
||||||
|
4. Copy microdicom viewer files
|
||||||
|
5. Nested loop: 14 modalities × N accessions → Java dcmqr C-MOVE
|
||||||
|
|
||||||
|
**Phase 2 — Relay to CD Publisher** (unique to this script):
|
||||||
|
6. `dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR`
|
||||||
|
7. **No genisoimage, no download streaming**
|
||||||
|
8. **No cleanup** (temp dir is leaked — Go version MUST fix this)
|
||||||
|
9. **Bogus headers**: Script sets `Content-Type: application/octet-stream` and `Content-Disposition` with patient-name filename, but never calls `readfile()`. The response body is empty (or dcmsend stdout).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go Implementation
|
||||||
|
|
||||||
|
### Service: `internal/service/relay.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"mkiso-server/pkg/dicom"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RelayService struct {
|
||||||
|
dicomSvc *DicomService
|
||||||
|
patientRepo *repo.PatientRepo // <-- NEW: patient API client
|
||||||
|
cdHost string
|
||||||
|
cdPort int
|
||||||
|
ourAETitle string
|
||||||
|
storescuBin string
|
||||||
|
microdicomSrc string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayResult struct {
|
||||||
|
AccessionsSent []string `json:"accessions_sent"`
|
||||||
|
PatientName string `json:"patient_name"` // from patient API
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
FilesSent int `json:"files_sent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RelayService) RelayToCDPublisher(
|
||||||
|
ctx context.Context,
|
||||||
|
accessionNumbers []string,
|
||||||
|
) (*RelayResult, error) {
|
||||||
|
// Step 1: Validate accession via patient API (replaces DB lookup)
|
||||||
|
// Use first accession to get patient context
|
||||||
|
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("patient API lookup for %q: %w", accessionNumbers[0], err)
|
||||||
|
}
|
||||||
|
s.logger.Info("patient data retrieved for relay",
|
||||||
|
"accession", accessionNumbers[0],
|
||||||
|
"patient", patient.PatientName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 2: Create temp dir
|
||||||
|
tempDir, err := os.MkdirTemp(cfg.TempDir, "dicomdir_")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir) // GUARANTEED cleanup (fixes PHP leak)
|
||||||
|
|
||||||
|
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
|
||||||
|
if err := copyMicrodicom(s.microdicomSrc, tempDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("copy microdicom: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch DICOM from PACS (same as download flow)
|
||||||
|
filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("DICOM fetch failed for %v: %w", accessionNumbers, err)
|
||||||
|
}
|
||||||
|
s.logger.Info("DICOM fetched for relay",
|
||||||
|
"accessions", accessionNumbers,
|
||||||
|
"files", filesRetrieved,
|
||||||
|
"dir", dicomDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relay to CD Publisher via storescu
|
||||||
|
destination := fmt.Sprintf("%s:%d", s.cdHost, s.cdPort)
|
||||||
|
exitCode, stdout, stderr, err := dicom.RunStoreSCU(
|
||||||
|
ctx,
|
||||||
|
s.storescuBin,
|
||||||
|
s.ourAETitle,
|
||||||
|
s.cdHost,
|
||||||
|
s.cdPort,
|
||||||
|
dicomDir,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storescu failed: %w", err)
|
||||||
|
}
|
||||||
|
if exitCode != 0 {
|
||||||
|
return nil, fmt.Errorf("storescu exit %d: %s", exitCode, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("DICOM relayed to CD Publisher",
|
||||||
|
"accessions", accessionNumbers,
|
||||||
|
"destination", destination,
|
||||||
|
"files", filesRetrieved,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &RelayResult{
|
||||||
|
AccessionsSent: accessionNumbers,
|
||||||
|
PatientName: patient.PatientName,
|
||||||
|
Destination: destination,
|
||||||
|
FilesSent: filesRetrieved,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DICOM Command: `pkg/dicom/command.go` — add `RunStoreSCU`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RunStoreSCU sends DICOM files to a remote AE via C-STORE.
|
||||||
|
// Equivalent to: storescu -aet <ae> +sd +r <host> <port> <dir>
|
||||||
|
func RunStoreSCU(
|
||||||
|
ctx context.Context,
|
||||||
|
storescuBin, aeTitle, host string,
|
||||||
|
port int,
|
||||||
|
dir string,
|
||||||
|
) (exitCode int, stdout, stderr string, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx,
|
||||||
|
storescuBin,
|
||||||
|
"-aet", aeTitle,
|
||||||
|
"+sd", // scan directories
|
||||||
|
"+r", // recurse
|
||||||
|
host,
|
||||||
|
strconv.Itoa(port),
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
stdout = outBuf.String()
|
||||||
|
stderr = errBuf.String()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
return exitCode, stdout, stderr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, stdout, stderr, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handler: `internal/handler/print.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := relaySvc.RelayToCDPublisher(r.Context(), []string{acc})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("print ISO failed", "accession", acc, "error", err)
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]string{
|
||||||
|
"error": "DICOM relay failed",
|
||||||
|
"destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port),
|
||||||
|
"detail": err.Error(),
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintISOMultiple(relaySvc *service.RelayService) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := r.URL.Query().Get("accession_numbers")
|
||||||
|
if raw == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_numbers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accs := strings.Split(raw, ",")
|
||||||
|
for i, a := range accs {
|
||||||
|
accs[i] = strings.TrimSpace(a)
|
||||||
|
if accs[i] == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty accession_number in list"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := relaySvc.RelayToCDPublisher(r.Context(), accs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("print ISO multiple failed", "accessions", accs, "error", err)
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||||
|
"error": "DICOM relay failed",
|
||||||
|
"destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port),
|
||||||
|
"detail": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"accessions_sent": result.AccessionsSent,
|
||||||
|
"destination": result.Destination,
|
||||||
|
"files_sent": result.FilesSent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Additions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml — new cd_publisher block
|
||||||
|
cd_publisher:
|
||||||
|
host: "172.16.0.120"
|
||||||
|
port: 104
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Struct: `internal/config/config.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
CDPublisher CDPublisherConfig `yaml:"cd_publisher"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CDPublisherConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Differences from Download Flow
|
||||||
|
|
||||||
|
| Aspect | Download Multiple | Print/Relay |
|
||||||
|
|--------|-------------------|-------------|
|
||||||
|
| Output | ISO file streamed to browser | DICOM sent to CD Publisher |
|
||||||
|
| Response | `Content-Type: application/octet-stream` | `Content-Type: application/json` |
|
||||||
|
| genisoimage | ✅ Required | ❌ Not used |
|
||||||
|
| storescu | ❌ Not used | ✅ Required (to CD Publisher) |
|
||||||
|
| Patient API | ✅ For filename | ✅ For validation + response context |
|
||||||
|
| Filename | `{name}-{accs}.iso` | N/A (JSON response) |
|
||||||
|
| Cleanup | After streaming completes | After relay completes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## storescu Command
|
||||||
|
|
||||||
|
Replaces `dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/data/dcmtk-bin/storescu \
|
||||||
|
-aet CDRECORD \
|
||||||
|
+sd \ # scan directories
|
||||||
|
+r \ # recurse
|
||||||
|
172.16.0.120 104 \
|
||||||
|
/tmp/dicomdir_xxx/DICOMDIR
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `-aet CDRECORD` | Our AE title (calling) |
|
||||||
|
| `+sd` | Scan subdirectories for DICOM files |
|
||||||
|
| `+r` | Recurse into subdirectories |
|
||||||
|
| `172.16.0.120 104` | CD Publisher host:port |
|
||||||
|
| `/tmp/dicomdir_xxx/DICOMDIR` | Directory containing DICOM files |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Cases
|
||||||
|
|
||||||
|
| Scenario | Response |
|
||||||
|
|----------|----------|
|
||||||
|
| Invalid accession (patient API 404) | `404 {"error":"accession_number not found"}` |
|
||||||
|
| Patient API unavailable | `502 {"error":"patient API unavailable"}` |
|
||||||
|
| CD Publisher unreachable | `502 {"error":"CD Publisher unreachable","destination":"..."}` |
|
||||||
|
| CD Publisher rejects association | `502 {"error":"DICOM relay failed","detail":"association rejected"}` |
|
||||||
|
| DICOM fetch failed (PACS down) | `502 {"error":"PACS unreachable"}` |
|
||||||
|
| No DICOM files fetched | `502 {"error":"no DICOM data for accession_number"}` |
|
||||||
|
| Partial relay (some files failed) | storescu may return 0 but log warnings — Go should parse stderr |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration with HIS Frontend
|
||||||
|
|
||||||
|
The `pacs_downloadiso` module JS calls:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// printiso() in index.php
|
||||||
|
window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+AccessionNumber);
|
||||||
|
|
||||||
|
// fn_printisomultiple() in index.php
|
||||||
|
window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+an);
|
||||||
|
```
|
||||||
|
|
||||||
|
The Go API returns **JSON**, not an HTML page. The HIS JS uses `window.open()` — it will open a new tab/window showing the JSON.
|
||||||
|
|
||||||
|
**Recommendation:** The nginx proxy should handle this. Or the HIS module could be updated to use AJAX instead of `window.open()` for print. For now, opening a JSON tab is acceptable (shows success/failure to the operator).
|
||||||
|
|
||||||
|
Better: if the `window.open` approach is kept, the Go handler could return a simple HTML page instead of raw JSON for the print endpoint (check `Accept` header or add a `?format=html` query param).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test single accession relay
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/api/iso/print?accession_number=MR.2024.001"
|
||||||
|
|
||||||
|
# Expected: {"status":"ok","accessions_sent":["MR.2024.001"],"patient_name":"JOHN DOE","destination":"172.16.0.120:104","files_sent":42}
|
||||||
|
|
||||||
|
# Test multiple accession relay
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/api/iso/print-multiple?accession_numbers=MR.001,CT.002"
|
||||||
|
|
||||||
|
# Test error: invalid accession (patient API returns 404)
|
||||||
|
# Expected: 404 {"error":"accession_number not found"}
|
||||||
|
|
||||||
|
# Test error: CD Publisher offline
|
||||||
|
# Expected: 502 {"error":"CD Publisher unreachable"}
|
||||||
|
|
||||||
|
# Test error: patient API unavailable
|
||||||
|
# Expected: 502 {"error":"patient API unavailable"}
|
||||||
|
|
||||||
|
# Test storescu directly (dev workstation)
|
||||||
|
storescu -aet CDRECORD +sd +r 172.16.0.120 104 /tmp/test_dicom/DICOMDIR
|
||||||
|
```
|
||||||
218
todo/mkiso-replace-java-with-dcmtk.detail.md
Normal file
218
todo/mkiso-replace-java-with-dcmtk.detail.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# mkiso dcmtk Replacement — Implementation Details
|
||||||
|
|
||||||
|
## Core Architectural Change
|
||||||
|
|
||||||
|
dcm4che2's `dcmqr` is a **monolithic** tool: one process handles the storage SCP, C-MOVE SCU, query, AND file writing.
|
||||||
|
dcmtk separates these into **two processes** that must run concurrently:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ dcm4che2 dcmqr (single process) │
|
||||||
|
│ ┌─────────────────────────────────────┐│
|
||||||
|
│ │ Storage SCP (listen CDRECORD:10104) ││
|
||||||
|
│ │ C-MOVE SCU ││
|
||||||
|
│ │ File writer (−cstoredest) ││
|
||||||
|
│ └─────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ storescp │ │ movescu │
|
||||||
|
│ AE:CDRECORD │ │ AE:CDRECORD │
|
||||||
|
│ port:10104 │ │ -aem CDRECORD│
|
||||||
|
│ -od DICOMDIR│ │ +P 10104 │
|
||||||
|
└──────┬───────┘ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
│ receives │ sends C-MOVE-RQ
|
||||||
|
│ C-STORE │ to PACS
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ PACS (ABPACS:11112) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Templates
|
||||||
|
|
||||||
|
### 1. storescp (background receiver)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start storage SCP in background, capture PID
|
||||||
|
/data/dcmtk-bin/storescp 10104 \
|
||||||
|
-aet CDRECORD \
|
||||||
|
-od "${dicomdir}/DICOMDIR" \
|
||||||
|
+xa \
|
||||||
|
&> /tmp/storescp_${uniq}.log &
|
||||||
|
|
||||||
|
STORESCP_PID=$!
|
||||||
|
|
||||||
|
# Give storescp time to bind the port
|
||||||
|
sleep 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key flags:**
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `10104` | Listen port (must match what PACS sends to) |
|
||||||
|
| `-aet CDRECORD` | AE title (must match C-MOVE destination AE) |
|
||||||
|
| `-od DICOMDIR` | Output directory for received files |
|
||||||
|
| `+xa` | Accept all transfer syntaxes (max compatibility) |
|
||||||
|
|
||||||
|
### 2. movescu (C-MOVE initiator) — Simplified (no modality loop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/data/dcmtk-bin/movescu \
|
||||||
|
-aet CDRECORD \
|
||||||
|
-aec ABPACS \
|
||||||
|
-aem CDRECORD \
|
||||||
|
localhost 11112 \
|
||||||
|
-S \
|
||||||
|
-k 0008,0050="${accession_number}" \
|
||||||
|
-k 0010,0020="" \
|
||||||
|
--no-port
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key flags:**
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `-aet CDRECORD` | Our AE title (calling) |
|
||||||
|
| `-aec ABPACS` | PACS AE title (called) |
|
||||||
|
| `-aem CDRECORD` | Move destination AE (tells PACS to C-STORE to CDRECORD) |
|
||||||
|
| `localhost 11112` | PACS host:port |
|
||||||
|
| `-S` | Study Root query model |
|
||||||
|
| `-k 0008,0050=X` | Accession Number query key |
|
||||||
|
| `-k 0010,0020=""` | Patient ID = wildcard (required for Study Root) |
|
||||||
|
| `--no-port` | No incoming port on movescu (storescp handles incoming) |
|
||||||
|
|
||||||
|
### 3. movescu — Conservative (with modality loop, replicating original behavior)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Same as above but add modality filter via query key
|
||||||
|
/data/dcmtk-bin/movescu \
|
||||||
|
-aet CDRECORD \
|
||||||
|
-aec ABPACS \
|
||||||
|
-aem CDRECORD \
|
||||||
|
localhost 11112 \
|
||||||
|
-S \
|
||||||
|
-k 0008,0050="${accession_number}" \
|
||||||
|
-k 0010,0020="" \
|
||||||
|
-k 0008,0060="${modality_code}" \
|
||||||
|
--no-port
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `-k 0008,0060=MR` filters queries to studies with Modality=MR.
|
||||||
|
This is NOT identical to dcm4che2's `-cstore MR` (which filters at the association/presentation-context level).
|
||||||
|
For most PACS, the query-level filter is sufficient. If the PACS requires specific presentation context negotiation per modality, the simplified approach (no modality loop) with `+xa` (accept all) is recommended.
|
||||||
|
|
||||||
|
### 4. Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After movescu completes (or times out):
|
||||||
|
kill $STORESCP_PID 2>/dev/null
|
||||||
|
wait $STORESCP_PID 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-File Replacements
|
||||||
|
|
||||||
|
### mkiso.php — current Java block (lines ~56-59)
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```php
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
$cmd = "JAVA_HOME=/usr/lib/jvm/jdk1.8.0_144 LANG=en_US.iso-8859-1 /usr/local/dcm4che/dcm4che2/bin/dcmqr -L CDRECORD:10104 ABPACS@localhost:11112 -cmove CDRECORD -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with (simplified approach):**
|
||||||
|
```php
|
||||||
|
// Start storescp as background storage receiver
|
||||||
|
$storescp_pid = exec("/data/dcmtk-bin/storescp 10104 -aet CDRECORD -od ${dicomdir}/DICOMDIR +xa &> /tmp/storescp_" . basename($dicomdir) . ".log & echo $!");
|
||||||
|
|
||||||
|
// Allow storescp to bind port
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// Single C-MOVE for the accession number (Study Root model)
|
||||||
|
$cmd = "/data/dcmtk-bin/movescu -aet CDRECORD -aec ABPACS -aem CDRECORD localhost 11112 -S -k 0008,0050=${accession_number} -k 0010,0020= --no-port";
|
||||||
|
exec($cmd, $outputRes, $exitCode);
|
||||||
|
|
||||||
|
// Clean up storescp
|
||||||
|
exec("kill $storescp_pid 2>/dev/null");
|
||||||
|
```
|
||||||
|
|
||||||
|
### mkiso2.php — current Java block (lines ~56-59)
|
||||||
|
|
||||||
|
Same C-MOVE replacement as mkiso.php.
|
||||||
|
|
||||||
|
**Additionally, replace dcmsend (line ~62):**
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```php
|
||||||
|
$cmd = "/usr/bin/dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```php
|
||||||
|
$cmd = "/data/dcmtk-bin/storescu -aet CDRECORD +sd +r 172.16.0.120 104 ${dicomdir}/DICOMDIR";
|
||||||
|
```
|
||||||
|
|
||||||
|
`/usr/bin/dcmsend` does not exist on the current system. `storescu +sd +r` (scan directories + recurse) is the dcmtk equivalent.
|
||||||
|
|
||||||
|
### mkiso_multiple.php — current Java block (lines ~80-87)
|
||||||
|
|
||||||
|
**Current (nested loop):**
|
||||||
|
```php
|
||||||
|
foreach($modalities as $cstore=>$v) {
|
||||||
|
foreach($as as $accession_number) {
|
||||||
|
$cmd = "JAVA_HOME=... dcmqr ... -qAccessionNumber=${accession_number} -cstore $cstore -cstoredest $dicomdir/DICOMDIR";
|
||||||
|
exec($cmd, $outputRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with (simplified, one C-MOVE per accession):**
|
||||||
|
```php
|
||||||
|
// Start storescp once for all accessions
|
||||||
|
$storescp_pid = exec("/data/dcmtk-bin/storescp 10104 -aet CDRECORD -od ${dicomdir}/DICOMDIR +xa &> /tmp/storescp_" . basename($dicomdir) . ".log & echo $!");
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
foreach($as as $accession_number) {
|
||||||
|
$cmd = "/data/dcmtk-bin/movescu -aet CDRECORD -aec ABPACS -aem CDRECORD localhost 11112 -S -k 0008,0050=${accession_number} -k 0010,0020= --no-port";
|
||||||
|
exec($cmd, $outputRes, $exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec("kill $storescp_pid 2>/dev/null");
|
||||||
|
```
|
||||||
|
|
||||||
|
## DICOM Tag Reference
|
||||||
|
|
||||||
|
| Tag | Name | Used As |
|
||||||
|
|-----|------|---------|
|
||||||
|
| (0008,0050) | Accession Number | Query key — filters by accession |
|
||||||
|
| (0008,0052) | Query/Retrieve Level | Set by `-S` (Study) — queries at study level |
|
||||||
|
| (0008,0060) | Modality | Query key — filters by modality (if using conservative approach) |
|
||||||
|
| (0010,0020) | Patient ID | Required wildcard for Study Root Q/R |
|
||||||
|
|
||||||
|
## Error Handling Considerations
|
||||||
|
|
||||||
|
1. **storescp already bound**: If `10104` is in use, storescp will fail. Use a random port or check first.
|
||||||
|
2. **movescu timeout**: Default is unlimited. Consider `-to 300` (5 min timeout) for production.
|
||||||
|
3. **PACS unreachable**: movescu returns non-zero exit code — PHP should handle this (currently scripts have no error handling).
|
||||||
|
4. **Partial retrieval**: movescu may return success even if some images fail. The exit code reflects the C-MOVE response status.
|
||||||
|
5. **Race condition**: The `sleep 1` is a heuristic. For production, consider polling to check storescp is ready.
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test storescp starts correctly
|
||||||
|
/data/dcmtk-bin/storescp 10104 -aet CDRECORD -od /tmp/test_dicom +xa &
|
||||||
|
PID=$!
|
||||||
|
sleep 1
|
||||||
|
echo "storescp PID: $PID (should be running)"
|
||||||
|
kill $PID
|
||||||
|
|
||||||
|
# Test movescu against PACS (dry run verification)
|
||||||
|
/data/dcmtk-bin/movescu -aet CDRECORD -aec ABPACS -aem CDRECORD localhost 11112 \
|
||||||
|
-S -k "0008,0050=MR.180505.026" -k "0010,0020=" --no-port -v
|
||||||
|
|
||||||
|
# Test storescu (replacement for dcmsend)
|
||||||
|
/data/dcmtk-bin/storescu -aet CDRECORD +sd +r 172.16.0.120 104 /tmp/test_dicom/ -v
|
||||||
|
```
|
||||||
39
todo/mkiso-replace-java-with-dcmtk.md
Normal file
39
todo/mkiso-replace-java-with-dcmtk.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Replace Java dcm4che2 `dcmqr` with dcmtk CLI
|
||||||
|
|
||||||
|
- [ ] 1. **Replace Java dcmqr in `mkiso.php`** — **see `todo/mkiso-replace-java-with-dcmtk.detail.md` § "mkiso.php"**
|
||||||
|
- Start `storescp` as background receiver, run single `movescu` per accession (no modality loop)
|
||||||
|
- Verify ISO generation, download, and cleanup still work
|
||||||
|
|
||||||
|
- [ ] 2. **Replace Java dcmqr in `mkiso2.php`** — **see `todo/mkiso-replace-java-with-dcmtk.detail.md` § "mkiso2.php"**
|
||||||
|
- ⚠️ **LOW PRIORITY** — `mkiso2.php` is NOT called by the HIS `pacs_downloadiso` module. It is a standalone DICOM relay script, possibly run manually or by cron. See `docs/pacs_downloadiso-usage.md` for integration analysis.
|
||||||
|
- Same C-MOVE replacement as mkiso.php
|
||||||
|
- Also replace `/usr/bin/dcmsend` (missing binary) with `/data/dcmtk-bin/storescu +sd +r`
|
||||||
|
- Verify DICOM relay to `172.16.0.120:104` works
|
||||||
|
|
||||||
|
- [ ] 3. **Replace Java dcmqr in `mkiso_multiple.php`** — **see `todo/mkiso-replace-java-with-dcmtk.detail.md` § "mkiso_multiple.php"**
|
||||||
|
- Start `storescp` once, run `movescu` for each accession in the list
|
||||||
|
- DB lookup logic stays unchanged
|
||||||
|
- Verify multi-accession ISO download works with patient-name filenames
|
||||||
|
|
||||||
|
- [ ] 4. **Environment setup**
|
||||||
|
- Ensure `/data/dcmtk-bin/` is readable and executable by the web server user
|
||||||
|
- ⚠️ **Concurrent requests confirmed** — multiple HIS users can trigger downloads simultaneously via `pacs_downloadiso` module. Must use unique port per request.
|
||||||
|
- If multiple concurrent requests are possible, use a unique port per request (e.g., `$port = 10104 + getmypid() % 100`)
|
||||||
|
- No changes needed in the HIS `pacs_downloadiso` module — it calls scripts on the PACS host by URL only
|
||||||
|
|
||||||
|
- [ ] 5. **Testing**
|
||||||
|
- Test with real accession numbers on the production server
|
||||||
|
- Verify all DICOM files retrieved (spot-check file count and content)
|
||||||
|
- Verify ISO downloads work end-to-end (download + mount + open in viewer)
|
||||||
|
- Test through the HIS `pacs_downloadiso` UI (Preview → Download ISO) to verify end-to-end integration still works
|
||||||
|
- Test both single and multi-accession download flows
|
||||||
|
- Verify mkiso2.php relay to `172.16.0.120:104` (if migrated)
|
||||||
|
- Test error cases: invalid accession number, PACS unreachable, timeout
|
||||||
|
- Check storescp process is always cleaned up (no zombie processes)
|
||||||
|
- Verify concurrent downloads from multiple HIS users don't conflict
|
||||||
|
- Test Print ISO (CD Publisher) still works (not part of dcmtk migration, regression check only)
|
||||||
|
|
||||||
|
- [ ] 6. **Remove Java dependency** (after dcmtk verified in production)
|
||||||
|
- Remove `/usr/local/dcm4che/dcm4che2/` if no other tools need it
|
||||||
|
- Remove JDK 1.8.0_144 if no other Java apps on server
|
||||||
|
- Remove `JAVA_HOME` environment variable
|
||||||
89
todo/pre-flight.md
Normal file
89
todo/pre-flight.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Pre-Flight Checklist — Before Implementation
|
||||||
|
|
||||||
|
## Decisions needed before writing code
|
||||||
|
|
||||||
|
- [ ] **YAML or JSON config?**
|
||||||
|
- YAML = 1 external dep (`gopkg.in/yaml.v3`), more human-readable
|
||||||
|
- JSON = zero external deps (`encoding/json`), `config.json` instead of `config.yaml`
|
||||||
|
- **Recommendation**: JSON for true stdlib-only. YAML is fine if 1 dep is ok.
|
||||||
|
|
||||||
|
- [ ] **Patient API availability during dev**
|
||||||
|
- The patient-data API doesn't exist yet (only spec in `docs/patient-api-spec.md`)
|
||||||
|
- Option A: Build a thin PHP stub first (copy the reference impl from the spec)
|
||||||
|
- Option B: Make patient API call optional with graceful degradation in Stage 1
|
||||||
|
- **Recommendation**: B for Stage 1 (multi-accession ISO falls back to accession-list filename if API unavailable). Build the real API before Stage 2.
|
||||||
|
|
||||||
|
- [ ] **`send_rimage_multiple.php` single vs multi routing**
|
||||||
|
- The HIS JS calls `send_rimage_multiple.php?accession_number=X` for single AND `send_rimage_multiple.php?accession_number=X,Y,Z` for multi
|
||||||
|
- The Go API has TWO endpoints: `/api/iso/print` (single) and `/api/iso/print-multiple` (multi)
|
||||||
|
- **Problem**: nginx can't distinguish them — same PHP file, same query param name
|
||||||
|
- **Fix**: Merge into one Go endpoint that auto-detects commas:
|
||||||
|
- `GET /api/iso/print?accession_number=X` → single
|
||||||
|
- `GET /api/iso/print?accession_number=X,Y,Z` → comma → multi
|
||||||
|
- Then nginx maps `send_rimage_multiple.php` → `/api/iso/print` only
|
||||||
|
|
||||||
|
- [ ] **0-file edge case in DICOM fetch**
|
||||||
|
- What if PACS returns success but 0 files? (valid accession, no images)
|
||||||
|
- Current plan doesn't explicitly handle this
|
||||||
|
- **Fix**: After FetchDICOM, check `filesCount > 0`. If 0, return `404 {"error":"no DICOM data"}` before attempting ISO creation.
|
||||||
|
|
||||||
|
- [ ] **microdicom viewer files path**
|
||||||
|
- Config: `iso.microdicom_path: "/var/www/html/microdicom"`
|
||||||
|
- These files are in `raw/microdicom/` in this project
|
||||||
|
- Must exist on the PACS server at the configured path
|
||||||
|
- If missing, ISO will still work (just no embedded viewer)
|
||||||
|
|
||||||
|
- [ ] **CD Publisher reachability**
|
||||||
|
- Config: `cd_publisher.host: "172.16.0.120"`, port 104
|
||||||
|
- May not be reachable from dev environment
|
||||||
|
- storescu will fail with connection refused — handle gracefully
|
||||||
|
|
||||||
|
- [ ] **Config path**
|
||||||
|
- Hardcode: `config.yaml` in working directory
|
||||||
|
- Or env: `MKISO_CONFIG=/etc/mkiso/config.yaml`
|
||||||
|
- **Recommendation**: env var with fallback to `./config.yaml`
|
||||||
|
|
||||||
|
## Files that need to be created (not just spec'd)
|
||||||
|
|
||||||
|
| File | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `go.mod` | Create with `module mkiso-server` |
|
||||||
|
| `config.example.yaml` | Template from design doc § config.yaml |
|
||||||
|
| `main.go` | Entrypoint |
|
||||||
|
| `internal/config/config.go` | Config loading |
|
||||||
|
| `internal/route/route.go` | Route wiring |
|
||||||
|
| `internal/middleware/auth.go` | API key middleware (Stage 2) |
|
||||||
|
| `internal/handler/health.go` | Health check |
|
||||||
|
| `internal/handler/iso.go` | ISO download handlers |
|
||||||
|
| `internal/handler/print.go` | Print/relay handlers |
|
||||||
|
| `internal/service/dicom.go` | DICOM orchestration |
|
||||||
|
| `internal/service/iso.go` | ISO creation |
|
||||||
|
| `internal/service/relay.go` | CD Publisher relay |
|
||||||
|
| `internal/repo/patient.go` | Patient API client |
|
||||||
|
| `pkg/dicom/command.go` | DICOM binary runner |
|
||||||
|
|
||||||
|
## Implementation order (respects dependencies)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. config.go (no deps)
|
||||||
|
2. pkg/dicom/command.go (no deps)
|
||||||
|
3. repo/patient.go (depends on config)
|
||||||
|
4. service/dicom.go (depends on #2)
|
||||||
|
5. service/iso.go (depends on #3, #4)
|
||||||
|
6. service/relay.go (depends on #3, #4)
|
||||||
|
7. handler/health.go (no deps)
|
||||||
|
8. handler/iso.go (depends on #5)
|
||||||
|
9. handler/print.go (depends on #6)
|
||||||
|
10. route/route.go (depends on #7, #8, #9)
|
||||||
|
11. middleware/auth.go (depends on config) — Stage 2
|
||||||
|
12. main.go (depends on #10)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known gaps in the plan (addressed above)
|
||||||
|
|
||||||
|
| Gap | Resolution |
|
||||||
|
|-----|------------|
|
||||||
|
| Nginx regex mapping bug (old `$1` didn't map to Go paths) | Fixed — explicit `location =` blocks with correct `proxy_pass` targets |
|
||||||
|
| `print` vs `print-multiple` routing conflict | Merge into single endpoint with comma detection. Update handler accordingly. |
|
||||||
|
| 0-files from PACS not handled | Check `filesCount > 0` after FetchDICOM, return 404 if empty |
|
||||||
|
| Patient API doesn't exist yet | Graceful degradation in Stage 1 (skip patient name for filename) |
|
||||||
Reference in New Issue
Block a user