diff --git a/api/Dockerfile b/api/Dockerfile index 7d855f7a..c0ca06c4 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,5 @@ # Use the official Node.js image from the Docker Hub -FROM node:20-alpine AS build +FROM node:22-alpine AS build # Set the working directory inside the container WORKDIR /app @@ -19,4 +19,4 @@ RUN chgrp -R 0 /app && \ USER node -ENTRYPOINT ["./container-entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["./container-entrypoint.sh"] diff --git a/api/apiCaptcha.js b/api/apiCaptcha.js index e11997dc..b0b390ba 100644 --- a/api/apiCaptcha.js +++ b/api/apiCaptcha.js @@ -3,11 +3,8 @@ var svgCaptcha = require('svg-captcha'); var jwt = require('jsonwebtoken'); -var wav = require('wav'); var text2wav = require('text2wav'); var arrayBufferToBuffer = require('arraybuffer-to-buffer'); -var streamifier = require('streamifier'); -var lame = require('lamejs'); function captchaInit(options) { @@ -33,7 +30,7 @@ function captchaInit(options) { return decrypted; } - function createCaptcha(nonce){ + function createCaptcha(nonce) { var captcha = svgCaptcha.create({ size: 6, // size of random string ignoreChars: '0Oo1iIl', // filter out some characters like 0o1i @@ -92,105 +89,78 @@ function captchaInit(options) { } function getAudio(body, req) { - return new Promise(function(resolve, reject) { - try { - // pull out encrypted answer - var validation = body.validation; - - // decrypt payload to get captcha text - var decryptedBody = decrypt(validation); - - // Insert leading text and commas to slow down reader - var captchaCharArray = decryptedBody.answer.toString().split(''); - let language = 'en'; - if (body.translation) { - if (typeof body.translation == 'string') { - if (voicePromptLanguageMap.hasOwnProperty(body.translation)) { - language = body.translation; + return new Promise(function (resolve, reject) { + try { + // pull out encrypted answer + var validation = body.validation; + + // decrypt payload to get captcha text + var decryptedBody = decrypt(validation); + + // Insert leading text and commas to slow down reader + var captchaCharArray = decryptedBody.answer.toString().split(''); + let language = 'en'; + if (body.translation) { + if (typeof body.translation == 'string') { + if (voicePromptLanguageMap.hasOwnProperty(body.translation)) { + language = body.translation; + } + } else if ( + body.translation === true && + req && + req.headers['accept-language'] + ) { + let lang = req.headers['accept-language'] + .split(',') + .map(e => e.split(';')[0].split('-')[0]) + .find(e => voicePromptLanguageMap.hasOwnProperty(e)); + if (lang) { + language = lang; + } } - } else if ( - body.translation === true && - req && - req.headers['accept-language'] + } + var spokenCatpcha = voicePromptLanguageMap[language] + ': '; + for (var i = 0; i < captchaCharArray.length; i++) { + spokenCatpcha += captchaCharArray[i] + ', '; + } + getMp3DataUriFromText(spokenCatpcha, language).then(function ( + audioDataUri ) { - let lang = req.headers['accept-language'] - .split(',') - .map(e => e.split(';')[0].split('-')[0]) - .find(e => voicePromptLanguageMap.hasOwnProperty(e)); - if (lang) { - language = lang; + if (!audioDataUri) { + resolve({ + error: 'unknown' + }); + return; } - } - } - var spokenCatpcha = voicePromptLanguageMap[language] + ': '; - for (var i = 0; i < captchaCharArray.length; i++) { - spokenCatpcha += captchaCharArray[i] + ', '; - } - getMp3DataUriFromText(spokenCatpcha, language).then(function( - audioDataUri - ) { - // Now pass back the full payload , + + // Now pass back the full payload. + resolve({ + audio: audioDataUri + }); + }); + } catch (e) { + console.log('Error getting audio:', e); resolve({ - audio: audioDataUri + error: 'unknown' }); - }); - } catch (e) { - console.log('Error getting audio:', e); - resolve({ - error: 'unknown' - }); - } - }); + } + }); } - //////////////////////////////////////////////////////// /* * Audio routines */ //////////////////////////////////////////////////////// function getMp3DataUriFromText(text, language = 'en') { - return new Promise(async function(resolve) { - // init wave reader, used to convert WAV to PCM - var reader = new wav.Reader(); - - // we have to wait for the "format" event before we can start encoding - reader.on('format', function(format) { - // init encoder - var encoder = new lame.Mp3Encoder(format); - - // Pipe Wav reader to the encoder and capture the output stream - // As the stream is encoded, convert the mp3 array buffer chunks into base64 string with mime type - var dataUri = 'data:audio/mp3;base64,'; - encoder.on('data', function(arrayBuffer) { - if (!dataUri) { - return; - } - dataUri += arrayBuffer.toString('base64'); - // by observation encoder hung before finish due to event loop being empty - // setTimeout injects an event to mitigate the issue - setTimeout(() => {}, 0); - }); - - // When encoding is complete, callback with data uri - encoder.on('finish', function() { - resolve(dataUri); - dataUri = undefined; - }); - reader.pipe(encoder); - }); - - // Generate audio, Base64 encoded WAV in DataUri format including mime type header - text2wav(text, { voice: language }).then(function(audioArrayBuffer) { - // convert to buffer + return text2wav(text, { voice: language }) + .then(function (audioArrayBuffer) { var audioBuffer = arrayBufferToBuffer(audioArrayBuffer); - - // Convert ArrayBuffer to Streamable type for input to the encoder - var audioStream = streamifier.createReadStream(audioBuffer); - - // once all events setup we can the pipeline - audioStream.pipe(reader); + return 'data:audio/wav;base64,' + audioBuffer.toString('base64'); + }) + .catch(function (error) { + console.log('Error generating audio CAPTCHA:', error); + return null; }); - }); } function verifyJWTResponse(token, nonce) { @@ -208,29 +178,29 @@ function captchaInit(options) { } return { - getCaptcha: function(req, res, next) { + getCaptcha: function (req, res, next) { var fullReponse = createCaptcha(req.body.nonce); res.send(fullReponse); next(); }, - verifyCaptcha: function(req, res, next) { + verifyCaptcha: function (req, res, next) { var ret = verifyCaptchaInternal(req.body); res.send(ret); next(); }, - getCaptchaAudio: function(req, res, next) { - getAudio(req.body, req).then(function(ret) { + getCaptchaAudio: function (req, res, next) { + getAudio(req.body, req).then(function (ret) { res.send(ret); next(); }); }, - verifyJWTResponseMiddleware: function(req, res, next) { + verifyJWTResponseMiddleware: function (req, res, next) { // if there is a captcha header , it shud be validated by captcha verifier - if(req.isAuthorised && req.userDetails && !req.headers[CAPTCHA_NONCE_HEADER]) { + if (req.isAuthorised && req.userDetails && !req.headers[CAPTCHA_NONCE_HEADER]) { console.log('Request is already validated'); return next(); } - + var token = req.headers[CAPTCHA_TOKEN_HEADER.toLowerCase()] || ''; token = token.replace('Bearer ', ''); var nonce = req.headers[CAPTCHA_NONCE_HEADER]; @@ -239,7 +209,7 @@ function captchaInit(options) { console.log('Captcha validated in apiCaptcha'); next(); } else { - res. send(401, 'Not Authorized'); + res.send(401, 'Not Authorized'); next('Invalid Captcha Token'); } } diff --git a/api/apiCustomFunctions.js b/api/apiCustomFunctions.js index 726b5534..f9f910af 100644 --- a/api/apiCustomFunctions.js +++ b/api/apiCustomFunctions.js @@ -16,6 +16,12 @@ const MAX_ATTACH_MB = 5; const maxAttachBytes = MAX_ATTACH_MB * 1024 * 1024; const submitFoiRequest = async (server, req, res, next) => { + console.log( + '[API]', + new Date().toISOString(), + 'submitFoiRequest API called' + ); + console.trace('submitFoiRequest API'); const apiUrl = `${foiRequestAPIBackend}/foirawrequests`; @@ -48,9 +54,9 @@ const submitFoiRequest = async (server, req, res, next) => { console.log("calling RAW FOI Request"); const response = await requestAPI.invokeRequestAPI(JSON.stringify(data.params), apiUrl); - console.log(`API response = ${response.status}`); + console.log(`API response = DATA: ${JSON.stringify(response.data)}, STATUS: ${response.status}`); - if(response.status === 200 && response.data.status ) { + if(response.status === 200 && response.data.status) { console.log(`response id: ${response.data.id}`); // if request needs payment, return earlier to prevent sending email as it will be sent after payment. if(needsPayment) { @@ -69,24 +75,35 @@ const submitFoiRequest = async (server, req, res, next) => { pendingPayment: false }); - } - else { + } else { req.log.info('Failed:', response); const unavailable = new restifyErrors.ServiceUnavailableError('Service is unavailable.'); return next(unavailable); } } - catch(error){ - console.log(`${error}`); - console.log("FOI API STATUS:", error.response.status); - console.log("FOI API DATA:", error.response.data) - req.log.info('Failed:', error); - const unavailable = new restifyErrors.ServiceUnavailableError(error.message || 'Service is unavailable.'); - return next(unavailable); + catch(error) { + console.log(`${error}`); + console.log("FOI API STATUS:", error.response.status); + console.log("FOI API DATA:", error.response.data); + req.log.info('Failed:', error); + let unavailable = ""; + if (error.response.status === 409) { + // Handle duplicate request + unavailable = new restifyErrors.ConflictError(error.response.data.message); + } else { + unavailable = new restifyErrors.ServiceUnavailableError(error.message || 'Service is unavailable.'); + } + return next(unavailable); } } const submitFoiRequestEmail = async (server, req, res, next) => { + console.log( + '[API]', + new Date().toISOString(), + 'submitFoiRequestEmail API called' + ); + console.trace('submitFoiRequestEmail API'); req.params.requestData = JSON.parse(req.params.requestData); diff --git a/web/src/app/route-components/ministry-confirmation/ministry-confirmation.component.html b/web/src/app/route-components/ministry-confirmation/ministry-confirmation.component.html index 6a4149da..98be7e82 100644 --- a/web/src/app/route-components/ministry-confirmation/ministry-confirmation.component.html +++ b/web/src/app/route-components/ministry-confirmation/ministry-confirmation.component.html @@ -4,6 +4,13 @@
+ Personal FOI requests can only be submitted to one ministry at a time. If you need to make a request to multiple + ministries, please submit a separate request for each ministry. +
+