Skip to content

Commit

Permalink
Merge pull request #634 from Telegram-Mini-Apps/bugfix/invalid-init-d…
Browse files Browse the repository at this point in the history
…ata-behavior

Bugfix/invalid init data behavior
  • Loading branch information
heyqbnk authored Jan 29, 2025
2 parents c52264d + c1d3b56 commit 88c5256
Show file tree
Hide file tree
Showing 22 changed files with 309 additions and 249 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-badgers-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@telegram-apps/init-data-node": patch
---

Bump `error-kid`
5 changes: 5 additions & 0 deletions .changeset/dry-starfishes-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@telegram-apps/bridge": minor
---

Add `retrieveRawInitData` utility. Set launchParams.tgWebAppData to string or URLSearchParams in `mockTelegramEnv`
5 changes: 5 additions & 0 deletions .changeset/strange-dryers-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@telegram-apps/transformers": minor
---

Add `isLaunchParamsQuery` utility.
5 changes: 5 additions & 0 deletions .changeset/warm-roses-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@telegram-apps/sdk": minor
---

Fix incorrect `initData.restore()` behavior. Add more exports from bridge.
48 changes: 29 additions & 19 deletions packages/bridge/src/env/mockTelegramEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,44 @@ it('should store launch parameters retuning them from retrieveLaunchParams', ()
subtitle_text_color: '#708499',
text_color: '#f5f5f5',
},
tgWebAppData: {
auth_date: new Date(1716922846000),
chat_instance: '8428209589180549439',
chat_type: 'sender',
hash: '89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31',
start_param: 'debug',
user: {
allows_write_to_pm: true,
first_name: 'Andrew',
id: 99281932,
is_premium: true,
language_code: 'en',
last_name: 'Rogue',
username: 'rogue',
},
signature: 'abc',
},
tgWebAppData: 'chat_type=sender&auth_date=1736409902&signature=FNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA&hash=4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90v',
tgWebAppVersion: '7.2',
tgWebAppPlatform: 'tdesktop',
tgWebAppBotInline: false,
tgWebAppShowSettings: false,
} as const;

createWindow();
createWindow({ location: { href: '' } } as any);

expect(retrieveLaunchParams).toThrow();
mockTelegramEnv({ launchParams });
expect(retrieveLaunchParams()).toStrictEqual(launchParams);
expect(retrieveLaunchParams()).toStrictEqual({
tgWebAppThemeParams: {
accent_text_color: '#6ab2f2',
bg_color: '#17212b',
button_color: '#5288c1',
button_text_color: '#ffffff',
destructive_text_color: '#ec3942',
header_bg_color: '#17212b',
hint_color: '#708499',
link_color: '#6ab3f3',
secondary_bg_color: '#232e3c',
section_bg_color: '#17212b',
section_header_text_color: '#6ab3f3',
subtitle_text_color: '#708499',
text_color: '#f5f5f5',
},
tgWebAppData: {
chat_type: 'sender',
auth_date: new Date(1736409902000),
signature: 'FNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA',
hash: '4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90v',
},
tgWebAppVersion: '7.2',
tgWebAppPlatform: 'tdesktop',
tgWebAppBotInline: false,
tgWebAppShowSettings: false,
});
});

