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" }], 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..c9e24f90 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; } }); @@ -3152,6 +3152,83 @@ 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) => { + if (reply.sent) return; + try { + const pairs = request.body; + const outputType = request.query.outputType || 'text'; + const fetchStudyDesc = request.query.studyDesc === true || request.query.studyDesc === 'true'; + + const results = await Promise.all( + pairs.map(async ({ subject, study, aimuid }) => { + let comment = ''; + let name = ''; + if (aimuid) { + 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 = ''; + if (fetchStudyDesc) { + try { + const studyList = await fastify.getPatientStudiesInternal( + { subject, study }, + undefined, + 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, name, comment, link }; + }) + ); + + if (outputType === 'json') { + reply.send(results); + } else { + const text = results + .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); + } + } 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..41060bc9 100644 --- a/routes/other.js +++ b/routes/other.js @@ -513,6 +513,72 @@ async function otherRoutes(fastify) { handler: fastify.decryptAdd, }); + fastify.route({ + method: 'PUT', + 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', + 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', 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.' }, + }, + }, + }, + }, + }, + }, + }, + }, + handler: fastify.exportLinks, + }); + fastify.route({ method: 'POST', url: '/reports/waterfall', diff --git a/test/otherTest.js b/test/otherTest.js index 4d80bd3a..dc35fd8d 100644 --- a/test/otherTest.js +++ b/test/otherTest.js @@ -289,6 +289,175 @@ 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}`) + .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', 'name', '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}`) + .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(''); + 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}`) + .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_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}`) + .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', 'name', '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}`) + .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].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}`) + .put('/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}`) + .put('/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 200 with empty comment when aimuid is omitted', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .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(); + }) + .catch((e) => done(e)); + }); + + it('should return 500 for an invalid aimuid', (done) => { + chai + .request(`http://${process.env.host}:${process.env.port}`) + .put('/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}`)