diff --git a/.gitea/workflows/deploy-dev.yaml b/.gitea/workflows/deploy-dev.yaml index f0f0a82..cbb62b4 100644 --- a/.gitea/workflows/deploy-dev.yaml +++ b/.gitea/workflows/deploy-dev.yaml @@ -25,6 +25,12 @@ jobs: - name: Copy .env.example to .env run: cp functions/.env.example functions/.env + - name: Create private key file + run: | + mkdir -p functions/assets/keys + echo "${{ secrets.FITLIEN_PRIVATEKEY_DEV }}" > functions/assets/keys/fitLien_private.pem + chmod 600 functions/assets/keys/fitLien_private.pem + - name: Replace variables in .env run: | sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env diff --git a/.gitea/workflows/deploy-qa.yaml b/.gitea/workflows/deploy-qa.yaml index c868dcb..e57c4bc 100644 --- a/.gitea/workflows/deploy-qa.yaml +++ b/.gitea/workflows/deploy-qa.yaml @@ -25,6 +25,12 @@ jobs: - name: Copy .env.example to .env run: cp functions/.env.example functions/.env + - name: Create private key file + run: | + mkdir -p functions/assets/keys + echo "${{ secrets.FITLIEN_PRIVATEKEY_DEV }}" > functions/assets/keys/fitLien_private.pem + chmod 600 functions/assets/keys/fitLien_private.pem + - name: Replace variables in .env run: | sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index dbc0dd5..d01f7eb 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -25,6 +25,12 @@ jobs: - name: Copy .env.example to .env run: cp functions/.env.example functions/.env + - name: Create private key file + run: | + mkdir -p functions/assets/keys + echo "${{ secrets.FITLIEN_PRIVATEKEY }}" > functions/assets/keys/fitLien_private.pem + chmod 600 functions/assets/keys/fitLien_private.pem + - name: Replace variables in .env run: | sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env diff --git a/.gitignore b/.gitignore index b17f631..b848764 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ pids # Directory for instrumented libs generated by jscoverage/JSCover lib-cov +# Private key +/functions/assets/keys/fitLien_private.pem + # Coverage directory used by tools like istanbul coverage @@ -67,3 +70,6 @@ node_modules/ # dataconnect generated files .dataconnect + +.DS_Store +**/.DS_Store \ No newline at end of file diff --git a/functions/package-lock.json b/functions/package-lock.json index 853a3bf..b349a53 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -25,13 +25,15 @@ "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", "pdfmake": "^0.2.20", - "twilio": "^5.4.0" + "twilio": "^5.4.0", + "xmldom": "^0.6.0" }, "devDependencies": { "@types/long": "^5.0.0", "@types/mime-types": "^2.1.4", - "@types/node": "^22.13.14", + "@types/node": "^22.15.31", "@types/pdfmake": "^0.2.11", + "@types/xmldom": "^0.1.34", "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, @@ -2829,9 +2831,10 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, "node_modules/@types/node": { - "version": "22.15.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.2.tgz", - "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", + "version": "22.15.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz", + "integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==", + "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } @@ -2946,6 +2949,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "optional": true }, + "node_modules/@types/xmldom": { + "version": "0.1.34", + "resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.34.tgz", + "integrity": "sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -8324,6 +8333,14 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" }, + "node_modules/xmldom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz", + "integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/functions/package.json b/functions/package.json index b29002f..334660d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -32,13 +32,15 @@ "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", "pdfmake": "^0.2.20", - "twilio": "^5.4.0" + "twilio": "^5.4.0", + "xmldom": "^0.6.0" }, "devDependencies": { "@types/long": "^5.0.0", "@types/mime-types": "^2.1.4", - "@types/node": "^22.13.14", + "@types/node": "^22.15.31", "@types/pdfmake": "^0.2.11", + "@types/xmldom": "^0.1.34", "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, diff --git a/functions/src/dooraccess/doorAccessUser.ts b/functions/src/dooraccess/doorAccessUser.ts new file mode 100644 index 0000000..58fcd2c --- /dev/null +++ b/functions/src/dooraccess/doorAccessUser.ts @@ -0,0 +1,7 @@ +export type DoorAccessUser = { + name: string; + location: string; + role: string; + expireFrom: Date | null; + expireTo: Date | null; +}; diff --git a/functions/src/dooraccess/essl.ts b/functions/src/dooraccess/essl.ts new file mode 100644 index 0000000..ff26ad3 --- /dev/null +++ b/functions/src/dooraccess/essl.ts @@ -0,0 +1,560 @@ +import { onRequest } from "firebase-functions/https"; +import { DoorAccessUser } from "./doorAccessUser"; +import { Request } from "firebase-functions/v2/https"; +import { Response } from "express"; +import { getCorsHandler } from "../shared/middleware"; +import { getLogger } from "../shared/config"; +import { DOMParser } from 'xmldom'; +import { RSADecryption } from "../shared/decrypt"; +const logger = getLogger(); +const corsHandler = getCorsHandler(); + +export interface EmployeeCodeRequest { + employeeCode: string; +} + +export interface PushLogRequest extends EmployeeCodeRequest { + attendanceDate: string; +} + +export interface UpdateEmployeeExRequest { + employeeCode: string; + employeeName: string; + employeeLocation: string; + employeeRole: string; + employeeVerificationType: string; + employeeExpiryFrom: string; + employeeExpiryTo: string; + employeeCardNumber: string; + groupId: string; + employeePhoto: string; +} + +function getDecryptedPassword(password: string | null): string { + if (!password) { + throw new Error('Password is required'); + } + return RSADecryption.decryptPassword(password); +} + +const escapeXml = (str: string) => { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +function createGetEmployeeDetailsRequest(username: string, password: string, employeeCode: string): string | null { + const soapRequest = ` + + + + ${escapeXml(username)} + ${escapeXml(password)} + ${escapeXml(employeeCode)} + + +`; + + return soapRequest; +} + +function parseGetEmployeeDetailsResponse(soapResponse: string): DoorAccessUser { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); + if (xmlDoc.documentElement.tagName !== 'soap:Envelope') { + throw new Error("Invalid SOAP response"); + } + if (null == xmlDoc.documentElement.firstChild) { + throw new Error("Invalid SOAP response"); + } + + let currentElement = xmlDoc.documentElement.firstChild as HTMLElement; + if (currentElement.tagName !== 'soap:Body') { + throw new Error("Invalid SOAP response"); + } + + currentElement = currentElement.firstChild as HTMLElement; + if (currentElement.tagName !== 'GetEmployeeDetailsResponse') { + throw new Error("Invalid SOAP response"); + } + + currentElement = currentElement.firstChild as HTMLElement; + if (currentElement.tagName !== 'GetEmployeeDetailsResult') { + throw new Error("Invalid SOAP response"); + } + + const resultText = currentElement.textContent; + if (!resultText) { + throw new Error("GetEmployeeDetailsResult is empty"); + } + + const userDetails: DoorAccessUser = + { + name: '', + location: '', + role: '', + expireFrom: null, + expireTo: null + }; + const pairs = resultText.split(','); + pairs.forEach(pair => { + const [key, value] = pair.split('='); + if (key && value !== undefined) { + const cleanKey = key.trim(); + const cleanValue = value.trim(); + switch (cleanKey) { + case 'EmployeeName': + userDetails.name = cleanValue; + break; + case 'EmployeeLocation': + userDetails.location = cleanValue; + break; + case 'EmployeeRole': + userDetails.role = cleanValue; + break; + case 'EmployeeExpiryFrom': + userDetails.expireFrom = new Date(cleanValue); + break; + case 'EmployeeExpiryTo': + userDetails.expireTo = new Date(cleanValue); + break; + default: + break; + } + } + }); + + return userDetails; +} + +function isValidDateString(dateString: string): boolean { + const dateRegex = /^\d{4}\-\d{2}\-\d{2}$/; + return dateRegex.test(dateString); +} + +function createUpdateEmployeeExRequest(username: string, password: string, request: UpdateEmployeeExRequest): string | null { + + if (!username || !password || !request.employeeCode || !request.employeeName || !request.employeeLocation) { + throw new Error('Missing required fields'); + } + + if (!isValidDateString(request.employeeExpiryFrom) || !isValidDateString(request.employeeExpiryTo)) { + throw new Error('Invalid date format'); + } + + const soapRequest = ` + + + + ${escapeXml(username)} + ${escapeXml(password)} + ${escapeXml(request.employeeCode)} + ${escapeXml(request.employeeName)} + ${escapeXml(request.employeeLocation)} + ${escapeXml(request.employeeRole)} + Card + ${escapeXml(request.employeeExpiryFrom)} + ${escapeXml(request.employeeExpiryTo)} + ${escapeXml(request.employeeCardNumber)} + + + + + +`; + return soapRequest; +} + +function parseUpdateEmployeeExResponse(soapResponse: string): string | null { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); + const currentElement = xmlDoc.documentElement.firstChild as HTMLElement; + const resultText = currentElement.textContent; + return resultText; +} + +function createDeleteEmployeeRequest(username: string, password: string, employeeCode: string): string | null { + if (!username || !password || !employeeCode) { + throw new Error('Missing required fields'); + } + const soapRequst = ` + + + + ${escapeXml(username)} + ${escapeXml(password)} + ${escapeXml(employeeCode)} + + +`; + return soapRequst; +} + +function parseDeleteEmployeeResponse(soapResponse: string): string | null { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); + const currentElement = xmlDoc.documentElement.firstChild as HTMLElement; + const resultText = currentElement.textContent; + return resultText; +} + +function createGetEmployeePunchLogsRequest(username: string, password: string, + employeeCode: string, attendanceDate: string): string | null { + const soapRequest = ` + + + + cosqclient + 3bbb58d5 + 1 + 2025-05-24 + + +`; + return soapRequest; +} + +function createDateFromTime(date: Date, timeString: string): Date { + const [hour, minute, second] = timeString.split(':').map(str => parseInt(str, 10)); + const newDate = new Date(date.getTime()); + newDate.setHours(hour); + newDate.setMinutes(minute); + newDate.setSeconds(second); + return newDate; +} + +function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate: string): Date[] { + const rootDate = new Date(attendanceDate); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); + const currentElement = xmlDoc.documentElement.firstChild as HTMLElement; + const resultText = currentElement.textContent; + const punchLogs: Date[] = []; + const parts = resultText!.split(';'); + for (const part of parts) { + try { + const logDateTime = new Date(part); + if (isNaN(logDateTime.getTime())) { + throw new Error('Invalid date format'); + } + punchLogs.push(logDateTime); + } catch { + try { + const timeParts = part.split(','); + for (const timePart of timeParts) { + try { + const logDateTime = createDateFromTime(rootDate, timePart); + punchLogs.push(logDateTime); + } catch { + continue; + } + } + } catch { + continue; + } + } + } + const sortedLogs = punchLogs.sort((a, b) => b.getTime() - a.getTime()); + return sortedLogs; +} + +async function sendSoapRequest(soapRequest: string, endpoint: string) { + try { + const headers: any = { + 'Content-Type': 'application/soap+xml; charset=utf-8', + 'Content-Length': soapRequest.length.toString() + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: headers, + body: soapRequest + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); + } catch (error: any) { + throw new Error(`SOAP request failed: ${error.message}`); + } +} + +async function getUserDetails(username: string, + password: string, + employeeCode: string, endpoint: string): Promise { + const soapRequest = createGetEmployeeDetailsRequest(username, password, employeeCode); + const soapResponse = await sendSoapRequest(soapRequest!, endpoint); + const parsedResponse = parseGetEmployeeDetailsResponse(soapResponse); + return parsedResponse; +} + +async function updateUserEx(username: string, + password: string, + request: UpdateEmployeeExRequest, + endpoint: string) { + const soapRequest = createUpdateEmployeeExRequest(username, password, request); + const soapResponse = await sendSoapRequest(soapRequest!, endpoint); + const parsedResponse = parseUpdateEmployeeExResponse(soapResponse); + return parsedResponse; +} + +async function deleteEmplyee(username: string, + password: string, + employeeCode: string, endpoint: string) { + const soapRequest = createDeleteEmployeeRequest(username, password, employeeCode); + const soapResponse = await sendSoapRequest(soapRequest!, endpoint); + const parsedResponse = parseDeleteEmployeeResponse(soapResponse); + return parsedResponse; +} + +async function getEmployeePunchLogs(username: string, + password: string, + employeeCode: string, + attendanceDate: string, endpoint: string): Promise { + const soapRequest = createGetEmployeePunchLogsRequest(username, password, employeeCode, attendanceDate); + const soapResponse = await sendSoapRequest(soapRequest!, endpoint); + const parsedResponse = parseGetEmployeePunchLogsResponse(soapResponse, attendanceDate); + return parsedResponse; +} + +export const esslGetUserDetails = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: Response) => { + return corsHandler(request, response, async () => { + try { + let username: string | null = request.body.username as string; + let password: string | null = request.body.password as string; + let endpoint: string | null = request.body.endpoint as string; + let gymId: string | null = request.body.gymId as string; + const getEmployeeDetailsRequest = request.body.params as EmployeeCodeRequest; + + if (!username) { + throw new Error('Missing username or password'); + } + username = username.trim(); + if (!password) { + if (!gymId) { + throw new Error('Missing password or gymId'); + } + // todo: Get password from gym configuration by decrypting with private key + throw new Error('Gym-based password retrieval not implemented yet'); + } + password = getDecryptedPassword(password); + if (!getEmployeeDetailsRequest) { + throw new Error('Missing request params'); + } + const employeeCode = getEmployeeDetailsRequest.employeeCode; + if (!employeeCode) { + throw new Error('Missing employeeCode'); + } + if (!endpoint) { + throw new Error('Missing endpoint'); + } + if (!endpoint || endpoint.trim() === '') { + throw new Error('Missing endpoint'); + } + try { + new URL(endpoint); + } catch (_) { + throw new Error('Endpoint is not a valid URI or URL'); + } + if (!endpoint.endsWith('/webservice.asmx')) { + if (endpoint.endsWith('/')) { + endpoint = endpoint.substring(0, endpoint.length - 1); + } + endpoint += '/webservice.asmx'; + } + const userDetails = await getUserDetails(username, password, employeeCode, endpoint); + response.send(userDetails); + } catch (error: any) { + logger.error(error); + response.status(500).send({ error: error.message }); + } + }) +}); + +export const esslUpdateUser = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: Response) => { + return corsHandler(request, response, async () => { + try { + let username: string | null = request.body.username as string; + let password: string | null = request.body.password as string; + let endpoint: string | null = request.body.endpoint as string; + let gymId: string | null = request.body.gymId as string; + const updateEmployeeExRequest = request.body.params as UpdateEmployeeExRequest; + + if (!username) { + throw new Error('Missing username'); + } + username = username.trim(); + + if (!password) { + if (!gymId) { + throw new Error('Missing password or gymId'); + } + // TODO: Get password from gym configuration by decrypting with private key + throw new Error('Gym-based password retrieval not implemented yet'); + } + password = getDecryptedPassword(password); + if (!endpoint) { + throw new Error('Missing endpoint'); + } + endpoint = endpoint.trim(); + if (!endpoint || endpoint.trim() === '') { + throw new Error('Missing endpoint'); + } + try { + new URL(endpoint); + } catch (_) { + throw new Error('Endpoint is not a valid URI or URL'); + } + if (!endpoint.endsWith('/webservice.asmx')) { + if (endpoint.endsWith('/')) { + endpoint = endpoint.substring(0, endpoint.length - 1); + } + endpoint += '/webservice.asmx'; + } + if (!updateEmployeeExRequest) { + throw new Error('Missing request params'); + } + const result = await updateUserEx(username, password, updateEmployeeExRequest, endpoint); + response.send(result); + } catch (error: any) { + logger.error(error); + response.status(500).send({ error: error.message }); + } + }); +}); + +export const esslDeleteUser = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: Response) => { + return corsHandler(request, response, async () => { + try { + let username: string | null = request.body.username as string; + let password: string | null = request.body.password as string; + let endpoint: string | null = request.body.endpoint as string; + let gymId: string | null = request.body.gymId as string; + const getEmployeeDetailsRequest = request.body.params as EmployeeCodeRequest; + + if (!username) { + throw new Error('Missing username'); + } + username = username.trim(); + + if (!password) { + if (!gymId) { + throw new Error('Missing password or gymId'); + } + // TODO: Get password from gym configuration by decrypting with private key + throw new Error('Gym-based password retrieval not implemented yet'); + } + password = getDecryptedPassword(password); + if (!getEmployeeDetailsRequest) { + throw new Error('Missing request params'); + } + const employeeCode = getEmployeeDetailsRequest.employeeCode; + if (!employeeCode) { + throw new Error('Missing employeeCode'); + } + if (!endpoint) { + throw new Error('Missing endpoint'); + } + if (!endpoint || endpoint.trim() === '') { + throw new Error('Missing endpoint'); + } + try { + new URL(endpoint); + } catch (_) { + throw new Error('Endpoint is not a valid URI or URL'); + } + if (!endpoint.endsWith('/webservice.asmx')) { + if (endpoint.endsWith('/')) { + endpoint = endpoint.substring(0, endpoint.length - 1); + } + endpoint += '/webservice.asmx'; + } + const result = await deleteEmplyee(username, password, employeeCode, endpoint); + response.send(result); + } catch (error: any) { + logger.error(error); + response.status(500).send({ error: error.message }); + } + }); +}); + + +export const esslGetEmployeePunchLogs = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: Response) => { + return corsHandler(request, response, async () => { + try { + let username: string | null = request.body.username as string; + let password: string | null = request.body.password as string; + let endpoint: string | null = request.body.endpoint as string; + let gymId: string | null = request.body.gymId as string; + const pushLogRequst = request.body.params as PushLogRequest; + + if (!username) { + throw new Error('Missing username'); + } + username = username.trim(); + + if (!password) { + if (!gymId) { + throw new Error('Missing password or gymId'); + } + // TODO: Get password from gym configuration by decrypting with private key + throw new Error('Gym-based password retrieval not implemented yet'); + } + password = getDecryptedPassword(password); + if (!pushLogRequst) { + throw new Error('Missing request params'); + } + const employeeCode = pushLogRequst.employeeCode; + if (!employeeCode) { + throw new Error('Missing employeeCode'); + } + const attendanceDate = pushLogRequst.attendanceDate; + if (!attendanceDate) { + throw new Error('Missing attendanceDate'); + } + const isValidDate = /^\d{4}-\d{2}-\d{2}$/; + if (!attendanceDate.match(isValidDate)) { + throw new Error('attendanceDate is not in the valid format YYYY-MM-DD'); + } + if (!endpoint) { + throw new Error('Missing endpoint'); + } + if (!endpoint || endpoint.trim() === '') { + throw new Error('Missing endpoint'); + } + try { + new URL(endpoint); + } catch (_) { + throw new Error('Endpoint is not a valid URI or URL'); + } + if (!endpoint.endsWith('/webservice.asmx')) { + if (endpoint.endsWith('/')) { + endpoint = endpoint.substring(0, endpoint.length - 1); + } + endpoint += '/webservice.asmx'; + } + const result = await getEmployeePunchLogs(username, + password, + employeeCode, + attendanceDate, + endpoint); + response.send(result); + } catch (error: any) { + logger.error(error); + response.status(500).send({ error: error.message }); + } + }); +}); \ No newline at end of file diff --git a/functions/src/dooraccess/index.ts b/functions/src/dooraccess/index.ts new file mode 100644 index 0000000..ad7da6e --- /dev/null +++ b/functions/src/dooraccess/index.ts @@ -0,0 +1,4 @@ +export { + esslGetUserDetails, esslUpdateUser, + esslDeleteUser, esslGetEmployeePunchLogs +} from './essl'; \ No newline at end of file diff --git a/functions/src/email/sendEmailSES.ts b/functions/src/email/sendEmailSES.ts index 78e6875..5efc40a 100644 --- a/functions/src/email/sendEmailSES.ts +++ b/functions/src/email/sendEmailSES.ts @@ -2,6 +2,7 @@ import { getLogger } from "../shared/config"; import { getCorsHandler } from "../shared/middleware"; import { onRequest } from "firebase-functions/v2/https"; import { Request } from "firebase-functions/v2/https"; +import { Response } from "express"; import { SESClient } from "@aws-sdk/client-ses"; import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses"; import { HttpsError } from "firebase-functions/v2/https"; @@ -19,8 +20,8 @@ interface EmailRequest { from: string; replyTo?: string; attachments?: Attachment[]; - fileUrl?: string; - fileName?: string; + fileUrl?: string; + fileName?: string; } interface Attachment { @@ -62,7 +63,7 @@ async function sendSimpleEmail(data: EmailRequest, recipients: string[]) { async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) { const ses = new SESClient({ - region: 'ap-south-1', + region: '#{SERVICES_RGN}#', credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' @@ -135,14 +136,14 @@ async function downloadFileFromUrl(url: string): Promise { } export const sendEmailSES = onRequest({ - region: 'asia-south1' -}, (request: Request, response) => { + region: '#{SERVICES_RGN}#' +}, (request: Request, response: Response) => { return corsHandler(request, response, async () => { try { const toAddress = request.body.toAddress; const subject = request.body.subject; const message = request.body.message; - + // Initialize data with basic fields const data: EmailRequest = { to: toAddress, @@ -153,42 +154,42 @@ export const sendEmailSES = onRequest({ replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com', attachments: request.body.attachments as Attachment[] || [] }; - + // Handle file URL if provided if (request.body.fileUrl && request.body.fileName) { logger.info(`Downloading attachment from URL: ${request.body.fileUrl}`); try { const fileContent = await downloadFileFromUrl(request.body.fileUrl); - + // If attachments array doesn't exist, create it if (!data.attachments) { data.attachments = []; } - + // Add the downloaded file as an attachment data.attachments.push({ filename: request.body.fileName, content: fileContent, contentType: mime.lookup(request.body.fileName) || 'application/octet-stream' }); - + logger.info(`Successfully downloaded attachment: ${request.body.fileName}`); } catch (downloadError) { logger.error(`Failed to download attachment: ${downloadError}`); throw new Error(`Failed to process attachment: ${downloadError}`); } } - + if (!data.to || !data.subject || !data.html || !data.from) { throw new HttpsError( 'invalid-argument', 'Missing required email fields' ); } - + logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`); const recipients = Array.isArray(data.to) ? data.to : [data.to]; - + if (data.attachments && data.attachments.length > 0) { const messageResult = await sendEmailWithAttachments(data, recipients); response.status(200).json(messageResult); diff --git a/functions/src/index.ts b/functions/src/index.ts index 432a73c..1156209 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,4 +16,8 @@ export { accessFile } from './storage'; export { processNotificationOnCreate } from './notifications'; export * from './payments'; export { getPlaceDetails, getPlacesAutocomplete } from './places'; -export { registerClient } from './clientRegistration'; +export { registerClient } from './users'; +export { + esslGetUserDetails, esslUpdateUser, + esslDeleteUser, esslGetEmployeePunchLogs +} from './dooraccess'; diff --git a/functions/src/shared/decrypt.ts b/functions/src/shared/decrypt.ts new file mode 100644 index 0000000..63e9b9f --- /dev/null +++ b/functions/src/shared/decrypt.ts @@ -0,0 +1,51 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +export class RSADecryption { + private static privateKeyObject: crypto.KeyObject | null = null; + + private static getPrivateKeyObject(): crypto.KeyObject { + if (!this.privateKeyObject) { + const keyPath = path.join(__dirname, '../../assets/keys/fitLien_private.pem'); + const keyContent = fs.readFileSync(keyPath, 'utf8'); + this.privateKeyObject = crypto.createPrivateKey({ + key: keyContent, + format: 'pem' + }); + } + return this.privateKeyObject; + } + + public static decryptPassword(encryptedPassword: string): string { + try { + if (!encryptedPassword || encryptedPassword.trim() === '') { + throw new Error('Encrypted password cannot be empty'); + } + + const privateKeyObject = this.getPrivateKeyObject(); + const encryptedBuffer = Buffer.from(encryptedPassword, 'base64'); + + if (encryptedBuffer.length === 0) { + throw new Error('Encrypted password buffer is empty'); + } + + const decryptedBuffer = crypto.privateDecrypt( + { + key: privateKeyObject, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha1' + }, + encryptedBuffer + ); + + return decryptedBuffer.toString('utf8'); + } catch (error) { + console.error('Decryption error details:', { + message: error instanceof Error ? error.message : 'Unknown error', + encryptedPasswordLength: encryptedPassword?.length || 0 + }); + throw new Error(`Failed to decrypt password: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} diff --git a/functions/src/clientRegistration/clientRegistration.ts b/functions/src/users/clientRegistration.ts similarity index 88% rename from functions/src/clientRegistration/clientRegistration.ts rename to functions/src/users/clientRegistration.ts index fe2fafc..f39b827 100644 --- a/functions/src/clientRegistration/clientRegistration.ts +++ b/functions/src/users/clientRegistration.ts @@ -1,6 +1,9 @@ import { onRequest } from "firebase-functions/v2/https"; import { getCorsHandler } from "../shared/middleware"; import { getAdmin, getLogger } from "../shared/config"; +import { Request } from "firebase-functions/v2/https"; +import { Response } from "express"; + const corsHandler = getCorsHandler(); const admin = getAdmin(); @@ -8,7 +11,7 @@ const logger = getLogger(); export const registerClient = onRequest({ region: '#{SERVICES_RGN}#' -}, async (req, res) => { +}, async (req: Request, res: Response) => { return corsHandler(req, res, async () => { try { if (req.method !== 'POST') { @@ -33,27 +36,21 @@ export const registerClient = onRequest({ return res.status(403).json({ error: 'Forbidden. Only gym owners can register clients.' }); } const gymUser = req.body; - if (!gymUser.phoneNumber) { + if (!gymUser.fields["phone-number"]) { return res.status(400).json({ error: 'Phone number is required' }); } - const isdCode = gymUser.isdCode || '91'; - const formattedPhoneNumber = gymUser.phoneNumber.startsWith('+') - ? gymUser.phoneNumber - : `${isdCode}${gymUser.phoneNumber}`; - let clientUid; try { - const userRecord = await admin.auth().getUserByPhoneNumber(formattedPhoneNumber) + const userRecord = await admin.auth().getUserByPhoneNumber(gymUser.fields["phone-number"]) .catch(() => null); if (userRecord) { clientUid = userRecord.uid; } else { const newUser = await admin.auth().createUser({ - phoneNumber: formattedPhoneNumber, - displayName: gymUser.name || '', - email: gymUser.email || null, + phoneNumber: gymUser.fields["phone-number"], + displayName: gymUser.fields["first-name"] || '', }); clientUid = newUser.uid; } @@ -79,7 +76,6 @@ export const registerClient = onRequest({ const clientData = { ...gymUser, - phoneNumber: formattedPhoneNumber, }; await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData); diff --git a/functions/src/clientRegistration/index.ts b/functions/src/users/index.ts similarity index 100% rename from functions/src/clientRegistration/index.ts rename to functions/src/users/index.ts