describe('env is iframe', () => {
Expand Down
43 changes: 32 additions & 11 deletions packages/bridge/src/env/mockTelegramEnv.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { is, parse, pipe, string } from 'valibot';
import {
isLaunchParamsQuery,
jsonParse,
parseLaunchParamsQuery,
type LaunchParamsLike,
MiniAppsMessageSchema,
serializeLaunchParamsQuery,
} from '@telegram-apps/transformers';
import type { If, IsNever } from '@telegram-apps/toolkit';
import { If, IsNever, setStorageValue } from '@telegram-apps/toolkit';

import { logInfo } from '@/debug.js';
import { isIframe } from '@/env/isIframe.js';
import { saveToStorage } from '@/launch-params/storage.js';
import type { MethodName, MethodParams } from '@/methods/types/index.js';
import { InvalidLaunchParamsError } from '@/errors.js';

/**
* Mocks the environment and imitates Telegram Mini Apps behavior.
Expand All @@ -19,8 +20,14 @@ export function mockTelegramEnv({ launchParams, onEvent }: {
/**
* Launch parameters to mock. They will be saved in the session storage making
* the `retrieveLaunchParams` function return them.
*
* Note that this value must have tgWebAppData presented in a raw format as long as you will
* need it when retrieving init data in this format. Otherwise, init data may be broken.
*/
launchParams?: LaunchParamsLike | string | URLSearchParams;
launchParams?:
| (Omit<LaunchParamsLike, 'tgWebAppData'> & { tgWebAppData?: string | URLSearchParams })
| string
| URLSearchParams;
/**
* Function that will be called if a Mini Apps method call was requested by the mini app.
* @param event - event information.
Expand All @@ -34,13 +41,27 @@ export function mockTelegramEnv({ launchParams, onEvent }: {
next: () => void,
) => void;
} = {}): void {
// If launch parameters were passed, save them in the session storage, so
// the retrieveLaunchParams function would return them.
launchParams && saveToStorage(
typeof launchParams === 'string' || launchParams instanceof URLSearchParams
? parseLaunchParamsQuery(launchParams)
: launchParams,
);
if (launchParams) {
// If launch parameters were passed, save them in the session storage, so
// the retrieveLaunchParams function would return them.
const launchParamsQuery =
typeof launchParams === 'string' || launchParams instanceof URLSearchParams
? launchParams.toString()
: (
// Here we have to trick serializeLaunchParamsQuery into thinking, it serializes a valid
// value. We are doing it because we are working with tgWebAppData presented as a
// string, not an object as serializeLaunchParamsQuery requires.
serializeLaunchParamsQuery({ ...launchParams, tgWebAppData: undefined })
// Then, we just append init data.
+ (launchParams.tgWebAppData ? `&tgWebAppData=${encodeURIComponent(launchParams.tgWebAppData.toString())}` : '')
);

// Remember to check if launch params are valid.
if (!isLaunchParamsQuery(launchParamsQuery)) {
throw new InvalidLaunchParamsError(launchParamsQuery);
}
setStorageValue('launchParams', launchParamsQuery);
}

// Original postEvent firstly checks if the current environment is iframe.
// That's why we have a separate branch for this environment here too.
Expand Down
24 changes: 19 additions & 5 deletions packages/bridge/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,33 @@ export const [
],
);

const retrieveLaunchParamsError = [
'Unable to retrieve launch parameters from any known source. Perhaps, you have opened your app outside Telegram?',
'📖 Refer to docs for more information:',
'https://docs.telegram-mini-apps.com/packages/telegram-apps-bridge/environment',
].join('\n');

export const [
LaunchParamsRetrieveError,
isLaunchParamsRetrieveError,
] = errorClassWithData<unknown[], [errors: unknown[]]>(
'LaunchParamsRetrieveError',
errors => errors,
[
'Unable to retrieve launch parameters from any known source. Perhaps, you have opened your app outside Telegram?',
'📖 Refer to docs for more information:',
'https://docs.telegram-mini-apps.com/packages/telegram-apps-bridge/environment',
].join('\n')
retrieveLaunchParamsError,
);

export const [
InvalidLaunchParamsError,
isInvalidLaunchParamsError,
] = errorClass<[string]>('InvalidLaunchParamsError', value => [
`Invalid value for launch params: ${value}`,
]);

export const [
InitDataRetrieveError,
isInitDataRetrieveError,
] = errorClass('InitDataRetrieveError', retrieveLaunchParamsError);

export const [UnknownEnvError, isUnknownEnvError] = errorClass('UnknownEnvError');

export const [
Expand Down
5 changes: 5 additions & 0 deletions packages/bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type RetrieveLPResultCamelCased,
type RetrieveLPResult,
} from '@/launch-params/retrieveLaunchParams.js';
export { retrieveRawInitData } from '@/launch-params/retrieveRawInitData.js';

export type * from '@/methods/types/index.js';
export { targetOrigin } from '@/methods/targetOrigin.js';
Expand Down Expand Up @@ -54,5 +55,9 @@ export {
isMethodMethodParameterUnsupportedError,
UnknownEnvError,
isUnknownEnvError,
InitDataRetrieveError,
isInitDataRetrieveError,
InvalidLaunchParamsError,
isInvalidLaunchParamsError,
} from '@/errors.js';
export { resetPackageState } from '@/resetPackageState.js';
38 changes: 38 additions & 0 deletions packages/bridge/src/launch-params/forEachLpSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getStorageValue } from '@telegram-apps/toolkit';

/**
* @param urlString - URL to extract launch parameters from.
* @returns Launch parameters from the specified URL.
* @throws Error if function was unable to extract launch parameters from the passed URL.
*/
function fromURL(urlString: string): string {
return urlString
// Replace everything before this first hashtag or question sign.
.replace(/^[^?#]*[?#]/, '')
// Replace all hashtags and question signs to make it look like some search params.
.replace(/[?#]/g, '&');
}

/**
* Runs the specified function for each value, where the value is one stored in any known
* launch parameters source.
* @param fn - function to run. Should return false when the execution must be stopped.
*/
export function forEachLpSource(fn: (value: string) => boolean): void {
for (const retrieve of [
// Try to retrieve launch parameters from the current location. This method can return
// nothing in case, location was changed, and then the page was reloaded.
() => fromURL(window.location.href),
// Then, try using the lower level API - window.performance.
() => {
const navigationEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
return navigationEntry ? fromURL(navigationEntry.name) : undefined;
},
() => getStorageValue<string>('launchParams') || '',
]) {
const v = retrieve();
if (v && !fn(v)) {
return;
}
}
}
55 changes: 12 additions & 43 deletions packages/bridge/src/launch-params/retrieveLaunchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,17 @@ import { LaunchParamsSchema, parseLaunchParamsQuery } from '@telegram-apps/trans
import {
type DeepConvertSnakeKeysToCamelCase,
deepSnakeToCamelObjKeys,
setStorageValue,
} from '@telegram-apps/toolkit';
import type { InferOutput } from 'valibot';

import { LaunchParamsRetrieveError } from '@/errors.js';
import { retrieveFromStorage, saveToStorage } from '@/launch-params/storage.js';
import { forEachLpSource } from '@/launch-params/forEachLpSource.js';

export type RetrieveLPResult = InferOutput<typeof LaunchParamsSchema>;
export type RetrieveLPResultCamelCased =
DeepConvertSnakeKeysToCamelCase<InferOutput<typeof LaunchParamsSchema>>;

/**
* @param urlString - URL to extract launch parameters from.
* @returns Launch parameters from the specified URL.
* @throws Error if function was unable to extract launch parameters from the passed URL.
*/
function fromURL(urlString: string): RetrieveLPResult {
return parseLaunchParamsQuery(
urlString
// Replace everything before this first hashtag or question sign.
.replace(/^[^?#]*[?#]/, '')
// Replace all hashtags and question signs to make it look like some search params.
.replace(/[?#]/g, '&'),
);
}

/**
* @returns Launch parameters based on the first navigation entry.
* @throws Error if function was unable to extract launch parameters from the navigation entry.
*/
function retrieveFromPerformance(): RetrieveLPResult {
const navigationEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
if (!navigationEntry) {
throw new Error('Unable to get first navigation entry.');
}

return fromURL(navigationEntry.name);
}

/**
* @returns Launch parameters from any known source.
* @param camelCase - should the output be camel-cased.
Expand All @@ -65,24 +38,20 @@ export function retrieveLaunchParams(camelCase?: boolean):
| RetrieveLPResult
| RetrieveLPResultCamelCased {
const errors: unknown[] = [];
let launchParams: RetrieveLPResult | undefined;

for (const retrieve of [
// Try to retrieve launch parameters from the current location. This method can return
// nothing in case, location was changed, and then the page was reloaded.
() => fromURL(window.location.href),
// Then, try using the lower level API - window.performance.
retrieveFromPerformance,
// Finally, try to extract launch parameters from the session storage.
retrieveFromStorage,
]) {
forEachLpSource(v => {
try {
const lp = retrieve();
saveToStorage(lp);
return camelCase ? deepSnakeToCamelObjKeys(lp) : lp;
launchParams = parseLaunchParamsQuery(v);
setStorageValue('launchParams', v);
return false;
} catch (e) {
errors.push(e);
return true;
}
});
if (!launchParams) {
throw new LaunchParamsRetrieveError(errors);
}

throw new LaunchParamsRetrieveError(errors);
return camelCase ? deepSnakeToCamelObjKeys(launchParams) : launchParams;
}
45 changes: 45 additions & 0 deletions packages/bridge/src/launch-params/retrieveRawInitData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { retrieveRawInitData } from '@/launch-params/retrieveRawInitData.js';

afterEach(() => {
vi.restoreAllMocks();
});

describe('window.location.href contains init data', () => {
it('should retrieve init data from the window.location.href. Throw an error if data is invalid or missing', () => {
vi
.spyOn(window.location, 'href', 'get')
.mockImplementationOnce(() => {
return '/abc?tgWebAppStartParam=location_hash#tgWebAppPlatform=tdesktop&tgWebAppVersion=7.0&tgWebAppThemeParams=%7B%7D&tgWebAppData=user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%252C%2522photo_url%2522%253A%2522https%253A%255C%252F%255C%252Ft.me%255C%252Fi%255C%252Fuserpic%255C%252F320%255C%252F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%2522%257D%26chat_instance%3D-9019086117643313246%26chat_type%3Dsender%26auth_date%3D1736409902%26signature%3DFNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA%26hash%3D4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90';
});
expect(retrieveRawInitData()).toBe('user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=-9019086117643313246&chat_type=sender&auth_date=1736409902&signature=FNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA&hash=4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90');
});
});

describe('first navigation entry contains init data', () => {
it('should retrieve init data from the window.performance. Throw an error if data is invalid or missing', () => {
vi
.spyOn(performance, 'getEntriesByType')
.mockImplementationOnce(() => [{
name: '/abc?tgWebAppStartParam=performance#tgWebAppPlatform=macos&tgWebAppVersion=7.3&tgWebAppThemeParams=%7B%7D&tgWebAppData=user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%252C%2522photo_url%2522%253A%2522https%253A%255C%252F%255C%252Ft.me%255C%252Fi%255C%252Fuserpic%255C%252F320%255C%252F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%2522%257D%26chat_instance%3D-9019086117643313246%26chat_type%3Dsender%26auth_date%3D1736409902%26signature%3DFNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA%26hash%3D4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d33',
}] as any);

expect(retrieveRawInitData()).toBe('user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=-9019086117643313246&chat_type=sender&auth_date=1736409902&signature=FNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA&hash=4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d33');
});
});

describe('session storage contains init data', () => {
it('should return launch parameters from the session storage tapps/launchParams key. If data is missing or invalid, throw an error', () => {
const spy = vi
.spyOn(sessionStorage, 'getItem')
.mockImplementationOnce(() => '');
expect(() => retrieveRawInitData()).toThrow();

spy.mockClear();
spy.mockImplementationOnce(() => {
return '"tgWebAppPlatform=android&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23ffffff%22%7D&tgWebAppVersion=7.5&tgWebAppData=user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%252C%2522photo_url%2522%253A%2522https%253A%255C%252F%255C%252Ft.me%255C%252Fi%255C%252Fuserpic%255C%252F320%255C%252F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%2522%257D%26chat_instance%3D-9019086117643313246%26chat_type%3Dsender%26auth_date%3D1736409902%26signature%3DFNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA%26hash%3D4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90"';
});
expect(retrieveRawInitData()).toBe('user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=-9019086117643313246&chat_type=sender&auth_date=1736409902&signature=FNWSy6kv5n4kkmYYmfTbrgRtswTvwXgHTRWBVjp-YOv2srtMFSYCWZ9nGr_PohWZeWcooFo_oQgsnTJge3JdBA&hash=4c710b1d446dd4fd301c0efbf7c31627eca193a2e657754c9e0612cb1eb71d90');
});
});
Loading

0 comments on commit 88c5256

Please sign in to comment.