diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6555378..376d547 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,13 @@ jobs: - name: test run: pnpm run test:coverage + - name: Upload all coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/coverage-final.json + fail_ci_if_error: false + - name: Setup NPM Authentication if: ${{ needs.release-please.outputs.release_created }} run: | diff --git a/.gitignore b/.gitignore index 42787ac..e31ac31 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules .env .env.local .env.development -.env.production \ No newline at end of file +.env.production +coverage/ \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 7b732e4..5ec5875 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -5,5 +5,6 @@ export declare const useCallback: (config: CallbackConfig) => { isDataURIEncoded?: boolean; }) => QueryPayloads; watcher: (options?: WatcherOptions) => QueryPayloads | undefined; + generateUrl: (url: string, payload: SendPayloads, sendType?: string, sender?: string) => string; }; export type { CallbackConfig, QueryPayloads, SendPayloads, WatcherOptions, SignIn, SignOut, OemSignOut, Troubleshoot, Recover, Replace, TrialExtend, TrialStart, Purchase, Redeem, Renew, Upgrade, UpdateOs, DowngradeOs, Manage, MyKeys, LinkKey, Activate, AccountActionTypes, AccountKeyActionTypes, PurchaseActionTypes, ServerActionTypes, ServerState, ServerData, UserInfo, ExternalSignIn, ExternalSignOut, ExternalKeyActions, ExternalUpdateOsAction, ServerPayload, ServerTroubleshoot, ExternalActions, UpcActions, ExternalPayload, UpcPayload, }; diff --git a/dist/index.js b/dist/index.js index d458a1b..99928d6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,6 +3,10 @@ import Utf8 from "crypto-js/enc-utf8.js"; import { createSharedComposable } from "@vueuse/core"; const _useCallback = (config) => { const send = (url, payload, redirectType, sendType, sender) => { + // send() requires browser APIs and is client-only + if (typeof window === "undefined") { + throw new Error("send() can only be called on the client side"); + } const stringifiedData = JSON.stringify({ actions: [...payload], sender: sender ?? window.location.href.replace("/Tools/Update", "/Tools"), @@ -26,28 +30,77 @@ const _useCallback = (config) => { ? decodeURI(data) : data; const decryptedMessage = AES.decrypt(dataToParse, config.encryptionKey); - const decryptedData = JSON.parse(decryptedMessage.toString(Utf8)); - return decryptedData; + let decryptedString; + try { + decryptedString = decryptedMessage.toString(Utf8); + } + catch (e) { + // Catch errors during UTF-8 conversion (likely due to bad decryption) + throw new Error('Decryption failed. Invalid key or corrupt data.'); + } + // Check if decryption resulted in an empty string (another failure case) + if (!decryptedString) { + throw new Error('Decryption failed. Invalid key or corrupt data.'); + } + try { + const decryptedData = JSON.parse(decryptedString); + return decryptedData; + } + catch (e) { + // Catch potential JSON parse errors even if decryption seemed successful + throw new Error('Failed to parse decrypted data.'); + } }; const watcher = (options = {}) => { let urlToParse = ""; if (options?.baseUrl && !options.skipCurrentUrl) { urlToParse = options.baseUrl; } - else if (window && window.location && !options.skipCurrentUrl) { + else if (typeof window !== "undefined" && window.location && !options.skipCurrentUrl) { urlToParse = window.location.toString(); } - const currentUrl = new URL(urlToParse); - const uriDecodedEncryptedData = decodeURI(options?.dataToParse ?? currentUrl?.searchParams.get("data") ?? ""); + else if (!options?.dataToParse && !options?.baseUrl) { + // If no window and no explicit data/baseUrl provided, return undefined + return undefined; + } + // If we have dataToParse, use it directly; otherwise parse from URL + const uriDecodedEncryptedData = options?.dataToParse + ? decodeURI(options.dataToParse) + : (() => { + try { + const currentUrl = new URL(urlToParse); + return decodeURI(currentUrl.searchParams.get("data") ?? ""); + } + catch { + return ""; + } + })(); if (!uriDecodedEncryptedData) { return undefined; } return parse(uriDecodedEncryptedData); }; + const generateUrl = (url, payload, sendType, sender) => { + // generateUrl() works on both server and client + // If no sender provided and we're on client, use window.location; otherwise use empty string + const defaultSender = sender ?? (typeof window !== "undefined" + ? window.location.href.replace("/Tools/Update", "/Tools") + : ""); + const stringifiedData = JSON.stringify({ + actions: [...payload], + sender: defaultSender, + type: sendType, + }); + const encryptedMessage = AES.encrypt(stringifiedData, config.encryptionKey).toString(); + const destinationUrl = new URL(url); + destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + return destinationUrl.toString(); + }; return { send, parse, watcher, + generateUrl, }; }; export const useCallback = createSharedComposable(_useCallback); diff --git a/dist/types.d.ts b/dist/types.d.ts index c61c0f7..678400b 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -1,27 +1,42 @@ -export type SignIn = 'signIn'; -export type SignOut = 'signOut'; -export type OemSignOut = 'oemSignOut'; -export type Troubleshoot = 'troubleshoot'; -export type Recover = 'recover'; -export type Replace = 'replace'; -export type TrialExtend = 'trialExtend'; -export type TrialStart = 'trialStart'; -export type Purchase = 'purchase'; -export type Redeem = 'redeem'; -export type Renew = 'renew'; -export type Upgrade = 'upgrade'; -export type UpdateOs = 'updateOs'; -export type DowngradeOs = 'downgradeOs'; -export type Manage = 'manage'; -export type MyKeys = 'myKeys'; -export type LinkKey = 'linkKey'; -export type Activate = 'activate'; +export type SignIn = "signIn"; +export type SignOut = "signOut"; +export type OemSignOut = "oemSignOut"; +export type Troubleshoot = "troubleshoot"; +export type Recover = "recover"; +export type Replace = "replace"; +export type TrialExtend = "trialExtend"; +export type TrialStart = "trialStart"; +export type Purchase = "purchase"; +export type Redeem = "redeem"; +export type Renew = "renew"; +export type Upgrade = "upgrade"; +export type UpdateOs = "updateOs"; +export type DowngradeOs = "downgradeOs"; +export type Manage = "manage"; +export type MyKeys = "myKeys"; +export type LinkKey = "linkKey"; +export type Activate = "activate"; export type AccountActionTypes = Troubleshoot | SignIn | SignOut | OemSignOut | Manage | MyKeys | LinkKey; export type AccountKeyActionTypes = Recover | Replace | TrialExtend | TrialStart | UpdateOs | DowngradeOs; export type PurchaseActionTypes = Purchase | Redeem | Renew | Upgrade | Activate; export type ServerActionTypes = AccountActionTypes | AccountKeyActionTypes | PurchaseActionTypes; -export type ServerState = 'BASIC' | 'PLUS' | 'PRO' | 'TRIAL' | 'EEXPIRED' | 'ENOKEYFILE' | 'EGUID' | 'EGUID1' | 'ETRIAL' | 'ENOKEYFILE2' | 'ENOKEYFILE1' | 'ENOFLASH' | 'ENOFLASH1' | 'ENOFLASH2' | 'ENOFLASH3' | 'ENOFLASH4' | 'ENOFLASH5' | 'ENOFLASH6' | 'ENOFLASH7' | 'EBLACKLISTED' | 'EBLACKLISTED1' | 'EBLACKLISTED2' | 'ENOCONN' | 'STARTER' | 'UNLEASHED' | 'LIFETIME' | 'STALE' | undefined; +export type ServerState = "BASIC" | "PLUS" | "PRO" | "TRIAL" | "EEXPIRED" | "ENOKEYFILE" | "EGUID" | "EGUID1" | "ETRIAL" | "ENOKEYFILE2" | "ENOKEYFILE1" | "ENOFLASH" | "ENOFLASH1" | "ENOFLASH2" | "ENOFLASH3" | "ENOFLASH4" | "ENOFLASH5" | "ENOFLASH6" | "ENOFLASH7" | "EBLACKLISTED" | "EBLACKLISTED1" | "EBLACKLISTED2" | "ENOCONN" | "STARTER" | "UNLEASHED" | "LIFETIME" | "STALE" | undefined; +export interface ActivationCodeData { + __typename?: "ActivationCode"; + background?: string | null; + code?: string | null; + comment?: string | null; + header?: string | null; + headermetacolor?: string | null; + partnerName?: string | null; + partnerUrl?: string | null; + serverName?: string | null; + showBannerGradient?: boolean | null; + sysModel?: string | null; + theme?: string | null; +} export interface ServerData { + activationCodeData?: ActivationCodeData | null; description?: string; deviceCount?: number; expireTime?: number; @@ -32,7 +47,7 @@ export interface ServerData { locale?: string; name?: string; osVersion?: string; - osVersionBranch?: 'stable' | 'next' | 'preview' | 'test'; + osVersionBranch?: "stable" | "next" | "preview" | "test"; registered: boolean; regExp?: number; regUpdatesExpired?: boolean; @@ -43,14 +58,14 @@ export interface ServerData { wanFQDN?: string; } export interface UserInfo { - 'custom:ips_id'?: string; + "custom:ips_id"?: string; email?: string; - email_verifed?: 'true' | 'false'; + email_verifed?: "true" | "false"; preferred_username?: string; sub?: string; username?: string; identities?: string; - 'cognito:groups'?: string[]; + "cognito:groups"?: string[]; } export interface ExternalSignIn { type: SignIn; @@ -80,14 +95,14 @@ export type ExternalActions = ExternalSignIn | ExternalSignOut | ExternalKeyActi export type UpcActions = ServerPayload | ServerTroubleshoot; export type SendPayloads = ExternalActions[] | UpcActions[]; export interface ExternalPayload { - type: 'forUpc'; + type: "forUpc"; actions: ExternalActions[]; sender: string; } export interface UpcPayload { actions: UpcActions[]; sender: string; - type: 'fromUpc'; + type: "fromUpc"; } export type QueryPayloads = ExternalPayload | UpcPayload; export interface WatcherOptions { diff --git a/src/__tests__/useSharedCallback.test.ts b/src/__tests__/useSharedCallback.test.ts index 12d90c3..8aa1bbc 100644 --- a/src/__tests__/useSharedCallback.test.ts +++ b/src/__tests__/useSharedCallback.test.ts @@ -102,6 +102,64 @@ describe('useCallback', () => { const decryptedData = callback.parse(encryptedData) expect(decryptedData).toEqual(testData) }) + + it('should use window.location.href when redirectType is null or undefined', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + + // Mock window.location.href setter to capture the value + let hrefValue = '' + const originalHref = Object.getOwnPropertyDescriptor(window.location, 'href') + Object.defineProperty(window.location, 'href', { + set: (val) => { hrefValue = val }, + get: () => 'http://test.com/Tools/Update', + configurable: true + }) + + try { + callback.send('http://test.com/Tools', testActions, null, 'test', 'http://test.com/Tools') + + const url = new URL(hrefValue) + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + expect(decryptedData).toEqual(testData) + } finally { + // Restore original href property + if (originalHref) { + Object.defineProperty(window.location, 'href', originalHref) + } + } + }) + + it('should normalize /Tools/Update to /Tools in destination URL', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + + let hrefValue = '' + const originalHref = Object.getOwnPropertyDescriptor(window.location, 'href') + Object.defineProperty(window.location, 'href', { + set: (val) => { hrefValue = val }, + get: () => 'http://test.com/Tools/Update', + configurable: true + }) + + try { + callback.send('http://test.com/Tools/Update', testActions, null, 'test', 'http://test.com/Tools') + + const url = new URL(hrefValue) + expect(url.pathname).toBe('/Tools') + } finally { + // Restore original href property + if (originalHref) { + Object.defineProperty(window.location, 'href', originalHref) + } + } + }) }) describe('parse function', () => { @@ -144,6 +202,31 @@ describe('useCallback', () => { // Expect parse to throw (likely the decryption error) expect(() => callback.parse(invalidData)).toThrow('Decryption failed. Invalid key or corrupt data.'); }) + + it('should decode URI when isDataURIEncoded option is true', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + const uriEncodedData = encodeURI(encryptedData) + + const decryptedData = callback.parse(uriEncodedData, { isDataURIEncoded: true }) + expect(decryptedData).toEqual(testData) + }) + + it('should throw an error for malformed JSON after successful decryption', () => { + const callback = useCallback(mockConfig) + // Create encrypted data that decrypts to invalid JSON + const invalidJson = 'not valid json' + const encryptedData = AES.encrypt(invalidJson, mockConfig.encryptionKey).toString() + + expect(() => callback.parse(encryptedData)).toThrow('Failed to parse decrypted data.') + }) }) describe('watcher function', () => { @@ -196,5 +279,269 @@ describe('useCallback', () => { const result = callback.watcher({ dataToParse: encryptedData }) expect(result).toEqual(testData) }) + + it('should work without window (server-side) when dataToParse is provided', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + + const result = callback.watcher({ dataToParse: encryptedData }) + expect(result).toEqual(testData) + + // Restore window + global.window = originalWindow + }) + + it('should return undefined on server-side when no data source is provided', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const result = callback.watcher() + expect(result).toBeUndefined() + + // Restore window + global.window = originalWindow + }) + + it('should skip current URL when skipCurrentUrl is true but still use dataToParse', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + + // When skipCurrentUrl is true, baseUrl is ignored, so we need to use dataToParse + const result = callback.watcher({ skipCurrentUrl: true, dataToParse: encryptedData }) + expect(result).toEqual(testData) + }) + + it('should handle invalid URL gracefully in watcher', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + + // Provide invalid URL as baseUrl - should fall back to dataToParse + const result = callback.watcher({ + baseUrl: 'not-a-valid-url', + dataToParse: encryptedData + }) + expect(result).toEqual(testData) + }) + + it('should return undefined when URL parsing fails and no dataToParse is provided', () => { + const callback = useCallback(mockConfig) + + // Provide invalid URL without dataToParse + const result = callback.watcher({ baseUrl: 'not-a-valid-url' }) + expect(result).toBeUndefined() + }) + }) + + describe('generateUrl function', () => { + it('should generate a URL with encrypted data', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c' + const sendType = 'forUpc' + const sender = 'http://test.com/Tools' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType, sender) + const url = new URL(generatedUrl) + + expect(url.origin + url.pathname).toBe(targetUrl) + expect(url.searchParams.has('data')).toBe(true) + + // Verify the encrypted data can be decrypted + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + expect(decryptedData).toEqual({ + actions: testActions, + sender, + type: sendType + }) + }) + + it('should use window.location.href as default sender on client-side', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c' + const sendType = 'forUpc' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) + const url = new URL(generatedUrl) + + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + + // Should use window.location.href (mocked to 'http://test.com/Tools/Update') + // which gets normalized to 'http://test.com/Tools' + expect(decryptedData.sender).toBe('http://test.com/Tools') + }) + + it('should use empty string as default sender on server-side', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c' + const sendType = 'forUpc' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, sendType) + const url = new URL(generatedUrl) + + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + + // Should use empty string when window is not available + expect(decryptedData.sender).toBe('') + + // Restore window + global.window = originalWindow + }) + + it('should work with different URL formats', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + + const urls = [ + 'http://test.com/c', + 'https://example.com/Tools', + 'https://server.local:8080/c' + ] + + urls.forEach(targetUrl => { + const generatedUrl = callback.generateUrl(targetUrl, testActions, 'forUpc', 'http://sender.com') + const url = new URL(generatedUrl) + + expect(url.origin + url.pathname).toBe(targetUrl) + expect(url.searchParams.has('data')).toBe(true) + }) + }) + + it('should preserve URL query parameters', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c?existing=param' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, 'forUpc', 'http://sender.com') + const url = new URL(generatedUrl) + + expect(url.searchParams.get('existing')).toBe('param') + expect(url.searchParams.has('data')).toBe(true) + }) + + it('should preserve URL path in generateUrl (no normalization)', () => { + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/Tools/Update' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, 'forUpc', 'http://sender.com') + const url = new URL(generatedUrl) + + // generateUrl does not normalize URLs (unlike send which does) + expect(url.pathname).toBe('/Tools/Update') + expect(url.searchParams.has('data')).toBe(true) + }) + + it('should handle empty payload arrays', () => { + const callback = useCallback(mockConfig) + const emptyActions: ExternalSignOut[] = [] + const targetUrl = 'http://test.com/c' + + const generatedUrl = callback.generateUrl(targetUrl, emptyActions, 'forUpc', 'http://sender.com') + const url = new URL(generatedUrl) + const encryptedData = url.searchParams.get('data') || '' + const decryptedData = callback.parse(encryptedData) + + expect(decryptedData).toEqual({ + actions: [], + sender: 'http://sender.com', + type: 'forUpc' + }) + }) + }) + + describe('SSR compatibility', () => { + it('should throw error when send() is called on server-side', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + + expect(() => { + callback.send('http://test.com/Tools', testActions, null, 'test', 'http://sender.com') + }).toThrow('send() can only be called on the client side') + + // Restore window + global.window = originalWindow + }) + + it('should allow parse() to work on server-side', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const testData = { + actions: testActions, + sender: 'http://test.com/Tools', + type: 'test' + } + const stringifiedData = JSON.stringify(testData) + const encryptedData = AES.encrypt(stringifiedData, mockConfig.encryptionKey).toString() + + const decryptedData = callback.parse(encryptedData) + expect(decryptedData).toEqual(testData) + + // Restore window + global.window = originalWindow + }) + + it('should allow generateUrl() to work on server-side', () => { + const originalWindow = global.window + // @ts-ignore - removing window to simulate server-side + delete global.window + + const callback = useCallback(mockConfig) + const testActions: ExternalSignOut[] = [{ type: 'signOut' }] + const targetUrl = 'http://test.com/c' + const sender = 'http://sender.com' + + const generatedUrl = callback.generateUrl(targetUrl, testActions, 'forUpc', sender) + const url = new URL(generatedUrl) + + expect(url.origin + url.pathname).toBe(targetUrl) + expect(url.searchParams.has('data')).toBe(true) + + // Restore window + global.window = originalWindow + }) }) }) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3febb19..1688227 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,63 @@ import type { UpcPayload, } from "./types.js"; +/** + * Encrypts a string using AES encryption + */ +const encryptData = (data: string, encryptionKey: string): string => { + return AES.encrypt(data, encryptionKey).toString(); +}; + +/** + * Decrypts an encrypted string using AES decryption + */ +const decryptData = (encryptedData: string, encryptionKey: string): string => { + const decryptedMessage = AES.decrypt(encryptedData, encryptionKey); + + let decryptedString: string; + try { + decryptedString = decryptedMessage.toString(Utf8); + } catch (e) { + // Catch errors during UTF-8 conversion (likely due to bad decryption) + throw new Error('Decryption failed. Invalid key or corrupt data.'); + } + + // Check if decryption resulted in an empty string (another failure case) + if (!decryptedString) { + throw new Error('Decryption failed. Invalid key or corrupt data.'); + } + + return decryptedString; +}; + +/** + * Stringifies a payload into the standard callback data format + */ +const stringifyPayload = ( + payload: SendPayloads, + sender: string, + sendType?: string +): string => { + return JSON.stringify({ + actions: [...payload], + sender, + type: sendType, + }); +}; + +/** + * Creates an encrypted data string from a payload + */ +const createEncryptedPayload = ( + payload: SendPayloads, + sender: string, + sendType: string | undefined, + encryptionKey: string +): string => { + const stringifiedData = stringifyPayload(payload, sender, sendType); + return encryptData(stringifiedData, encryptionKey); +}; + const _useCallback = (config: CallbackConfig) => { const send = ( url: string, @@ -51,16 +108,18 @@ const _useCallback = (config: CallbackConfig) => { sendType?: string, sender?: string ) => { - const stringifiedData = JSON.stringify({ - actions: [...payload], - sender: sender ?? window.location.href.replace("/Tools/Update", "/Tools"), - type: sendType, - }); - - const encryptedMessage = AES.encrypt( - stringifiedData, + // send() requires browser APIs and is client-only + if (typeof window === "undefined") { + throw new Error("send() can only be called on the client side"); + } + + const defaultSender = sender ?? window.location.href.replace("/Tools/Update", "/Tools"); + const encryptedMessage = createEncryptedPayload( + payload, + defaultSender, + sendType, config.encryptionKey - ).toString(); + ); const destinationUrl = new URL(url.replace("/Tools/Update", "/Tools")); destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); @@ -80,20 +139,8 @@ const _useCallback = (config: CallbackConfig) => { const dataToParse: string = options?.isDataURIEncoded ? decodeURI(data) : data; - const decryptedMessage = AES.decrypt(dataToParse, config.encryptionKey); - - let decryptedString: string; - try { - decryptedString = decryptedMessage.toString(Utf8); - } catch (e) { - // Catch errors during UTF-8 conversion (likely due to bad decryption) - throw new Error('Decryption failed. Invalid key or corrupt data.'); - } - - // Check if decryption resulted in an empty string (another failure case) - if (!decryptedString) { - throw new Error('Decryption failed. Invalid key or corrupt data.'); - } + + const decryptedString = decryptData(dataToParse, config.encryptionKey); try { const decryptedData: QueryPayloads = JSON.parse(decryptedString); @@ -108,14 +155,24 @@ const _useCallback = (config: CallbackConfig) => { let urlToParse: string = ""; if (options?.baseUrl && !options.skipCurrentUrl) { urlToParse = options.baseUrl; - } else if (window && window.location && !options.skipCurrentUrl) { + } else if (typeof window !== "undefined" && window.location && !options.skipCurrentUrl) { urlToParse = window.location.toString(); + } else if (!options?.dataToParse && !options?.baseUrl) { + // If no window and no explicit data/baseUrl provided, return undefined + return undefined; } - const currentUrl = new URL(urlToParse); - const uriDecodedEncryptedData = decodeURI( - options?.dataToParse ?? currentUrl?.searchParams.get("data") ?? "" - ); + // If we have dataToParse, use it directly; otherwise parse from URL + const uriDecodedEncryptedData = options?.dataToParse + ? decodeURI(options.dataToParse) + : (() => { + try { + const currentUrl = new URL(urlToParse); + return decodeURI(currentUrl.searchParams.get("data") ?? ""); + } catch { + return ""; + } + })(); if (!uriDecodedEncryptedData) { return undefined; @@ -124,10 +181,38 @@ const _useCallback = (config: CallbackConfig) => { return parse(uriDecodedEncryptedData); }; + const generateUrl = ( + url: string, + payload: SendPayloads, + sendType?: string, + sender?: string + ): string => { + // generateUrl() works on both server and client + // If no sender provided and we're on client, use window.location; otherwise use empty string + const defaultSender = sender ?? ( + typeof window !== "undefined" + ? window.location.href.replace("/Tools/Update", "/Tools") + : "" + ); + + const encryptedMessage = createEncryptedPayload( + payload, + defaultSender, + sendType, + config.encryptionKey + ); + + const destinationUrl = new URL(url); + destinationUrl.searchParams.set("data", encodeURI(encryptedMessage)); + + return destinationUrl.toString(); + }; + return { send, parse, watcher, + generateUrl, }; };