feat(proj): init
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
/// Name of the JavaScript channel injected into the cabinet page.
|
||||
/// Must stay in sync with `web/src/auth/telegramRedirect.ts`.
|
||||
const telegramAuthChannel = 'PpmAuth';
|
||||
|
||||
/// Chrome-like UA without the `; wv` token Android WebView inserts.
|
||||
/// Telegram's widget rejects the default WebView user-agent on some devices.
|
||||
const telegramWebViewUserAgent =
|
||||
'Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
/// Runs inside the cabinet and hands the JWT back to Flutter.
|
||||
///
|
||||
/// Two paths, both without backend changes:
|
||||
/// 1. The web app posts to `PpmAuth` from `setSession` right after login.
|
||||
/// 2. This script hooks `localStorage.setItem` and polls, so an already-open
|
||||
/// session (or an older cabinet build) still works.
|
||||
const telegramTokenProbeJs = '''
|
||||
(function () {
|
||||
function ping() {
|
||||
try {
|
||||
var token = window.localStorage.getItem('ppm_session_jwt');
|
||||
if (token && window.$telegramAuthChannel) {
|
||||
$telegramAuthChannel.postMessage(JSON.stringify({ token: token }));
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!window.__ppmAuthHooked) {
|
||||
window.__ppmAuthHooked = true;
|
||||
try {
|
||||
var original = window.localStorage.setItem.bind(window.localStorage);
|
||||
window.localStorage.setItem = function (key, value) {
|
||||
original(key, value);
|
||||
if (key === 'ppm_session_jwt') ping();
|
||||
};
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
ping();
|
||||
})();
|
||||
''';
|
||||
|
||||
/// WebView login only exists on mobile; desktop and web fall back to the token
|
||||
/// form.
|
||||
bool get isTelegramWebLoginSupported {
|
||||
if (kIsWeb) return false;
|
||||
return defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
}
|
||||
|
||||
/// The cabinet and the API share an origin (nginx proxies `/api`), so the API
|
||||
/// address can be derived unless it was configured explicitly.
|
||||
String resolveApiBaseUrl({
|
||||
required String cabinetUrl,
|
||||
String configuredApiBaseUrl = '',
|
||||
}) {
|
||||
if (configuredApiBaseUrl.trim().isNotEmpty) {
|
||||
return configuredApiBaseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
return resolveCabinetOrigin(cabinetUrl);
|
||||
}
|
||||
|
||||
/// Origin of the cabinet URL — used both as the API base and as a sanity check.
|
||||
String resolveCabinetOrigin(String cabinetUrl) {
|
||||
final uri = resolveCabinetLoginUri(cabinetUrl);
|
||||
return uri.hasPort && !_isDefaultPort(uri)
|
||||
? '${uri.scheme}://${uri.host}:${uri.port}'
|
||||
: '${uri.scheme}://${uri.host}';
|
||||
}
|
||||
|
||||
/// Always open `/login` so the Telegram widget is on screen.
|
||||
Uri resolveCabinetLoginUri(String cabinetUrl) {
|
||||
var raw = cabinetUrl.trim();
|
||||
if (raw.isEmpty) return Uri.parse('https://localhost/login');
|
||||
|
||||
if (!raw.contains('://')) {
|
||||
raw = 'https://$raw';
|
||||
}
|
||||
|
||||
final uri = Uri.parse(raw);
|
||||
final path = uri.path;
|
||||
if (path.isEmpty || path == '/') {
|
||||
return uri.replace(path: '/login');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool _isDefaultPort(Uri uri) {
|
||||
return (uri.scheme == 'https' && uri.port == 443) ||
|
||||
(uri.scheme == 'http' && uri.port == 80);
|
||||
}
|
||||
|
||||
/// Extracts the JWT from the payload posted by [telegramTokenProbeJs].
|
||||
String? parseTelegramAuthMessage(String raw) {
|
||||
try {
|
||||
final token = asString(asMap(jsonDecode(raw))['token']);
|
||||
return token.isEmpty ? null : token;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool isExternalAuthScheme(Uri uri) {
|
||||
return uri.scheme == 'tg' || uri.scheme == 'telegram';
|
||||
}
|
||||
|
||||
bool isTelegramOAuthHost(String host) {
|
||||
return host == 'oauth.telegram.org' ||
|
||||
host == 'telegram.org' ||
|
||||
host.endsWith('.telegram.org');
|
||||
}
|
||||
|
||||
/// Opens the cabinet in an in-app browser and resolves with the JWT once the
|
||||
/// user has signed in through the Telegram Login Widget.
|
||||
Future<String?> showTelegramLogin({
|
||||
required BuildContext context,
|
||||
required String cabinetUrl,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<String>(
|
||||
CupertinoPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => TelegramLoginScreen(cabinetUrl: cabinetUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class TelegramLoginScreen extends StatefulWidget {
|
||||
const TelegramLoginScreen({super.key, required this.cabinetUrl});
|
||||
|
||||
final String cabinetUrl;
|
||||
|
||||
@override
|
||||
State<TelegramLoginScreen> createState() => _TelegramLoginScreenState();
|
||||
}
|
||||
|
||||
class _TelegramLoginScreenState extends State<TelegramLoginScreen> {
|
||||
late final WebViewController _controller;
|
||||
Timer? _poll;
|
||||
bool _loading = true;
|
||||
bool _completed = false;
|
||||
bool _canGoBack = false;
|
||||
String? _error;
|
||||
|
||||
Uri get _startUri => resolveCabinetLoginUri(widget.cabinetUrl);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(telegramWebViewUserAgent)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..addJavaScriptChannel(
|
||||
telegramAuthChannel,
|
||||
onMessageReceived: (message) => _onToken(message.message),
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
onPageStarted: (_) {
|
||||
if (mounted) setState(() => _loading = true);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
_refreshCanGoBack();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
_probe();
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted || _completed) return;
|
||||
// Subframe errors (the Telegram iframe) must not kill the page.
|
||||
if (error.isForMainFrame == false) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.description;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_configureAndroid();
|
||||
_controller.loadRequest(_startUri);
|
||||
|
||||
// The widget writes the token after an async callback, so polling is more
|
||||
// reliable than a single probe on page load.
|
||||
_poll = Timer.periodic(const Duration(milliseconds: 700), (_) => _probe());
|
||||
}
|
||||
|
||||
Future<void> _configureAndroid() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||
|
||||
final platform = _controller.platform;
|
||||
if (platform is! AndroidWebViewController) return;
|
||||
|
||||
final cookies = AndroidWebViewCookieManager(
|
||||
const PlatformWebViewCookieManagerCreationParams(),
|
||||
);
|
||||
await cookies.setAcceptThirdPartyCookies(platform, true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _probe() async {
|
||||
if (_completed) return;
|
||||
try {
|
||||
await _controller.runJavaScript(telegramTokenProbeJs);
|
||||
} on PlatformException {
|
||||
// The page may be mid-navigation; the next tick retries.
|
||||
}
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.navigate;
|
||||
|
||||
if (isExternalAuthScheme(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
Future<void> _refreshCanGoBack() async {
|
||||
final canGoBack = await _controller.canGoBack();
|
||||
if (mounted && canGoBack != _canGoBack) {
|
||||
setState(() => _canGoBack = canGoBack);
|
||||
}
|
||||
}
|
||||
|
||||
void _onToken(String raw) {
|
||||
if (_completed) return;
|
||||
|
||||
final token = parseTelegramAuthMessage(raw);
|
||||
if (token == null) return;
|
||||
|
||||
_completed = true;
|
||||
_poll?.cancel();
|
||||
Navigator.of(context).pop(token);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: 'Вход через Telegram',
|
||||
subtitle: _startUri.host,
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const AppText.body('Закрыть', color: AppColors.accent),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_canGoBack)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () async {
|
||||
await _controller.goBack();
|
||||
await _refreshCanGoBack();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.back, color: AppColors.accent),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _error != null
|
||||
? AppErrorView(
|
||||
message: _error!,
|
||||
onRetry: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_loading) const Center(child: AppSpinner()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user