Skip to content

Commit fdc13dd

Browse files
committed
Merge remote-tracking branch 'o/webui-tweb' into webui-glass
# Conflicts: # src/WebUI/templates/assets/tweb/app.js
2 parents aaf8dac + 92de5a4 commit fdc13dd

7 files changed

Lines changed: 65 additions & 13 deletions

File tree

‎src/WebUI/WebUI.php‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,16 +266,27 @@ private static function writeJson(array $data): void
266266
}
267267

268268
/**
269-
* Get the path of the current URL, without the query string.
269+
* Get the path of the current URL, without the query string (or with the query of the page).
270270
*
271271
* Always same-origin: a request for `//evil.example/` (which a front controller may route to the script)
272272
* would otherwise give a protocol-relative URL, pointing redirects and the QR code requests to another
273273
* host.
274274
*/
275-
public static function getSelfUrl(): string
275+
public static function getSelfUrl(bool $withQuery = false): string
276276
{
277-
$path = explode('?', $_SERVER['REQUEST_URI'] ?? '', 2)[0];
278-
return '/'.ltrim(str_replace('\\', '/', $path), '/');
277+
[$path, $query] = explode('?', $_SERVER['REQUEST_URI'] ?? '', 2) + [1 => ''];
278+
$path = '/'.ltrim(str_replace('\\', '/', $path), '/');
279+
if (!$withQuery) {
280+
return $path;
281+
}
282+
// The query of the page, which may select the session or the bot (index.php?bot=x), without the
283+
// parameters of the web UI: as is (parse_str() would rename the parameters with dots or spaces)
284+
$query = implode('&', array_filter(
285+
explode('&', $query),
286+
static fn (string $pair): bool => $pair !== ''
287+
&& !\in_array(urldecode(explode('=', $pair, 2)[0]), ['getQrCode', 'waitQrCodeOrLogin', self::ASSET_PARAMETER], true),
288+
));
289+
return $query === '' ? $path : "$path?$query";
279290
}
280291

