From 235d520cecadddb0fdf532c525dad68f5a88973f Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 17:46:10 -0700 Subject: [PATCH 01/10] chore: bump ESLint ecmaVersion to 2020 Enables optional chaining (?.) and nullish coalescing (??) which are supported by Node >= 16 but blocked by the previous ecmaVersion: 2018. Co-Authored-By: Claude Sonnet 4.6 --- .eslintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 72476da2..2325f146 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,7 +11,7 @@ "SharedArrayBuffer": "readonly" }, "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2020 }, "rules": { "prettier/prettier": ["error", { "trailingComma": "es5" }], From 0def777dd52d4462f6a4e093da4ce36b8b376b97 Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 17:46:17 -0700 Subject: [PATCH 02/10] feat: add POST /exportlinks endpoint to generate encrypted study share links Accepts a list of {subject, study, aimuid} pairs and returns per-study AES-256-CBC encrypted URLs along with AIM comment and optionally study description (studyDesc query param, default false). Output format is text (tab-separated) or json via outputType param. Includes tests. Co-Authored-By: Claude Sonnet 4.6 --- config/test.js | 2 + plugins/Other.js | 67 ++++++++++++++++++++ routes/other.js | 28 +++++++++ test/otherTest.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+) diff --git a/config/test.js b/config/test.js index a5c631ce..df5dbb9c 100644 --- a/config/test.js +++ b/config/test.js @@ -22,4 +22,6 @@ module.exports = { auditLog: true, versionAudit: true, teachingProject: 'testaim', + secret: 'testsecret', + baseUrl: 'http://localhost:5987', }; diff --git a/plugins/Other.js b/plugins/Other.js index 63b936f8..5e415a69 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3152,6 +3152,73 @@ async function other(fastify) { } }); + fastify.decorate('encryptInternal', (queryParams) => { + if (!config.secret) throw new Error('No secret defined'); + const key = crypto.createHash('sha256').update(config.secret, 'utf8').digest(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + cipher.setAutoPadding(true); + const ciphertext = Buffer.concat([cipher.update(queryParams, 'utf8'), cipher.final()]); + const ivLenBuf = Buffer.alloc(4); + ivLenBuf.writeInt32BE(iv.length, 0); + return Buffer.concat([ivLenBuf, iv, ciphertext]).toString('base64'); + }); + + fastify.decorate('exportLinks', async (request, reply) => { + try { + const pairs = request.body; + const outputType = request.query.outputType || 'text'; + const fetchStudyDesc = request.query.studyDesc === true; + + const results = await Promise.all( + pairs.map(async ({ subject, study, aimuid }) => { + const db = fastify.couch.db.use(config.db); + const aimDoc = await new Promise((resolve, reject) => + db.get(aimuid, (err, body) => (err ? reject(err) : resolve(body))) + ); + const imageAnnotation = + aimDoc?.aim?.ImageAnnotationCollection?.imageAnnotations?.ImageAnnotation; + const ia = Array.isArray(imageAnnotation) ? imageAnnotation[0] : imageAnnotation; + const rawComment = ia?.comment?.value ?? ''; + const comment = rawComment.split('~~')[0]; + + let studyDescription = ''; + if (fetchStudyDesc) { + try { + const studyList = await fastify.getPatientStudiesInternal( + { subject, study }, + {}, + request.epadAuth, + {}, + true + ); + studyDescription = studyList?.[0]?.studyDescription ?? ''; + } catch (_e) { + // DICOMweb unavailable — leave empty + } + } + + const queryParams = `patientID=${subject}&studyUID=${study}`; + const encrypted = fastify.encryptInternal(queryParams); + const link = `${config.baseUrl}?arg=${encodeURIComponent(encrypted)}`; + + return { study_desc: studyDescription, study_uid: study, comment, link }; + }) + ); + + if (outputType === 'json') { + reply.send(results); + } else { + const text = results + .map((r) => `${r.study_uid}\t${r.study_desc}\t${r.comment}\t${r.link}`) + .join('\n'); + reply.type('text/plain').send(text); + } + } catch (err) { + reply.send(new InternalError('Generate links', err)); + } + }); + fastify.decorate('decryptInternal', async (encrypted) => { if (!config.secret) { throw new Error('No secret defined'); diff --git a/routes/other.js b/routes/other.js index 76355997..5ed4d4a0 100644 --- a/routes/other.js +++ b/routes/other.js @@ -513,6 +513,34 @@ async function otherRoutes(fastify) { handler: fastify.decryptAdd, }); + fastify.route({ + method: 'POST', + url: '/exportlinks', + schema: { + tags: ['link'], + querystring: { + type: 'object', + properties: { + outputType: { type: 'string', enum: ['json', 'text'], default: 'text' }, + studyDesc: { type: 'boolean', default: false }, + }, + }, + body: { + type: 'array', + items: { + type: 'object', + required: ['subject', 'study', 'aimuid'], + properties: { + subject: { type: 'string' }, + study: { type: 'string' }, + aimuid: { type: 'string' }, + }, + }, + }, + }, + handler: fastify.exportLinks, + }); + fastify.route({ method: 'POST', url: '/reports/waterfall', diff --git a/test/otherTest.js b/test/otherTest.js index 4d80bd3a..efee64d2 100644 --- a/test/otherTest.js +++ b/test/otherTest.js @@ -289,6 +289,160 @@ describe('Other Tests', () => { }); }); + describe('Export Links Tests', () => { + const aimUID = '2.25.211702350959705565754863799143359605362'; + const studyUID = '1.3.12.2.1107.5.8.2.484849.837749.68675556.20031107184420110'; + const subject = '13116'; + + before(async () => { + const jsonBuffer = JSON.parse(fs.readFileSync('test/data/roi_sample_aim.json')); + await chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/aims') + .send(jsonBuffer) + .query({ username: 'admin' }); + }); + + after(async () => { + await chai + .request(`http://${process.env.host}:${process.env.port}`) + .delete(`/aims/${aimUID}`) + .query({ username: 'admin' }); + }); + + it('should return 200 with JSON array when outputType=json', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body).to.be.an('array'); + expect(res.body).to.have.lengthOf(1); + expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'comment', 'link'); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return correct study_uid and comment from AIM', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body[0].study_uid).to.equal(studyUID); + expect(res.body[0].comment).to.equal('CT / / 37 / 2'); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return empty study_desc when studyDesc is not set (default false)', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body[0].study_desc).to.equal(''); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return 200 with empty study_desc when studyDesc=true but DICOMweb unavailable', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', studyDesc: true, username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'comment', 'link'); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return an encrypted link containing the base URL', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body[0].link).to.match(/^http:\/\/localhost:5987\?arg=/); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return text output by default (no outputType param)', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: aimUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.text).to.be.a('string'); + expect(res.text).to.include(studyUID); + expect(res.text).to.include('\t'); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return multiple entries for multiple pairs', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([ + { subject, study: studyUID, aimuid: aimUID }, + { subject, study: studyUID, aimuid: aimUID }, + ]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body).to.have.lengthOf(2); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return 400 for body missing required fields', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID }]) + .then((res) => { + expect(res.statusCode).to.equal(400); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return 500 for an invalid aimuid', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .post('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject, study: studyUID, aimuid: 'nonexistent-aim-uid' }]) + .then((res) => { + expect(res.statusCode).to.equal(500); + done(); + }) + .catch((e) => done(e)); + }); + }); + /* it('dcm upload should be successful ', done => { chai .request(`http://${process.env.host}:${process.env.port}`) From 5551c232b94d0cf4cb843c18e82ac89e8ca2438e Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 18:15:32 -0700 Subject: [PATCH 03/10] feat(exportlinks): switch to PUT, make aimuid optional, add name field, use narrative comment - Change method from POST to PUT - Make aimuid optional; comment and name default to empty string when omitted - Add name field (ImageAnnotation.name.value) to output - Use the narrative part of comment (after ~~) instead of the DICOM metadata prefix Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 27 ++++++++++++++++----------- routes/other.js | 4 ++-- test/otherTest.js | 41 ++++++++++++++++++++++++++++------------- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/plugins/Other.js b/plugins/Other.js index 5e415a69..f8ff2f3c 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3172,15 +3172,20 @@ async function other(fastify) { const results = await Promise.all( pairs.map(async ({ subject, study, aimuid }) => { - const db = fastify.couch.db.use(config.db); - const aimDoc = await new Promise((resolve, reject) => - db.get(aimuid, (err, body) => (err ? reject(err) : resolve(body))) - ); - const imageAnnotation = - aimDoc?.aim?.ImageAnnotationCollection?.imageAnnotations?.ImageAnnotation; - const ia = Array.isArray(imageAnnotation) ? imageAnnotation[0] : imageAnnotation; - const rawComment = ia?.comment?.value ?? ''; - const comment = rawComment.split('~~')[0]; + let comment = ''; + let name = ''; + if (aimuid) { + const db = fastify.couch.db.use(config.db); + const aimDoc = await new Promise((resolve, reject) => + db.get(aimuid, (err, body) => (err ? reject(err) : resolve(body))) + ); + const imageAnnotation = + aimDoc?.aim?.ImageAnnotationCollection?.imageAnnotations?.ImageAnnotation; + const ia = Array.isArray(imageAnnotation) ? imageAnnotation[0] : imageAnnotation; + const rawComment = ia?.comment?.value ?? ''; + comment = rawComment.split('~~')[1] || ''; + name = ia?.name?.value ?? ''; + } let studyDescription = ''; if (fetchStudyDesc) { @@ -3202,7 +3207,7 @@ async function other(fastify) { const encrypted = fastify.encryptInternal(queryParams); const link = `${config.baseUrl}?arg=${encodeURIComponent(encrypted)}`; - return { study_desc: studyDescription, study_uid: study, comment, link }; + return { study_desc: studyDescription, study_uid: study, name, comment, link }; }) ); @@ -3210,7 +3215,7 @@ async function other(fastify) { reply.send(results); } else { const text = results - .map((r) => `${r.study_uid}\t${r.study_desc}\t${r.comment}\t${r.link}`) + .map((r) => `${r.study_uid}\t${r.study_desc}\t${r.name}\t${r.comment}\t${r.link}`) .join('\n'); reply.type('text/plain').send(text); } diff --git a/routes/other.js b/routes/other.js index 5ed4d4a0..6f1b94bd 100644 --- a/routes/other.js +++ b/routes/other.js @@ -514,7 +514,7 @@ async function otherRoutes(fastify) { }); fastify.route({ - method: 'POST', + method: 'PUT', url: '/exportlinks', schema: { tags: ['link'], @@ -529,7 +529,7 @@ async function otherRoutes(fastify) { type: 'array', items: { type: 'object', - required: ['subject', 'study', 'aimuid'], + required: ['subject', 'study'], properties: { subject: { type: 'string' }, study: { type: 'string' }, diff --git a/test/otherTest.js b/test/otherTest.js index efee64d2..dc35fd8d 100644 --- a/test/otherTest.js +++ b/test/otherTest.js @@ -313,14 +313,14 @@ describe('Other Tests', () => { it('should return 200 with JSON array when outputType=json', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { expect(res.statusCode).to.equal(200); expect(res.body).to.be.an('array'); expect(res.body).to.have.lengthOf(1); - expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'comment', 'link'); + expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'name', 'comment', 'link'); done(); }) .catch((e) => done(e)); @@ -329,13 +329,13 @@ describe('Other Tests', () => { it('should return correct study_uid and comment from AIM', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { expect(res.statusCode).to.equal(200); expect(res.body[0].study_uid).to.equal(studyUID); - expect(res.body[0].comment).to.equal('CT / / 37 / 2'); + expect(res.body[0].comment).to.equal(''); done(); }) .catch((e) => done(e)); @@ -344,7 +344,7 @@ describe('Other Tests', () => { it('should return empty study_desc when studyDesc is not set (default false)', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { @@ -358,12 +358,12 @@ describe('Other Tests', () => { it('should return 200 with empty study_desc when studyDesc=true but DICOMweb unavailable', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', studyDesc: true, username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { expect(res.statusCode).to.equal(200); - expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'comment', 'link'); + expect(res.body[0]).to.have.all.keys('study_desc', 'study_uid', 'name', 'comment', 'link'); done(); }) .catch((e) => done(e)); @@ -372,7 +372,7 @@ describe('Other Tests', () => { it('should return an encrypted link containing the base URL', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { @@ -386,7 +386,7 @@ describe('Other Tests', () => { it('should return text output by default (no outputType param)', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ username: 'admin' }) .send([{ subject, study: studyUID, aimuid: aimUID }]) .then((res) => { @@ -402,7 +402,7 @@ describe('Other Tests', () => { it('should return multiple entries for multiple pairs', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([ { subject, study: studyUID, aimuid: aimUID }, @@ -416,12 +416,27 @@ describe('Other Tests', () => { .catch((e) => done(e)); }); - it('should return 400 for body missing required fields', (done) => { + it('should return 200 with empty comment when aimuid is omitted', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID }]) + .then((res) => { + expect(res.statusCode).to.equal(200); + expect(res.body[0].comment).to.equal(''); + expect(res.body[0].study_uid).to.equal(studyUID); + done(); + }) + .catch((e) => done(e)); + }); + + it('should return 400 for body missing required fields', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .put('/exportlinks') + .query({ outputType: 'json', username: 'admin' }) + .send([{ subject }]) .then((res) => { expect(res.statusCode).to.equal(400); done(); @@ -432,7 +447,7 @@ describe('Other Tests', () => { it('should return 500 for an invalid aimuid', (done) => { chai .request(`http://${process.env.host}:${process.env.port}`) - .post('/exportlinks') + .put('/exportlinks') .query({ outputType: 'json', username: 'admin' }) .send([{ subject, study: studyUID, aimuid: 'nonexistent-aim-uid' }]) .then((res) => { From 8c30242b0bc5c4a1459c7eb4d5a6d8e80482def1 Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 18:37:52 -0700 Subject: [PATCH 04/10] fix(exportlinks): guard against double-send and fix studyDesc boolean coercion - Add reply.sent guard so the handler exits immediately if the rights check already sent an unauthorized response - Accept both boolean true and string "true" for studyDesc query param to handle Fastify query string coercion edge cases Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/Other.js b/plugins/Other.js index f8ff2f3c..c48e6696 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3165,10 +3165,11 @@ async function other(fastify) { }); fastify.decorate('exportLinks', async (request, reply) => { + if (reply.sent) return; try { const pairs = request.body; const outputType = request.query.outputType || 'text'; - const fetchStudyDesc = request.query.studyDesc === true; + const fetchStudyDesc = request.query.studyDesc === true || request.query.studyDesc === 'true'; const results = await Promise.all( pairs.map(async ({ subject, study, aimuid }) => { From 5b7c46f11a9145f5bfa0700df6c876f0cbecdf1c Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 18:47:46 -0700 Subject: [PATCH 05/10] fix(exportlinks): pass undefined instead of {} for filter and requestQuery params getPatientStudiesInternal expects filter to be an array or undefined; passing {} caused filter.join to throw since objects have no join method. Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Other.js b/plugins/Other.js index c48e6696..ff31d35d 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3193,9 +3193,9 @@ async function other(fastify) { try { const studyList = await fastify.getPatientStudiesInternal( { subject, study }, - {}, + undefined, request.epadAuth, - {}, + undefined, true ); studyDescription = studyList?.[0]?.studyDescription ?? ''; From cd420d3f853592955c4e7e64718b08840a72107c Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 18:52:54 -0700 Subject: [PATCH 06/10] fix(exportlinks): pass {} for requestQuery to avoid undefined property access requestQuery.filterDSO is accessed without a null guard in getPatientStudiesInternal, so undefined caused a crash. Empty object is the correct no-op value; undefined is still correct for filter. Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Other.js b/plugins/Other.js index ff31d35d..c9e139ea 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3195,7 +3195,7 @@ async function other(fastify) { { subject, study }, undefined, request.epadAuth, - undefined, + {}, true ); studyDescription = studyList?.[0]?.studyDescription ?? ''; From 522514bfd12f8b4630072bdb0c015a8dd322cd56 Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 18:58:38 -0700 Subject: [PATCH 07/10] docs(exportlinks): add Swagger summary, description, and property descriptions Co-Authored-By: Claude Sonnet 4.6 --- routes/other.js | 48 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/routes/other.js b/routes/other.js index 6f1b94bd..41060bc9 100644 --- a/routes/other.js +++ b/routes/other.js @@ -518,22 +518,60 @@ async function otherRoutes(fastify) { url: '/exportlinks', schema: { tags: ['link'], + summary: 'Generate encrypted share links for a list of studies', + description: + 'Accepts a list of subject/study/aimuid triples. For each entry, retrieves the AIM ' + + 'annotation name and narrative comment, optionally fetches the study description from ' + + 'DICOMweb, and returns an AES-256-CBC encrypted URL that can be shared to open the study ' + + 'directly. Output can be a JSON array or a tab-separated text block.', querystring: { type: 'object', properties: { - outputType: { type: 'string', enum: ['json', 'text'], default: 'text' }, - studyDesc: { type: 'boolean', default: false }, + outputType: { + type: 'string', + enum: ['json', 'text'], + default: 'text', + description: 'Response format. "json" returns an array; "text" returns tab-separated lines.', + }, + studyDesc: { + type: 'boolean', + default: false, + description: 'When true, fetches study description from DICOMweb (adds a network call per entry).', + }, }, }, body: { type: 'array', + description: 'List of study entries to generate links for.', items: { type: 'object', required: ['subject', 'study'], properties: { - subject: { type: 'string' }, - study: { type: 'string' }, - aimuid: { type: 'string' }, + subject: { type: 'string', description: 'Patient ID.' }, + study: { type: 'string', description: 'Study Instance UID.' }, + aimuid: { type: 'string', description: 'AIM annotation UID. When provided, name and narrative comment are extracted from the annotation.' }, + }, + }, + }, + response: { + 200: { + description: 'Successful response. Shape depends on outputType.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + study_uid: { type: 'string', description: 'Study Instance UID.' }, + study_desc: { type: 'string', description: 'Study description from DICOMweb. Empty when studyDesc=false.' }, + name: { type: 'string', description: 'AIM annotation name (ImageAnnotation.name.value). Empty when aimuid is omitted.' }, + comment: { type: 'string', description: 'Narrative comment from AIM (text after ~~ in comment field). Empty when aimuid is omitted or no narrative was recorded.' }, + link: { type: 'string', description: 'AES-256-CBC encrypted URL for opening the study.' }, + }, + }, + }, + }, }, }, }, From a26616fb4868c3ab16bf374388ac576b8feb6f08 Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 19:18:07 -0700 Subject: [PATCH 08/10] fix(auth): replace reply.send with throw in epadThickRightsCheck All seven reply.send() calls inside epadThickRightsCheck now throw so errors bubble up to the auth hook's single res.send(err), preventing the "reply already sent" error from Fastify seeing multiple responses. The inner catch re-throws for the same reason, and the unused reply parameter is removed from the function signature. Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/Other.js b/plugins/Other.js index c9e139ea..0db72db1 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -2896,7 +2896,7 @@ async function other(fastify) { ); }); - fastify.decorate('epadThickRightsCheck', async (request, reply) => { + fastify.decorate('epadThickRightsCheck', async (request) => { try { const reqInfo = fastify.getInfoFromRequest(request); // check if user type is admin, if not admin @@ -2909,7 +2909,7 @@ async function other(fastify) { // check if it is a public project const project = await fastify.getProjectInternal(reqInfo.project); if (!project || project.type.toLowerCase() !== 'public') - reply.send(new UnauthorizedError('User has no access to project')); + throw new UnauthorizedError('User has no access to project'); } break; case 'PUT': // check permissions @@ -2923,7 +2923,7 @@ async function other(fastify) { ((await fastify.isCreatorOfObject(request, reqInfo)) === true && request.raw.url.includes(`/users/${request.epadAuth.username}`)))) ) - reply.send(new UnauthorizedError('User has no access to project and/or resource')); + throw new UnauthorizedError('User has no access to project and/or resource'); break; case 'POST': if ( @@ -2931,7 +2931,7 @@ async function other(fastify) { (reqInfo.level === 'project' && !fastify.hasCreatePermission(request, reqInfo.level)) ) - reply.send(new UnauthorizedError('User has no access to project and/or to create')); + throw new UnauthorizedError('User has no access to project and/or to create'); break; case 'DELETE': // check if owner if ( @@ -2940,7 +2940,7 @@ async function other(fastify) { ((await fastify.isCreatorOfObject(request, reqInfo)) === true && request.raw.url.includes(`/users/${request.epadAuth.username}`))) ) - reply.send(new UnauthorizedError('User has no access to project and/or resource')); + throw new UnauthorizedError('User has no access to project and/or resource'); break; default: break; @@ -2982,7 +2982,7 @@ async function other(fastify) { request.epadAuth.username ) ) - reply.send(new UnauthorizedError('User has no access to resource')); + throw new UnauthorizedError('User has no access to resource'); break; case 'POST': // reqInfo.worklistId identifies a worklist path. @@ -3011,7 +3011,7 @@ async function other(fastify) { reqInfo.level !== 'miracclexport' && reqInfo.level !== 'waterfall' ) - reply.send(new UnauthorizedError('User has no access to create')); + throw new UnauthorizedError('User has no access to create'); break; case 'DELETE': // check if owner if ( @@ -3031,7 +3031,7 @@ async function other(fastify) { request.epadAuth.username ) ) - reply.send(new UnauthorizedError('User has no access to resource')); + throw new UnauthorizedError('User has no access to resource'); break; default: break; @@ -3039,7 +3039,7 @@ async function other(fastify) { } } } catch (err) { - reply.send(err); + throw err; } }); From be3e80d9a33685774730684f5ac6decbc9cff9ea Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 19:20:12 -0700 Subject: [PATCH 09/10] fix(exportlinks): skip tabs for empty fields in text output Filter out empty/null values before joining with tabs so that missing study_desc, name, or comment fields don't produce stray tab characters in the plain-text response. Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Other.js b/plugins/Other.js index 0db72db1..4581c832 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3216,7 +3216,7 @@ async function other(fastify) { reply.send(results); } else { const text = results - .map((r) => `${r.study_uid}\t${r.study_desc}\t${r.name}\t${r.comment}\t${r.link}`) + .map((r) => [r.study_uid, r.study_desc, r.name, r.comment, r.link].filter(Boolean).join('\t')) .join('\n'); reply.type('text/plain').send(text); } From 42a5c3a985ab9f92dfe5475ca838c4295e39f453 Mon Sep 17 00:00:00 2001 From: Emel ALKIM Date: Sun, 23 Aug 2026 19:41:37 -0700 Subject: [PATCH 10/10] fix(exportlinks): guard AIM reads against errors Wrap the CouchDB AIM fetch in a try/catch so any failure (missing doc, DB unavailable, unexpected shape) leaves name and comment empty instead of crashing the whole request. Co-Authored-By: Claude Sonnet 4.6 --- plugins/Other.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/plugins/Other.js b/plugins/Other.js index 4581c832..c9e24f90 100644 --- a/plugins/Other.js +++ b/plugins/Other.js @@ -3176,16 +3176,20 @@ async function other(fastify) { let comment = ''; let name = ''; if (aimuid) { - const db = fastify.couch.db.use(config.db); - const aimDoc = await new Promise((resolve, reject) => - db.get(aimuid, (err, body) => (err ? reject(err) : resolve(body))) - ); - const imageAnnotation = - aimDoc?.aim?.ImageAnnotationCollection?.imageAnnotations?.ImageAnnotation; - const ia = Array.isArray(imageAnnotation) ? imageAnnotation[0] : imageAnnotation; - const rawComment = ia?.comment?.value ?? ''; - comment = rawComment.split('~~')[1] || ''; - name = ia?.name?.value ?? ''; + try { + const db = fastify.couch.db.use(config.db); + const aimDoc = await new Promise((resolve, reject) => + db.get(aimuid, (err, body) => (err ? reject(err) : resolve(body))) + ); + const imageAnnotation = + aimDoc?.aim?.ImageAnnotationCollection?.imageAnnotations?.ImageAnnotation; + const ia = Array.isArray(imageAnnotation) ? imageAnnotation[0] : imageAnnotation; + const rawComment = ia?.comment?.value ?? ''; + comment = rawComment.split('~~')[1] || ''; + name = ia?.name?.value ?? ''; + } catch (_e) { + // AIM unavailable — leave name and comment empty + } } let studyDescription = '';