Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix/invalid init data behavior #634

Merged
merged 11 commits into from
Jan 29, 2025
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