281292
/**

‎src/WebUI/templates/assets/tweb/app.js‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/Wrappers/Start.php‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ trait Start
5757
public function start(): array
5858
{
5959
if ($this->getAuthorization() === \danog\MadelineProto\API::LOGGED_IN) {
60+
// Requests of the web UI that come after the login (a late QR code poll, a form submitted twice, or
61+
// from another tab): they're answered here, the script must not run in them (it runs in the page)
62+
if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
63+
if (isset($_GET['waitQrCodeOrLogin']) || isset($_GET['getQrCode'])) {
64+
$this->webQrLogin();
65+
die;
66+
}
67+
if (WebUI::writeLoggedIn()) {
68+
die;
69+
}
70+
}
6071
return $this instanceof Client ? $this->getSelf() : $this->fullGetSelf();
6172
}
6273
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
@@ -280,7 +291,7 @@ private function getWebLoginPage(): ?Page
280291
],
281292
submit: Lang::$current_lang['webContinue'],
282293
alerts: $alerts,
283-
qrEndpoint: WebUI::getSelfUrl(),
294+
qrEndpoint: WebUI::getSelfUrl(true),
284295
);
285296
case API::WAITING_CODE:
286297
return new Page(

‎tests/danog/MadelineProto/WebUITest.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,18 @@ public function testSelfUrlIsAlwaysSameOrigin(): void
293293
$_SERVER['REQUEST_URI'] = $requestUri;
294294
self::assertSame($expected, WebUI::getSelfUrl());
295295
}
296+
// With the query of the page (which may select the session), without the parameters of the web UI
297+
foreach ([
298+
'/index.php?bot=a&getQrCode' => '/index.php?bot=a',
299+
'/index.php?waitQrCodeOrLogin=&x=1' => '/index.php?x=1',
300+
'/index.php?getQrCode' => '/index.php',
301+
'/index.php?madelineAsset=assets/tweb/app.js&bot=a' => '/index.php?bot=a',
302+
'//evil.example/x?bot=a' => '/evil.example/x?bot=a',
303+
'/index.php?bot.name=a%20b&getQrCode' => '/index.php?bot.name=a%20b',
304+
] as $requestUri => $expected) {
305+
$_SERVER['REQUEST_URI'] = $requestUri;
306+
self::assertSame($expected, WebUI::getSelfUrl(true));
307+
}
296308
} finally {
297309
if ($uri === null) {
298310
unset($_SERVER['REQUEST_URI']);

‎webui/src/lib/transport.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ describe('submitForm', () => {
3939
expect(await submitForm({app_title: 'x', app_shortname: 'xxxxx'})).toEqual({loggedIn: true});
4040
});
4141

42+
it('fails on HTTP errors, rather than taking an error page of PHP or of a proxy for a login', async() => {
43+
vi.stubGlobal('fetch', vi.fn(() => respond('<html>Fatal error</html>', 'text/html', 500)));
44+
await expect(submitForm({phone_code: '12345'})).rejects.toThrow('HTTP 500');
45+
vi.stubGlobal('fetch', vi.fn(() => respond('Bad gateway', 'text/plain', 502)));
46+
await expect(submitForm({phone_code: '12345'})).rejects.toThrow('HTTP 502');
47+
});
48+
4249
it('marks the submission as pending while it runs', async() => {
4350
let resolve: (response: Response) => void;
4451
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((r) => resolve = r)));
@@ -60,6 +67,13 @@ describe('pollQr', () => {
6067
expect(fetch.mock.calls[0][1].redirect).toBe('error');
6168
});
6269

70+
it('keeps the query of the page, which may select the session', async() => {
71+
const fetch = vi.fn(() => respond({logged_in: false, link: 'tg://login?token=a'}));
72+
vi.stubGlobal('fetch', fetch);
73+
await pollQr('/index.php?bot=a', 'waitQrCodeOrLogin');
74+
expect((fetch.mock.calls[0] as unknown[])[0]).toBe(window.location.origin + '/index.php?bot=a&waitQrCodeOrLogin');
75+
});
76+
6377
it('never asks another origin', async() => {
6478
const fetch = vi.fn(() => respond({}));
6579
vi.stubGlobal('fetch', fetch);

‎webui/src/lib/transport.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ function pageUrl() {
2424
* POST the fields to the script, as the form of the server-rendered page would.
2525
*
2626
* `{state}`: the next page (or the same one, with an error). `{loggedIn}`: the login is over, and the
27-
* user's script runs in the GET of the reload; any answer that isn't JSON means the same (the script
28-
* printed its own page).
27+
* user's script runs in the GET of the reload; any successful answer that isn't JSON means the same (the
28+
* script printed its own page). A failed one (an error of PHP, of a proxy) is an error, not a login.
2929
*/
3030
export async function submitForm(fields: Record<string, string>): Promise<SubmitResult> {
3131
submitted = true;
@@ -41,6 +41,10 @@ export async function submitForm(fields: Record<string, string>): Promise<Submit
4141
body: new URLSearchParams(fields)
4242
});
4343

44+
if(!response.ok) {
45+
throw new Error('HTTP ' + response.status);
46+
}
47+
4448
if(!(response.headers.get('Content-Type') || '').includes('application/json')) {
4549
return {loggedIn: true};
4650
}
@@ -76,8 +80,9 @@ export async function pollQr(endpoint: string, query: 'getQrCode' | 'waitQrCodeO
7680
throw new Error('The QR code endpoint is on another origin');
7781
}
7882

79-
// the validated URL itself: a path starting with `//`, fetched on its own, would be another host
80-
url.search = '?' + query;
83+
// the validated URL itself: a path starting with `//`, fetched on its own, would be another host. With the
84+
// query of the page, which may select the session or the bot (index.php?bot=x)
85+
url.search = url.search ? url.search + '&' + query : '?' + query;
8186
url.hash = '';
8287
const response = await fetch(url.href, {credentials: 'same-origin', cache: 'no-store', redirect: 'error', signal});
8388
if(!response.ok) {

‎webui/src/pages/pageContent.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@ export function isEmptyText(text: Text | null | undefined): boolean {
3434

3535
function textContent(text: Text): string {
3636
if(typeof(text) === 'string') return text;
37-
const div = document.createElement('div');
38-
div.innerHTML = text.html;
39-
return div.textContent;
37+
// parsed in an inert document: nothing in it loads or runs
38+
return new DOMParser().parseFromString(text.html, 'text/html').body.textContent ?? '';
4039
}
4140

4241
/**

0 commit comments

Comments
 (0)