From 97a254b5d23507b61df05b879d9a46197b388751 Mon Sep 17 00:00:00 2001 From: Matthew Lieder Date: Sun, 28 Jan 2024 17:27:02 -0600 Subject: [PATCH] synology-ss: make login more resilient (#1289) Fixes #1266 --- plugins/synology-ss/package-lock.json | 191 +++++++++++++++--- plugins/synology-ss/package.json | 6 +- .../src/api/synology-api-client.ts | 21 +- plugins/synology-ss/src/main.ts | 138 ++++++++----- plugins/synology-ss/tsconfig.json | 2 +- 5 files changed, 266 insertions(+), 92 deletions(-) diff --git a/plugins/synology-ss/package-lock.json b/plugins/synology-ss/package-lock.json index 8fd85df06..1ef2f6ac3 100644 --- a/plugins/synology-ss/package-lock.json +++ b/plugins/synology-ss/package-lock.json @@ -1,30 +1,30 @@ { "name": "@scrypted/synology-ss", - "version": "0.0.16", + "version": "0.0.17", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/synology-ss", - "version": "0.0.16", + "version": "0.0.17", "license": "Apache", "dependencies": { - "axios": "^0.24.0" + "axios": "^1.0.0" }, "devDependencies": { "@scrypted/sdk": "file:../../sdk", - "@types/node": "^16.6.1" + "@types/node": "^18.0.0" } }, "../../sdk": { "name": "@scrypted/sdk", - "version": "0.2.86", + "version": "0.3.5", "dev": true, "license": "ISC", "dependencies": { "@babel/preset-typescript": "^7.18.6", "adm-zip": "^0.4.13", - "axios": "^0.21.4", + "axios": "^1.6.5", "babel-loader": "^9.1.0", "babel-plugin-const-enum": "^1.1.0", "esbuild": "^0.15.9", @@ -59,23 +59,52 @@ "link": true }, "node_modules/@types/node": { - "version": "16.11.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.7.tgz", - "integrity": "sha512-QB5D2sqfSjCmTuWcBWyJ+/44bcjO7VbjSbOE0ucoVbAsSNQc4Lt6QkgkVXkTDwkL4z/beecZNDvVX15D4P8Jbw==", - "dev": true + "version": "18.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.10.tgz", + "integrity": "sha512-IZD8kAM02AW1HRDTPOlz3npFava678pr8Ie9Vp8uRhBROXAv8MXT2pCnGZZAKYdromsNQLHQcfWQ6EOatVLtqA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.14.4" + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" } }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -90,6 +119,49 @@ "optional": true } } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true } }, "dependencies": { @@ -100,7 +172,7 @@ "@types/node": "^18.11.18", "@types/stringify-object": "^4.0.0", "adm-zip": "^0.4.13", - "axios": "^0.21.4", + "axios": "^1.6.5", "babel-loader": "^9.1.0", "babel-plugin-const-enum": "^1.1.0", "esbuild": "^0.15.9", @@ -118,23 +190,80 @@ } }, "@types/node": { - "version": "16.11.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.7.tgz", - "integrity": "sha512-QB5D2sqfSjCmTuWcBWyJ+/44bcjO7VbjSbOE0ucoVbAsSNQc4Lt6QkgkVXkTDwkL4z/beecZNDvVX15D4P8Jbw==", - "dev": true - }, - "axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "version": "18.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.10.tgz", + "integrity": "sha512-IZD8kAM02AW1HRDTPOlz3npFava678pr8Ie9Vp8uRhBROXAv8MXT2pCnGZZAKYdromsNQLHQcfWQ6EOatVLtqA==", + "dev": true, "requires": { - "follow-redirects": "^1.14.4" + "undici-types": "~5.26.4" } }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "requires": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true } } } diff --git a/plugins/synology-ss/package.json b/plugins/synology-ss/package.json index 452ac98c6..bd435cfbd 100644 --- a/plugins/synology-ss/package.json +++ b/plugins/synology-ss/package.json @@ -1,6 +1,6 @@ { "name": "@scrypted/synology-ss", - "version": "0.0.16", + "version": "0.0.17", "description": "A Synology Surveillance Station plugin for Scrypted", "author": "Scrypted", "license": "Apache", @@ -36,10 +36,10 @@ ] }, "dependencies": { - "axios": "^0.24.0" + "axios": "^1.0.0" }, "devDependencies": { "@scrypted/sdk": "file:../../sdk", - "@types/node": "^16.6.1" + "@types/node": "^18.0.0" } } diff --git a/plugins/synology-ss/src/api/synology-api-client.ts b/plugins/synology-ss/src/api/synology-api-client.ts index 5a7b0bee7..185070155 100644 --- a/plugins/synology-ss/src/api/synology-api-client.ts +++ b/plugins/synology-ss/src/api/synology-api-client.ts @@ -164,11 +164,12 @@ export class SynologyApiClient { const response = await this.client.get>(url ?? await this.getApiPath(params.api), { params }); if (!response.data?.success) { - if (response.data?.error?.code) { + const errorCode = response.data?.error?.code; + if (errorCode) { const errorCodeLookup = { ...errorCodeDescriptions, ...extraErrorCodes } - throw new Error(`${errorCodeLookup[response.data.error.code]} (error code ${response.data.error.code})`) + throw new SynologyApiError(`${errorCodeLookup[errorCode]} (error code ${errorCode})`, errorCode) } else { - throw new Error(`Synology API call failed with status code ${response.status}`); + throw new SynologyApiError(`Synology API call failed with status code ${response.status}`); } } @@ -186,7 +187,17 @@ export interface SynologyApiInfo { maxVersion: number; } -export interface SynologyApiError { +export class SynologyApiError extends Error { + code?: string; + constructor(message: string, code?: string) { + super(message); + + this.name = 'SynologyApiError'; + this.code = code; + } +} + +export interface SynologyApiErrorObject { code: string; } @@ -198,7 +209,7 @@ interface SynologyApiRequestParams { interface SynologyApiResponse { data?: T; - error?: SynologyApiError; + error?: SynologyApiErrorObject; success: boolean; } diff --git a/plugins/synology-ss/src/main.ts b/plugins/synology-ss/src/main.ts index 17988658f..1fa7bcabf 100644 --- a/plugins/synology-ss/src/main.ts +++ b/plugins/synology-ss/src/main.ts @@ -1,6 +1,6 @@ import sdk, { Camera, Device, DeviceProvider, HttpRequest, HttpRequestHandler, HttpResponse, MediaObject, MediaStreamOptions, MediaStreamUrl, MotionSensor, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, VideoCamera } from "@scrypted/sdk"; import { createInstanceableProviderPlugin, enableInstanceableProviderMode, isInstanceableProviderModeEnabled } from '../../../common/src/provider-plugin'; -import { SynologyApiClient, SynologyCamera, SynologyCameraStream } from "./api/synology-api-client"; +import { SynologyApiClient, SynologyApiError, SynologyCamera, SynologyCameraStream } from "./api/synology-api-client"; const { deviceManager } = sdk; @@ -162,10 +162,11 @@ class SynologyCameraDevice extends ScryptedDeviceBase implements Camera, HttpReq } class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings, DeviceProvider { - private cameras: SynologyCamera[]; + private cameras: SynologyCamera[] = []; private cameraDevices: Map = new Map(); api: SynologyApiClient; private startup: Promise; + private discovering: boolean; constructor(nativeId?: string) { super(nativeId); @@ -177,66 +178,23 @@ class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings } public async discoverDevices(duration: number): Promise { - const url = this.getSetting('url'); - const username = this.getSetting('username'); - const password = this.getSetting('password'); - const otpCode = this.getSetting('otpCode'); - const mfaDeviceId = this.getSetting('mfaDeviceId'); + if (this.discovering) return; + this.discovering = true; - this.log.clearAlerts(); - - if (!url) { - this.log.a('Must provide URL.'); - return - } - - if (!username) { - this.log.a('Must provide username.'); - return - } - - if (!password) { - this.log.a('Must provide password.'); - return - } - - if (!this.api || url !== this.api.url) { - this.api = new SynologyApiClient(url); - } + this.console.info(`Fetching list of cameras from Synology server...`); try { - const newMfaDeviceId = await this.api.login(username, password, otpCode ? parseInt(otpCode) : undefined, !!otpCode, 'Scrypted', mfaDeviceId); - - // If a OTP was present, store the device ID to allow us to skip the OTP requirement next login. - if (otpCode) { - this.storage.setItem('mfaDeviceId', newMfaDeviceId); + if (!await this.tryLogin()) { + return; } - } - catch (e) { - this.log.a(`login error: ${e}`); - this.console.error('login error', e); - // Clear device ID upon login failure, since it's likely useless now - this.storage.removeItem('mfaDeviceId'); - - return; - } - finally { - // Clear the OTP setting if provided since it's a temporary code - if (otpCode) { - this.storage.removeItem('otpCode'); - this.onDeviceEvent(ScryptedInterface.Settings, undefined); - } - } - - try { this.cameras = await this.api.listCameras(); if (!this.cameras) { this.console.error('Cameras failed to load. Retrying in 10 seconds.'); setTimeout(() => { this.discoverDevices(0); - }, 100000); + }, 10000); return; } @@ -285,6 +243,8 @@ class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings catch (e) { this.log.a(`device discovery error: ${e}`); this.console.error('device discovery error', e); + } finally { + this.discovering = false; } } @@ -353,7 +313,81 @@ class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings return; } this.storage.setItem(key, value.toString()); - this.discoverDevices(0); + + // Delaying discover in case user updated multiple settings, so that it doesn't run until all have been set + setTimeout(() => this.discoverDevices(0), 200); + } + + private async tryLogin(): Promise { + this.console.info('Logging into Synology...'); + + const url = this.getSetting('url'); + const username = this.getSetting('username'); + const password = this.getSetting('password'); + const otpCode = this.getSetting('otpCode'); + const mfaDeviceId = this.getSetting('mfaDeviceId'); + + this.log.clearAlerts(); + + if (!url) { + this.log.a('Must provide URL.'); + return + } + + if (!username) { + this.log.a('Must provide username.'); + return + } + + if (!password) { + this.log.a('Must provide password.'); + return + } + + if (!this.api || url !== this.api.url) { + this.api = new SynologyApiClient(url); + } + + let successful = false; + for (let attempt=1; attempt<=3; attempt++) { + try { + const newMfaDeviceId = await this.api.login(username, password, otpCode ? parseInt(otpCode) : undefined, !!otpCode, 'Scrypted', mfaDeviceId); + + // If a OTP was present, store the device ID to allow us to skip the OTP requirement next login. + if (otpCode) { + this.storage.setItem('mfaDeviceId', newMfaDeviceId); + } + + successful = true; + } + catch (e) { + this.log.a(`login error on attempt ${attempt}: ${e}`); + this.console.error(`login error on attempt ${attempt}`, e); + + if (e instanceof SynologyApiError) { + break; + } else { + // Retry on failures that aren't Synology-specific, such as timeouts + await new Promise((resolve) => setTimeout(resolve, attempt * 1000)); + continue; + } + } + finally { + // Clear the OTP setting if provided since it's a temporary code + if (otpCode) { + this.storage.removeItem('otpCode'); + this.onDeviceEvent(ScryptedInterface.Settings, undefined); + } + } + } + + if (successful) { + this.console.info(`Successfully logged into Synology`); + } else { + this.console.info(`Failed to log into Synology`); + } + + return successful; } } diff --git a/plugins/synology-ss/tsconfig.json b/plugins/synology-ss/tsconfig.json index 34a847ad8..ba9b4d395 100644 --- a/plugins/synology-ss/tsconfig.json +++ b/plugins/synology-ss/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", "target": "ES2021", "resolveJsonModule": true, "moduleResolution": "Node16",