From b9a15134a21b8a4501e2f8ef8766cf03338ec8b6 Mon Sep 17 00:00:00 2001 From: faris Aljohari <83524184+farisaljohari@users.noreply.github.com> Date: Fri, 28 Jun 2024 20:47:41 +0300 Subject: [PATCH] feat: Add DoorLockService and PasswordEncryptionService --- src/door-lock/services/door.lock.service.ts | 575 ++++++++++++++++++ src/door-lock/services/encryption.services.ts | 57 ++ src/door-lock/services/index.ts | 1 + 3 files changed, 633 insertions(+) create mode 100644 src/door-lock/services/door.lock.service.ts create mode 100644 src/door-lock/services/encryption.services.ts create mode 100644 src/door-lock/services/index.ts diff --git a/src/door-lock/services/door.lock.service.ts b/src/door-lock/services/door.lock.service.ts new file mode 100644 index 0000000..d60c4bd --- /dev/null +++ b/src/door-lock/services/door.lock.service.ts @@ -0,0 +1,575 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { TuyaContext } from '@tuya/tuya-connector-nodejs'; +import { ConfigService } from '@nestjs/config'; +import { + AddDoorLockOnlineInterface, + addDeviceObjectInterface, + createTickInterface, + deleteTemporaryPasswordInterface, + getPasswordInterface, +} from '../interfaces/door.lock.interface'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { ProductType } from '@app/common/constants/product-type.enum'; + +import { PasswordEncryptionService } from './encryption.services'; +import { AddDoorLockOfflineTempDto } from '../dtos/add.offline-temp.dto'; +import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; + +@Injectable() +export class DoorLockService { + private tuya: TuyaContext; + constructor( + private readonly configService: ConfigService, + private readonly deviceRepository: DeviceRepository, + private readonly passwordEncryptionService: PasswordEncryptionService, + ) { + const accessKey = this.configService.get('auth-config.ACCESS_KEY'); + const secretKey = this.configService.get('auth-config.SECRET_KEY'); + const tuyaEuUrl = this.configService.get('tuya-config.TUYA_EU_URL'); + this.tuya = new TuyaContext({ + baseUrl: tuyaEuUrl, + accessKey, + secretKey, + }); + } + async deleteDoorLockPassword(doorLockUuid: string, passwordId: string) { + try { + const deviceDetails = await this.getDeviceByDeviceUuid(doorLockUuid); + + if (!deviceDetails || !deviceDetails.deviceTuyaUuid) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } else if (deviceDetails.productDevice.prodType !== ProductType.DL) { + throw new HttpException( + 'This is not a door lock device', + HttpStatus.BAD_REQUEST, + ); + } + + const deletePass = await this.deleteDoorLockPasswordTuya( + deviceDetails.deviceTuyaUuid, + passwordId, + ); + + if (!deletePass.success) { + throw new HttpException('PasswordId not found', HttpStatus.NOT_FOUND); + } + return deletePass; + } catch (error) { + throw new HttpException( + error.message || 'Error deleting temporary password', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async deleteDoorLockPasswordTuya( + doorLockUuid: string, + passwordId: string, + ): Promise { + try { + const path = `/v1.0/devices/${doorLockUuid}/door-lock/temp-passwords/${passwordId}`; + + const response = await this.tuya.request({ + method: 'DELETE', + path, + }); + + return response as deleteTemporaryPasswordInterface; + } catch (error) { + throw new HttpException( + 'Error deleting temporary password from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getOfflineMultipleTimeTemporaryPasswords(doorLockUuid: string) { + try { + const deviceDetails = await this.getDeviceByDeviceUuid(doorLockUuid); + + if (!deviceDetails || !deviceDetails.deviceTuyaUuid) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } else if (deviceDetails.productDevice.prodType !== ProductType.DL) { + throw new HttpException( + 'This is not a door lock device', + HttpStatus.BAD_REQUEST, + ); + } + const passwords = await this.getTemporaryPasswordsTuya( + deviceDetails.deviceTuyaUuid, + ); + + if (passwords.result.length > 0) { + const passwordFiltered = passwords.result.filter( + (item) => + (!item.schedule_list || item.schedule_list.length === 0) && + item.type === 0, + ); + + return convertKeysToCamelCase(passwordFiltered); + } + + return passwords; + } catch (error) { + throw new HttpException( + error.message || + 'Error getting offline multiple time temporary passwords', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getOfflineOneTimeTemporaryPasswords(doorLockUuid: string) { + try { + const deviceDetails = await this.getDeviceByDeviceUuid(doorLockUuid); + + if (!deviceDetails || !deviceDetails.deviceTuyaUuid) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } else if (deviceDetails.productDevice.prodType !== ProductType.DL) { + throw new HttpException( + 'This is not a door lock device', + HttpStatus.BAD_REQUEST, + ); + } + const passwords = await this.getTemporaryPasswordsTuya( + deviceDetails.deviceTuyaUuid, + ); + + if (passwords.result.length > 0) { + const passwordFiltered = passwords.result.filter( + (item) => + (!item.schedule_list || item.schedule_list.length === 0) && + item.type === 1, + ); + + return convertKeysToCamelCase(passwordFiltered); + } + + return passwords; + } catch (error) { + throw new HttpException( + error.message || 'Error getting offline one time temporary passwords', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getOnlineTemporaryPasswords(doorLockUuid: string) { + try { + const deviceDetails = await this.getDeviceByDeviceUuid(doorLockUuid); + + if (!deviceDetails || !deviceDetails.deviceTuyaUuid) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } else if (deviceDetails.productDevice.prodType !== ProductType.DL) { + throw new HttpException( + 'This is not a door lock device', + HttpStatus.BAD_REQUEST, + ); + } + const passwords = await this.getTemporaryPasswordsTuya( + deviceDetails.deviceTuyaUuid, + ); + + if (passwords.result.length > 0) { + const passwordFiltered = passwords.result + .filter((item) => item.schedule_list && item.schedule_list.length > 0) + .map((password: any) => { + password.schedule_list = password.schedule_list.map((schedule) => { + schedule.working_day = this.getDaysFromWorkingDayValue( + schedule.working_day, + ); + schedule.effective_time = this.minutesToTime( + schedule.effective_time, + ); + schedule.invalid_time = this.minutesToTime(schedule.invalid_time); + return schedule; + }); + return password; + }); + + return convertKeysToCamelCase(passwordFiltered); + } + + return passwords; + } catch (error) { + throw new HttpException( + error.message || 'Error getting online temporary passwords', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getTemporaryPasswordsTuya( + doorLockUuid: string, + ): Promise { + try { + const path = `/v1.0/devices/${doorLockUuid}/door-lock/temp-passwords?valid=true`; + + const response = await this.tuya.request({ + method: 'GET', + path, + }); + + return response as getPasswordInterface; + } catch (error) { + throw new HttpException( + 'Error getting temporary passwords from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async addOfflineMultipleTimeTemporaryPassword( + addDoorLockOfflineTempDto: AddDoorLockOfflineTempDto, + doorLockUuid: string, + ) { + try { + const createOnlinePass = await this.addOnlineTemporaryPassword( + addDoorLockOfflineTempDto, + doorLockUuid, + 'multiple', + false, + ); + if (!createOnlinePass) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } + const createOnceOfflinePass = await this.addOfflineTemporaryPasswordTuya( + addDoorLockOfflineTempDto, + createOnlinePass.id, + createOnlinePass.deviceTuyaUuid, + 'multiple', + ); + if (!createOnceOfflinePass.success) { + throw new HttpException( + createOnceOfflinePass.msg, + HttpStatus.BAD_REQUEST, + ); + } + return { + result: createOnceOfflinePass.result, + }; + } catch (error) { + throw new HttpException( + error.message || 'Error adding offline temporary password from Tuya', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async addOfflineOneTimeTemporaryPassword( + addDoorLockOfflineTempDto: AddDoorLockOfflineTempDto, + doorLockUuid: string, + ) { + try { + const createOnlinePass = await this.addOnlineTemporaryPassword( + addDoorLockOfflineTempDto, + doorLockUuid, + 'once', + false, + ); + if (!createOnlinePass) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } + const createOnceOfflinePass = await this.addOfflineTemporaryPasswordTuya( + addDoorLockOfflineTempDto, + createOnlinePass.id, + createOnlinePass.deviceTuyaUuid, + 'once', + ); + if (!createOnceOfflinePass.success) { + throw new HttpException( + createOnceOfflinePass.msg, + HttpStatus.BAD_REQUEST, + ); + } + return { + result: createOnceOfflinePass.result, + }; + } catch (error) { + throw new HttpException( + error.message || 'Error adding offline temporary password from Tuya', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async addOfflineTemporaryPasswordTuya( + addDoorLockDto: AddDoorLockOnlineInterface, + onlinePassId: number, + doorLockUuid: string, + type: string, + ): Promise { + try { + const path = `/v1.1/devices/${doorLockUuid}/door-lock/offline-temp-password`; + + const response = await this.tuya.request({ + method: 'POST', + path, + body: { + name: addDoorLockDto.name, + ...(type === 'multiple' && { + effective_time: addDoorLockDto.effectiveTime, + invalid_time: addDoorLockDto.invalidTime, + }), + + type, + password_id: onlinePassId, + }, + }); + + return response as createTickInterface; + } catch (error) { + throw new HttpException( + 'Error adding offline temporary password from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async addOnlineTemporaryPassword( + addDoorLockDto: AddDoorLockOnlineInterface, + doorLockUuid: string, + type: string = 'once', + isOnline: boolean = true, + ) { + try { + const passwordData = await this.getTicketAndEncryptedPassword( + doorLockUuid, + addDoorLockDto.password, + ); + if ( + !passwordData.ticketKey || + !passwordData.encryptedPassword || + !passwordData.deviceTuyaUuid + ) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } + const addDeviceObj: addDeviceObjectInterface = { + ...addDoorLockDto, + ...passwordData, + }; + const createPass = await this.addOnlineTemporaryPasswordTuya( + addDeviceObj, + passwordData.deviceTuyaUuid, + type, + isOnline, + ); + + if (!createPass.success) { + throw new HttpException(createPass.msg, HttpStatus.BAD_REQUEST); + } + return { + id: createPass.result.id, + deviceTuyaUuid: passwordData.deviceTuyaUuid, + }; + } catch (error) { + throw new HttpException( + error.message || 'Error adding online temporary password from Tuya', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getTicketAndEncryptedPassword( + doorLockUuid: string, + passwordPlan: string, + ) { + try { + const deviceDetails = await this.getDeviceByDeviceUuid(doorLockUuid); + + if (!deviceDetails || !deviceDetails.deviceTuyaUuid) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } else if (deviceDetails.productDevice.prodType !== ProductType.DL) { + throw new HttpException( + 'This is not a door lock device', + HttpStatus.BAD_REQUEST, + ); + } + const ticketDetails = await this.createDoorLockTicketTuya( + deviceDetails.deviceTuyaUuid, + ); + + if (!ticketDetails.result.ticket_id || !ticketDetails.result.ticket_key) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } + const decrypted = + this.passwordEncryptionService.generateEncryptedPassword( + passwordPlan, + ticketDetails.result.ticket_key, + ); + return { + ticketId: ticketDetails.result.ticket_id, + ticketKey: ticketDetails.result.ticket_key, + encryptedPassword: decrypted, + deviceTuyaUuid: deviceDetails.deviceTuyaUuid, + }; + } catch (error) { + throw new HttpException( + error.message || 'Error processing the request', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async createDoorLockTicketTuya( + deviceUuid: string, + ): Promise { + try { + const path = `/v1.0/smart-lock/devices/${deviceUuid}/password-ticket`; + const response = await this.tuya.request({ + method: 'POST', + path, + }); + + return response as createTickInterface; + } catch (error) { + throw new HttpException( + 'Error creating door lock ticket from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async addOnlineTemporaryPasswordTuya( + addDeviceObj: addDeviceObjectInterface, + doorLockUuid: string, + type: string, + isOnline: boolean = true, + ): Promise { + try { + const path = `/v1.0/devices/${doorLockUuid}/door-lock/temp-password`; + let scheduleList; + if (isOnline) { + scheduleList = addDeviceObj.scheduleList.map((schedule) => ({ + effective_time: this.timeToMinutes(schedule.effectiveTime), + invalid_time: this.timeToMinutes(schedule.invalidTime), + working_day: this.getWorkingDayValue(schedule.workingDay), + })); + } + + const response = await this.tuya.request({ + method: 'POST', + path, + body: { + name: addDeviceObj.name, + password: addDeviceObj.encryptedPassword, + effective_time: addDeviceObj.effectiveTime, + invalid_time: addDeviceObj.invalidTime, + password_type: 'ticket', + ticket_id: addDeviceObj.ticketId, + ...(isOnline && { + schedule_list: scheduleList, + }), + type: type === 'multiple' ? '0' : '1', + }, + }); + + return response as createTickInterface; + } catch (error) { + throw new HttpException( + error.msg || 'Error adding online temporary password from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + getWorkingDayValue(days) { + // Array representing the days of the week + const weekDays = ['Sat', 'Fri', 'Thu', 'Wed', 'Tue', 'Mon', 'Sun']; + + // Initialize a binary string with 7 bits + let binaryString = '0000000'; + + // Iterate through the input array and update the binary string + days.forEach((day) => { + const index = weekDays.indexOf(day); + if (index !== -1) { + // Set the corresponding bit to '1' + binaryString = + binaryString.substring(0, index) + + '1' + + binaryString.substring(index + 1); + } + }); + + // Convert the binary string to an integer + const workingDayValue = parseInt(binaryString, 2); + + return workingDayValue; + } + getDaysFromWorkingDayValue(workingDayValue) { + // Array representing the days of the week + const weekDays = ['Sat', 'Fri', 'Thu', 'Wed', 'Tue', 'Mon', 'Sun']; + + // Convert the integer to a binary string and pad with leading zeros to ensure 7 bits + const binaryString = workingDayValue.toString(2).padStart(7, '0'); + + // Initialize an array to hold the days of the week + const days = []; + + // Iterate through the binary string and weekDays array + for (let i = 0; i < binaryString.length; i++) { + if (binaryString[i] === '1') { + days.push(weekDays[i]); + } + } + + return days; + } + timeToMinutes(timeStr) { + try { + // Special case for "24:00" + if (timeStr === '24:00') { + return 1440; + } + + // Regular expression to validate the 24-hour time format (HH:MM) + const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/; + const match = timeStr.match(timePattern); + + if (!match) { + throw new Error('Invalid time format'); + } + + // Extract hours and minutes from the matched groups + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + + // Calculate the total minutes + const totalMinutes = hours * 60 + minutes; + + return totalMinutes; + } catch (error) { + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + minutesToTime(totalMinutes) { + try { + if ( + typeof totalMinutes !== 'number' || + totalMinutes < 0 || + totalMinutes > 1440 + ) { + throw new Error('Invalid minutes value'); + } + + if (totalMinutes === 1440) { + return '24:00'; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes}`; + } catch (error) { + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + async getDeviceByDeviceUuid( + deviceUuid: string, + withProductDevice: boolean = true, + ) { + try { + return await this.deviceRepository.findOne({ + where: { + uuid: deviceUuid, + }, + ...(withProductDevice && { relations: ['productDevice'] }), + }); + } catch (error) { + throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND); + } + } +} diff --git a/src/door-lock/services/encryption.services.ts b/src/door-lock/services/encryption.services.ts new file mode 100644 index 0000000..7e56afe --- /dev/null +++ b/src/door-lock/services/encryption.services.ts @@ -0,0 +1,57 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import * as CryptoJS from 'crypto-js'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class PasswordEncryptionService { + constructor(private readonly configService: ConfigService) {} + + encrypt(plainText: string, secretKey: string): string { + const keyBytes = CryptoJS.enc.Utf8.parse(secretKey); + + const encrypted = CryptoJS.AES.encrypt(plainText, keyBytes, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }); + + return encrypted.ciphertext.toString(CryptoJS.enc.Hex); + } + + decrypt(encryptedText: string, secretKey: string): string { + const keyBytes = CryptoJS.enc.Utf8.parse(secretKey); + + const decrypted = CryptoJS.AES.decrypt( + { + ciphertext: CryptoJS.enc.Hex.parse(encryptedText), + } as any, + keyBytes, + { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }, + ); + + return decrypted.toString(CryptoJS.enc.Utf8); + } + + generateEncryptedPassword( + plainTextPassword: string, + ticketKey: string, + ): string { + try { + const accessSecret = this.configService.get( + 'auth-config.SECRET_KEY', + ); + // The accessSecret must be 32 bytes, ensure it is properly padded or truncated + const paddedAccessSecret = accessSecret.padEnd(32, '0').slice(0, 32); + const plainTextTicketKey = this.decrypt(ticketKey, paddedAccessSecret); + + return this.encrypt(plainTextPassword, plainTextTicketKey); + } catch (error) { + throw new HttpException( + `Error encrypting password: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/door-lock/services/index.ts b/src/door-lock/services/index.ts new file mode 100644 index 0000000..e847a10 --- /dev/null +++ b/src/door-lock/services/index.ts @@ -0,0 +1 @@ +export * from './door.lock.service';