commit 983667a76a87aad05ab1b0a4929f67af69079fe6 Author: varch Date: Fri Jun 5 08:11:44 2026 +0700 feat: base go mkiso diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c09682e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..5f55bc8 --- /dev/null +++ b/config.example.yaml @@ -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" diff --git a/docs/go-mkiso-design.md b/docs/go-mkiso-design.md new file mode 100644 index 0000000..4195507 --- /dev/null +++ b/docs/go-mkiso-design.md @@ -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: + +GET /api/iso/print?accession_number=X,Y,Z +X-API-Key: +``` + +--- + +## 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_/ + ├─ service.iso.go: GenerateISO() + │ ├─ service.dicom.go: FetchDICOM() + │ │ ├─ pkg/dicom: StartStoresCP() → storescp as child process + │ │ │ storescp -aet CDRECORD -od +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= + │ │ │ -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 .iso + │ └─ 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_/ + ├─ 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 +sd +r + │ ├─ 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_/ +``` + +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 (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; +} +``` diff --git a/docs/howto-manual-iso.md b/docs/howto-manual-iso.md new file mode 100644 index 0000000..a2404e6 --- /dev/null +++ b/docs/howto-manual-iso.md @@ -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 +``` diff --git a/docs/mkiso-analysis.md b/docs/mkiso-analysis.md new file mode 100644 index 0000000..27f0344 --- /dev/null +++ b/docs/mkiso-analysis.md @@ -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_/ +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) | diff --git a/docs/pacs_downloadiso-usage.md b/docs/pacs_downloadiso-usage.md new file mode 100644 index 0000000..685ae5e --- /dev/null +++ b/docs/pacs_downloadiso-usage.md @@ -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 = "..."; +} else { + $Action = "..."; +} +``` + +--- + +## 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 diff --git a/docs/patient-api-spec.md b/docs/patient-api-spec.md new file mode 100644 index 0000000..9459034 --- /dev/null +++ b/docs/patient-api-spec.md @@ -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 ` — if the HIS exposes standard JWT +- `X-API-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 + '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 +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..15b34b7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8fd730e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7315764 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..adef8eb --- /dev/null +++ b/internal/handler/health.go @@ -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) + } +} diff --git a/internal/handler/iso.go b/internal/handler/iso.go new file mode 100644 index 0000000..7be039d --- /dev/null +++ b/internal/handler/iso.go @@ -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 +} diff --git a/internal/handler/print.go b/internal/handler/print.go new file mode 100644 index 0000000..03cdbe0 --- /dev/null +++ b/internal/handler/print.go @@ -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, + }) + } +} diff --git a/internal/isobuilder/builder.go b/internal/isobuilder/builder.go new file mode 100644 index 0000000..59e748c --- /dev/null +++ b/internal/isobuilder/builder.go @@ -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