diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e723d12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git* +README.md diff --git a/Dockerfile b/Dockerfile index a34f964..04a225c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ -FROM node:20-bookworm -LABEL description="Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout" +FROM node:20-bookworm-slim AS build-stage # Create app directory RUN mkdir -p /usr/src/app @@ -13,9 +12,20 @@ RUN npm install COPY . /usr/src/app # Run tests -RUN npm run test +RUN npm run test ; \ + rm -r tests coverage + +# Compile +RUN npm run build -RUN rm -r tests -RUN rm -r coverage +# Remove devel-only dependencies +RUN npm prune --omit dev + +FROM node:20-bookworm-slim +LABEL description="Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout" + +COPY --from=build-stage /usr/src/app /usr/src/app + +WORKDIR /usr/src/app -CMD [ "npm", "start" ] +CMD [ "npm", "run", "start-heroku" ] diff --git a/package-lock.json b/package-lock.json index b5d5050..20fefff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nightscout-librelink-up", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nightscout-librelink-up", - "version": "2.5.1", + "version": "2.6.0", "license": "MIT", "dependencies": { "axios": "~1.6.8", diff --git a/package.json b/package.json index b5306fe..3e3c10a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nightscout-librelink-up", - "version": "2.5.1", + "version": "2.6.0", "description": "Script written in TypeScript that uploads CGM readings from LibreLink Up to Nightscout", "main": "dist/index.js", "scripts": { diff --git a/src/config.ts b/src/config.ts index 56313ce..f4519bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,22 +1,87 @@ -function readConfig() { - const requiredEnvs = ['NIGHTSCOUT_API_TOKEN', 'NIGHTSCOUT_URL']; - for (let envName of requiredEnvs) { - if (!process.env[envName]) { - throw Error(`Required environment variable ${envName} is not set`); - } - } - - const protocol = - process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true' ? 'http://' : 'https://'; - const url = new URL(protocol + process.env.NIGHTSCOUT_URL); - - return { - nightscoutApiToken: process.env.NIGHTSCOUT_API_TOKEN as string, - nightscoutBaseUrl: url.toString(), - - nightscoutApiV3: process.env.NIGHTSCOUT_API_V3 === 'true', - nightscoutDevice: process.env.DEVICE_NAME || 'nightscout-librelink-up', - }; +import {LLU_API_ENDPOINTS} from './constants/llu-api-endpoints'; + +function readConfig() +{ + let requiredEnvs: string[] = []; + + if (!isTest()) + { + requiredEnvs = [ + 'NIGHTSCOUT_API_TOKEN', + 'NIGHTSCOUT_URL', + 'LINK_UP_USERNAME', + 'LINK_UP_PASSWORD', + ]; + } + + for (let envName of requiredEnvs) + { + if (!process.env[envName]) + { + exitLog(`Required environment variable ${envName} is not set`) + } + } + + if (process.env.LOG_LEVEL) + { + if (!['info', 'debug'].includes(process.env.LOG_LEVEL.toLowerCase())) + { + exitLog(`LOG_LEVEL should be either 'info' or 'debug', but got '${process.env.LOG_LEVEL}'`); + } + } + + if (process.env.LINK_UP_REGION) + { + if (!LLU_API_ENDPOINTS.hasOwnProperty(process.env.LINK_UP_REGION)) + { + exitLog(`LINK_UP_REGION should be one of ${Object.keys(LLU_API_ENDPOINTS)}, but got ${process.env.LINK_UP_REGION}`); + } + } + + if (process.env.LINK_UP_TIME_INTERVAL) + { + if (isNaN(parseInt(process.env.LINK_UP_TIME_INTERVAL))) + { + exitLog(`LINK_UP_TIME_INTERVAL expected to be an integer, but got '${process.env.LINK_UP_TIME_INTERVAL}'`); + } + } + + const protocol = + process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true' ? 'http://' : 'https://'; + const url = new URL(protocol + process.env.NIGHTSCOUT_URL); + + return { + nightscoutApiToken: process.env.NIGHTSCOUT_API_TOKEN as string, + nightscoutBaseUrl: url.toString(), + linkUpUsername: process.env.LINK_UP_USERNAME as string, + linkUpPassword: process.env.LINK_UP_PASSWORD as string, + + logLevel: process.env.LOG_LEVEL || 'info', + singleShot: process.env.SINGLE_SHOT === 'true', + + nightscoutApiV3: process.env.NIGHTSCOUT_API_V3 === 'true', + nightscoutDisableHttps: process.env.NIGHTSCOUT_DISABLE_HTTPS === 'true', + nightscoutDevice: process.env.DEVICE_NAME || 'nightscout-librelink-up', + + linkUpRegion: process.env.LINK_UP_REGION || 'EU', + linkUpTimeInterval: Number(process.env.LINK_UP_TIME_INTERVAL) || 5, + linkUpConnection: process.env.LINK_UP_CONNECTION as string, + }; +} + +function exitLog(msg: string): void +{ + console.log(msg); + + if (!isTest()) + { + process.exit(1); + } +} + +function isTest(): boolean +{ + return typeof jest !== "undefined"; } export default readConfig; diff --git a/src/index.ts b/src/index.ts index 253601d..6b81066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,19 +24,25 @@ import {Agent as HttpAgent} from "node:http"; import {Agent as HttpsAgent} from "node:https"; import * as crypto from "crypto"; -// Generate new Cyphers for stealth mode in order to bypass SSL fingerprinting used by Cloudflare. -// The new Cyphers are then used in the HTTPS Agent for Axios. -const defaultCyphers: Array = crypto.constants.defaultCipherList.split(":"); -const stealthCyphers: Array = defaultCyphers.slice(0, 3); +// Generate new Ciphers for stealth mode in order to bypass SSL fingerprinting used by Cloudflare. +// The new Ciphers are then used in the HTTPS Agent for Axios. +const defaultCiphers: Array = crypto.constants.defaultCipherList.split(":"); +const stealthCiphers: Array = [ + defaultCiphers[0], + defaultCiphers[2], + defaultCiphers[1], + ...defaultCiphers.slice(3) +]; + const stealthHttpsAgent: HttpsAgent = new HttpsAgent({ - ciphers: stealthCyphers.join(":") + ciphers: stealthCiphers.join(":") }); // Create a new CookieJar and HttpCookieAgent for Axios to handle cookies. const jar: CookieJar = new CookieJar(); const cookieAgent: HttpAgent = new HttpCookieAgent({cookies: {jar}}) -const config = readConfig(); +let config = readConfig(); const {combine, timestamp, printf} = format; @@ -51,50 +57,34 @@ const logger = createLogger({ logFormat ), transports: [ - new transports.Console({level: process.env.LOG_LEVEL || "info"}), + new transports.Console({level: config.logLevel}), ] }); -axios.interceptors.response.use(response => -{ - return response; -}, error => -{ - if (error.response) - { - logger.error(JSON.stringify(error.response.data)); - } - else +axios.interceptors.response.use( + response => response, + error => { - logger.error(error.message); + if (error.response) + { + logger.error(JSON.stringify(error.response.data)); + } + else + { + logger.error(error.message); + } + return error; } - return error; -}); +); -const USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1"; - -/** - * LibreLink Up Credentials - */ -const LINK_UP_USERNAME = process.env.LINK_UP_USERNAME; -const LINK_UP_PASSWORD = process.env.LINK_UP_PASSWORD; +const USER_AGENT = "Mozilla/5.0 (iPhone; CPU OS 17_4.1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/17.4.1 Mobile/10A5355d Safari/8536.25"; /** * LibreLink Up API Settings (Don't change this unless you know what you are doing) */ -const LIBRE_LINK_UP_VERSION = "4.7.0"; +const LIBRE_LINK_UP_VERSION = "4.10.0"; const LIBRE_LINK_UP_PRODUCT = "llu.ios"; -const LINK_UP_REGION = process.env.LINK_UP_REGION || "EU"; -const LIBRE_LINK_UP_URL = getLibreLinkUpUrl(LINK_UP_REGION); - -function getLibreLinkUpUrl(region: string): string -{ - if (LLU_API_ENDPOINTS.hasOwnProperty(region)) - { - return LLU_API_ENDPOINTS[region]; - } - return LLU_API_ENDPOINTS.EU; -} +const LIBRE_LINK_UP_URL = LLU_API_ENDPOINTS[config.linkUpRegion]; /** * last known authTicket @@ -108,13 +98,13 @@ const libreLinkUpHttpHeaders: LibreLinkUpHttpHeaders = { "product": LIBRE_LINK_UP_PRODUCT } -if (process.env.SINGLE_SHOT === "true") +if (config.singleShot) { main().then(); } else { - const schedule = "*/" + (process.env.LINK_UP_TIME_INTERVAL || 5) + " * * * *"; + const schedule = `*/${config.linkUpTimeInterval} * * * *`; logger.info("Starting cron schedule: " + schedule) cron.schedule(schedule, () => { @@ -150,14 +140,16 @@ async function main(): Promise export async function login(): Promise { + config = readConfig() + try { const url = "https://" + LIBRE_LINK_UP_URL + "/llu/auth/login" const response: { data: LoginResponse } = await axios.post( url, { - email: LINK_UP_USERNAME, - password: LINK_UP_PASSWORD, + email: config.linkUpUsername, + password: config.linkUpPassword, }, { headers: libreLinkUpHttpHeaders, @@ -165,7 +157,7 @@ export async function login(): Promise httpAgent: cookieAgent, httpsAgent: stealthHttpsAgent }); - + try { if (response.data.status !== 0) @@ -197,6 +189,8 @@ export async function login(): Promise export async function getGlucoseMeasurements(): Promise { + config = readConfig() + try { const connectionId = await getLibreLinkUpConnection(); @@ -226,6 +220,8 @@ export async function getGlucoseMeasurements(): Promise export async function getLibreLinkUpConnection(): Promise { + config = readConfig() + try { const url = "https://" + LIBRE_LINK_UP_URL + "/llu/connections" @@ -255,14 +251,15 @@ export async function getLibreLinkUpConnection(): Promise dumpConnectionData(connectionData); - if (!process.env.LINK_UP_CONNECTION) + if (!config.linkUpConnection) { logger.warn("You did not specify a Patient-ID in the LINK_UP_CONNECTION environment variable."); logPickedUpConnection(connectionData[0]); return connectionData[0].patientId; } - const connection = connectionData.filter(connectionEntry => connectionEntry.patientId === process.env.LINK_UP_CONNECTION)[0]; + const connection = connectionData.filter(connectionEntry => connectionEntry.patientId === config.linkUpConnection)[0]; + if (!connection) { logger.error("The specified Patient-ID was not found."); diff --git a/tests/unit-tests/librelink/librelink.test.ts b/tests/unit-tests/librelink/librelink.test.ts index 32ddb00..c0cc30a 100644 --- a/tests/unit-tests/librelink/librelink.test.ts +++ b/tests/unit-tests/librelink/librelink.test.ts @@ -1,14 +1,7 @@ import "jest"; -import { - createFormattedMeasurements, - getGlucoseMeasurements, - getLibreLinkUpConnection, - login, -} from "../../../src"; +import {createFormattedMeasurements, getGlucoseMeasurements, getLibreLinkUpConnection, login,} from "../../../src"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; - -const mock = new MockAdapter(axios); import {default as loginSuccessResponse} from "../../data/login.json"; import {default as loginFailedResponse} from "../../data/login-failed.json"; import {default as connectionsResponse} from "../../data/connections.json"; @@ -17,6 +10,9 @@ import {default as graphResponse} from "../../data/graph.json"; import {AuthTicket} from "../../../src/interfaces/librelink/common"; import {GraphData} from "../../../src/interfaces/librelink/graph-response"; import {Entry} from "../../../src/nightscout/interface"; +import readConfig from "../../../src/config"; + +const mock = new MockAdapter(axios); mock.onPost("https://api-eu.libreview.io/llu/auth/login").reply(200, loginSuccessResponse); mock.onGet("https://api-eu.libreview.io/llu/connections").reply(200, connectionsResponse); @@ -64,6 +60,7 @@ describe("LibreLink Up", () => it("Get available connections - Second available patient-id", async () => { process.env.LINK_UP_CONNECTION = "77179667-ba4b-11eb-ad1f-0242ac110004"; + const config = readConfig(); const connectionId: string | null = await getLibreLinkUpConnection(); expect(connectionId).toBe("77179667-ba4b-11eb-ad1f-0242ac110004"); });