Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2020
},
"rules": {
"prettier/prettier": ["error", { "trailingComma": "es5" }],
Expand Down
2 changes: 2 additions & 0 deletions config/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ module.exports = {
auditLog: true,
versionAudit: true,
teachingProject: 'testaim',
secret: 'testsecret',
baseUrl: 'http://localhost:5987',
};
95 changes: 86 additions & 9 deletions plugins/Other.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -2923,15 +2923,15 @@ 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 (
fastify.hasAccessToProject(request, reqInfo.project) === undefined ||
(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 (
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
Expand All @@ -3031,15 +3031,15 @@ 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;
}
}
}
} catch (err) {
reply.send(err);
throw err;
}
});

Expand Down Expand Up @@ -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');
Expand Down
66 changes: 66 additions & 0 deletions routes/other.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading