diff --git a/lib/component/auth_dialog/auth_app_connect_dialog.dart b/lib/component/auth_dialog/auth_app_connect_dialog.dart index a390ce3..2a7dfb8 100644 --- a/lib/component/auth_dialog/auth_app_connect_dialog.dart +++ b/lib/component/auth_dialog/auth_app_connect_dialog.dart @@ -1,15 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/component/auth_dialog/auth_dialog_base_componnet.dart'; import 'package:nowser/const/connect_type.dart'; +import 'package:nowser/const/reasonable_permissions.dart'; +import 'package:nowser/data/app_db.dart'; +import 'package:nowser/main.dart'; +import 'package:nowser/util/router_util.dart'; import '../../const/base.dart'; +import '../../data/app.dart'; class AuthAppConnectDialog extends StatefulWidget { - static void show(BuildContext context) { - showDialog( + App app; + + AuthAppConnectDialog({required this.app}); + + static Future show(BuildContext context, App app) { + return showDialog( context: context, builder: (context) { - return AuthAppConnectDialog(); + return AuthAppConnectDialog( + app: app, + ); }, ); } @@ -65,7 +77,12 @@ class _AuthAppConnectDialog extends State { children: list, ); - return AuthDialogBaseComponnet(title: "App Connect", child: child); + return AuthDialogBaseComponnet( + app: widget.app, + title: "App Connect", + onConfirm: onConfirm, + child: child, + ); } void onConnectTypeChange(int? value) { @@ -75,4 +92,22 @@ class _AuthAppConnectDialog extends State { }); } } + + onConfirm() async { + var app = widget.app; + app.connectType = connectType; + if (StringUtil.isBlank(app.pubkey) && keyProvider.pubkeys.isNotEmpty) { + app.pubkey = keyProvider.pubkeys.first; + } + if (connectType == ConnectType.REASONABLE) { + app.alwaysAllow = ReasonablePermissions.text; + } + app.createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + app.updatedAt = app.createdAt; + + if (await AppDB.insert(app) > 0) { + await appProvider.reload(); + RouterUtil.back(context); + } + } } diff --git a/lib/component/auth_dialog/auth_app_info_component.dart b/lib/component/auth_dialog/auth_app_info_component.dart index 8a47af6..fde8da8 100644 --- a/lib/component/auth_dialog/auth_app_info_component.dart +++ b/lib/component/auth_dialog/auth_app_info_component.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/const/app_type.dart'; import 'package:nowser/const/base.dart'; +import 'package:nowser/data/app.dart'; import '../app/app_type_component.dart'; class AuthAppInfoComponent extends StatefulWidget { + App app; + + AuthAppInfoComponent({required this.app}); + @override State createState() { return _AuthAppInfoComponent(); @@ -16,6 +22,26 @@ class _AuthAppInfoComponent extends State { Widget build(BuildContext context) { var themeData = Theme.of(context); + String? name; + String? des; + if (StringUtil.isNotBlank(widget.app.name)) { + name = widget.app.name; + des = widget.app.code; + } else if (StringUtil.isBlank(widget.app.name)) { + name = widget.app.code; + } + + List rightList = []; + if (StringUtil.isNotBlank(name)) { + rightList.add(Text( + name!, + style: TextStyle(fontWeight: FontWeight.bold), + )); + } + if (StringUtil.isNotBlank(des)) { + rightList.add(Text(des!)); + } + return Stack( alignment: Alignment.center, children: [ @@ -24,12 +50,13 @@ class _AuthAppInfoComponent extends State { height: 64, child: Card( child: Container( - padding: EdgeInsets.all(Base.BASE_PADDING_HALF), + padding: const EdgeInsets.all(Base.BASE_PADDING_HALF), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( - margin: EdgeInsets.only(right: Base.BASE_PADDING_HALF), + margin: + const EdgeInsets.only(right: Base.BASE_PADDING_HALF), child: Icon( Icons.image, size: 46, @@ -38,13 +65,7 @@ class _AuthAppInfoComponent extends State { Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "APP NAME", - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text("This is App info des"), - ], + children: rightList, ) ], ), diff --git a/lib/component/auth_dialog/auth_dialog.dart b/lib/component/auth_dialog/auth_dialog.dart index 6cf3ab7..22a57a7 100644 --- a/lib/component/auth_dialog/auth_dialog.dart +++ b/lib/component/auth_dialog/auth_dialog.dart @@ -1,14 +1,39 @@ import 'package:flutter/material.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/component/auth_dialog/auth_dialog_base_componnet.dart'; +import 'package:nowser/const/auth_result.dart'; +import 'package:nowser/data/app.dart'; +import 'package:nowser/util/router_util.dart'; import '../../const/base.dart'; class AuthDialog extends StatefulWidget { - static void show(BuildContext context) { - showDialog( + App app; + + int authType; + + int? eventKind; + + String? authDetail; + + AuthDialog({ + required this.app, + required this.authType, + this.eventKind, + this.authDetail, + }); + + static Future show(BuildContext context, App app, int authType, + {int? eventKind, String? authDetail}) { + return showDialog( context: context, builder: (context) { - return AuthDialog(); + return AuthDialog( + app: app, + authType: authType, + eventKind: eventKind, + authDetail: authDetail, + ); }, ); } @@ -31,49 +56,54 @@ class _AuthDialog extends State { ); var hintColor = themeData.hintColor; + // handle this title and des with widget.authType + String authTitle = "Sign Event"; + String authDes = "Allow web.nostrmo.com to sign a authenticate event"; + authTitle = "AuthType ${widget.authType}"; + List list = []; list.add(Container( margin: baseMargin, child: Text( - "Allow web.nostrmo.com to sign a authenticate event", + authDes, ), )); - var showDetailWidget = GestureDetector( - onTap: () { - setState(() { - showDetail = !showDetail; - }); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("detail"), - showDetail ? Icon(Icons.expand_less) : Icon(Icons.expand_more), - ], - ), - ); - List detailList = []; - if (showDetail) { + if (StringUtil.isNotBlank(widget.authDetail)) { + var showDetailWidget = GestureDetector( + onTap: () { + setState(() { + showDetail = !showDetail; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("detail"), + showDetail ? Icon(Icons.expand_less) : Icon(Icons.expand_more), + ], + ), + ); + if (showDetail) { + detailList.add(Container( + height: 210, + width: double.infinity, + padding: EdgeInsets.all(Base.BASE_PADDING_HALF), + decoration: BoxDecoration( + color: hintColor.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: SingleChildScrollView( + child: Text(widget.authDetail!), + ), + )); + } else {} detailList.add(Container( - height: 210, - width: double.infinity, - padding: EdgeInsets.all(Base.BASE_PADDING_HALF), - decoration: BoxDecoration( - color: hintColor.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: SingleChildScrollView( - child: Text( - "GoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGoodGood"), - ), + margin: baseMargin, + child: showDetailWidget, )); - } else {} - detailList.add(Container( - margin: baseMargin, - child: showDetailWidget, - )); + } list.add(Container( height: 250, @@ -90,6 +120,16 @@ class _AuthDialog extends State { children: list, ); - return AuthDialogBaseComponnet(title: "Sign Event", child: child); + return AuthDialogBaseComponnet( + app: widget.app, + title: authTitle, + onConfirm: onConfirm, + child: child, + ); + } + + onConfirm() { + print("auth dialog confirm!"); + RouterUtil.back(context, AuthResult.OK); } } diff --git a/lib/component/auth_dialog/auth_dialog_base_componnet.dart b/lib/component/auth_dialog/auth_dialog_base_componnet.dart index ccf2123..5260d08 100644 --- a/lib/component/auth_dialog/auth_dialog_base_componnet.dart +++ b/lib/component/auth_dialog/auth_dialog_base_componnet.dart @@ -1,16 +1,33 @@ import 'package:flutter/material.dart'; +import 'package:nostr_sdk/nip19/nip19.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/const/base.dart'; +import 'package:nowser/data/app.dart'; +import 'package:nowser/provider/key_provider.dart'; import 'package:nowser/util/router_util.dart'; +import 'package:provider/provider.dart'; import '../logo_component.dart'; import 'auth_app_info_component.dart'; class AuthDialogBaseComponnet extends StatefulWidget { + App app; + String title; Widget child; - AuthDialogBaseComponnet({required this.title, required this.child}); + Function onConfirm; + + Function(String)? onPubkeyChange; + + AuthDialogBaseComponnet({ + required this.app, + required this.title, + required this.child, + required this.onConfirm, + this.onPubkeyChange, + }); @override State createState() { @@ -30,11 +47,43 @@ class _AuthDialog extends State { List list = []; + var keyWidget = + Selector>(builder: (context, pubkeys, child) { + List> items = []; + for (var pubkey in pubkeys) { + items.add(DropdownMenuItem( + value: pubkey, + child: Text( + Nip19.encodePubKey(pubkey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + )); + } + + if (StringUtil.isBlank(widget.app.pubkey) && pubkeys.isNotEmpty) { + widget.app.pubkey = pubkeys.first; + } + + return DropdownButton( + items: items, + onChanged: (String? value) { + if (StringUtil.isNotBlank(value)) { + widget.app.pubkey = value; + setState(() {}); + } + }, + value: widget.app.pubkey, + ); + }, selector: (context, provider) { + return provider.pubkeys; + }); + var topWidget = Container( child: Row( children: [ Container( - margin: EdgeInsets.only( + margin: const EdgeInsets.only( left: Base.BASE_PADDING, right: Base.BASE_PADDING, top: Base.BASE_PADDING_HALF, @@ -46,16 +95,7 @@ class _AuthDialog extends State { child: Container( margin: EdgeInsets.only(right: Base.BASE_PADDING_HALF), alignment: Alignment.centerRight, - child: DropdownButton( - items: [ - DropdownMenuItem( - child: Text("npubxxxxxx"), - value: "npubxxxxxx", - ) - ], - onChanged: (Object? value) {}, - value: "npubxxxxxx", - ), + child: keyWidget, ), ), ], @@ -76,7 +116,9 @@ class _AuthDialog extends State { list.add(Container( margin: baseMargin, - child: AuthAppInfoComponent(), + child: AuthAppInfoComponent( + app: widget.app, + ), )); list.add(Container( @@ -112,7 +154,12 @@ class _AuthDialog extends State { margin: EdgeInsets.only( left: Base.BASE_PADDING_HALF, ), - child: FilledButton(onPressed: () {}, child: Text("Confirm")), + child: FilledButton( + onPressed: () { + widget.onConfirm(); + }, + child: Text("Confirm"), + ), ) ], ), diff --git a/lib/component/webview/web_home_component.dart b/lib/component/webview/web_home_component.dart index b747f82..90991a7 100644 --- a/lib/component/webview/web_home_component.dart +++ b/lib/component/webview/web_home_component.dart @@ -64,7 +64,7 @@ class _WebHomeComponent extends State { RouterUtil.router(context, RouterPath.WEB_TABS); }), wrapBottomBtn(const Icon(Icons.space_dashboard), onTap: () { - AuthDialog.show(context); + // AuthDialog.show(context); // AuthAppConnectDialog.show(context); }), wrapBottomBtn(const Icon(Icons.segment), onTap: () { diff --git a/lib/component/webview/webview_component.dart b/lib/component/webview/webview_component.dart index 5d30f9f..71c6b70 100644 --- a/lib/component/webview/webview_component.dart +++ b/lib/component/webview/webview_component.dart @@ -1,6 +1,19 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:nostr_sdk/event.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/component/webview/web_info.dart'; +import 'package:nowser/const/app_type.dart'; +import 'package:nowser/const/auth_type.dart'; +import 'package:nowser/main.dart'; +import 'package:nowser/util/permission_check_mixin.dart'; + +import '../../const/auth_result.dart'; +import '../../data/app.dart'; class WebViewComponent extends StatefulWidget { WebInfo webInfo; @@ -21,23 +34,479 @@ class WebViewComponent extends StatefulWidget { } } -class _WebViewComponent extends State { +class _WebViewComponent extends State + with PermissionCheckMixin { InAppWebViewController? webViewController; + late ContextMenu contextMenu; + InAppWebViewSettings settings = InAppWebViewSettings( + isInspectable: kDebugMode, + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + iframeAllow: "camera; microphone", + iframeAllowFullscreen: true); + + PullToRefreshController? pullToRefreshController; + + double progress = 0; + + @override + void initState() { + super.initState(); + + contextMenu = ContextMenu( + menuItems: [ + ContextMenuItem( + id: 1, + title: "Special", + action: () async { + print("Menu item Special clicked!"); + print(await webViewController?.getSelectedText()); + await webViewController?.clearFocus(); + }) + ], + settings: ContextMenuSettings(hideDefaultSystemContextMenuItems: false), + onCreateContextMenu: (hitTestResult) async { + print("onCreateContextMenu"); + print(hitTestResult.extra); + print(await webViewController?.getSelectedText()); + }, + onHideContextMenu: () { + print("onHideContextMenu"); + }, + onContextMenuActionItemClicked: (contextMenuItemClicked) async { + var id = contextMenuItemClicked.id; + print("onContextMenuActionItemClicked: " + + id.toString() + + " " + + contextMenuItemClicked.title); + }); + + pullToRefreshController = kIsWeb || + ![TargetPlatform.iOS, TargetPlatform.android] + .contains(defaultTargetPlatform) + ? null + : PullToRefreshController( + settings: PullToRefreshSettings( + color: Colors.blue, + ), + onRefresh: () async { + if (defaultTargetPlatform == TargetPlatform.android) { + webViewController?.reload(); + } else if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + webViewController?.loadUrl( + urlRequest: + URLRequest(url: await webViewController?.getUrl())); + } + }, + ); + } + @override Widget build(BuildContext context) { return Container( - child: InAppWebView( - initialUrlRequest: URLRequest(url: WebUri(widget.webInfo.url)), - onWebViewCreated: (controller) async { - webViewController = controller; - // initJSHandle(controller); - widget.onWebViewCreated(widget.webInfo, controller); - }, - onTitleChanged: (controller, title) { - widget.onTitleChanged(widget.webInfo, controller, title); - }, + child: Stack( + children: [ + InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.webInfo.url)), + initialUserScripts: UnmodifiableListView([]), + initialSettings: settings, + contextMenu: contextMenu, + pullToRefreshController: pullToRefreshController, + onWebViewCreated: (controller) async { + webViewController = controller; + initJSHandle(controller); + widget.onWebViewCreated(widget.webInfo, controller); + }, + onTitleChanged: (controller, title) { + widget.onTitleChanged(widget.webInfo, controller, title); + }, + onLoadStart: (controller, url) async {}, + onPermissionRequest: (controller, request) async { + return PermissionResponse( + resources: request.resources, + action: PermissionResponseAction.GRANT); + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + // var uri = navigationAction.request.url!; + // if (uri.scheme == "lightning" && + // StringUtil.isNotBlank(uri.path)) { + // var result = + // await NIP07Dialog.show(context, NIP07Methods.lightning); + // if (result == true) { + // await LightningUtil.goToPay(context, uri.path); + // } + // return NavigationActionPolicy.CANCEL; + // } + + // if (uri.scheme == "nostr+walletconnect") { + // webViewProvider.closeAndReturn(uri.toString()); + // return NavigationActionPolicy.CANCEL; + // } + + // if (![ + // "http", + // "https", + // "file", + // "chrome", + // "data", + // "javascript", + // "about" + // ].contains(uri.scheme)) { + // if (await canLaunchUrl(uri)) { + // // Launch the App + // await launchUrl( + // uri, + // ); + // // and cancel the request + // return NavigationActionPolicy.CANCEL; + // } + // } + + return NavigationActionPolicy.ALLOW; + }, + onLoadStop: (controller, url) async { + pullToRefreshController?.endRefreshing(); + addInitScript(controller); + }, + onReceivedError: (controller, request, error) { + pullToRefreshController?.endRefreshing(); + }, + onProgressChanged: (controller, progress) { + if (progress == 100) { + pullToRefreshController?.endRefreshing(); + } + setState(() { + this.progress = progress / 100; + }); + }, + onUpdateVisitedHistory: (controller, url, isReload) {}, + onConsoleMessage: (controller, consoleMessage) { + print(consoleMessage); + }, + ), + progress < 1.0 + ? LinearProgressIndicator(value: progress) + : Container(), + ], ), ); } + + Future nip07Reject(String resultId, String contnet) async { + var script = "window.nostr.reject(\"$resultId\", \"${contnet}\");"; + await webViewController!.evaluateJavascript(source: script); + } + + void initJSHandle(InAppWebViewController controller) { + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_getPublicKey", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_getPublicKey $jsMsg"); + var jsonObj = jsonDecode(jsMsg); + var resultId = jsonObj["resultId"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.GET_PUBLIC_KEY, + () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) { + var pubkey = app.pubkey; + var script = "window.nostr.callback(\"$resultId\", \"$pubkey\");"; + controller.evaluateJavascript(source: script); + }); + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_signEvent", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_signEvent $jsMsg"); + var jsonObj = jsonDecode(jsMsg); + var resultId = jsonObj["resultId"]; + var content = jsonObj["msg"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + try { + var eventObj = jsonDecode(content); + var eventKind = eventObj["kind"]; + if (eventKind is int) { + checkPermission(context, AppType.WEB, code, AuthType.SIGN_EVENT, + eventKind: eventKind, () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) async { + var tags = eventObj["tags"]; + Event? event = Event(app.pubkey!, eventObj["kind"], tags ?? [], + eventObj["content"]); + event = await signer.signEvent(event); + if (event == null) { + return; + } + + var eventResultStr = jsonEncode(event.toJson()); + // TODO this method to handle " may be error + eventResultStr = eventResultStr.replaceAll("\"", "\\\""); + var script = + "window.nostr.callback(\"$resultId\", JSON.parse(\"$eventResultStr\"));"; + webViewController!.evaluateJavascript(source: script); + }); + } + } catch (e) { + nip07Reject(resultId, "Sign fail"); + } + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_getRelays", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_getRelays $jsMsg"); + var jsonObj = jsonDecode(jsMsg); + var resultId = jsonObj["resultId"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.GET_RELAYS, () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) { + // TODO handle getRelays + // var app = appProvider.getApp(AppType.WEB, code); + // if (app != null) { + // var relayMaps = {}; + // var relayAddrs = relayProvider.relayAddrs; + // for (var relayAddr in relayAddrs) { + // relayMaps[relayAddr] = {"read": true, "write": true}; + // } + // var resultStr = jsonEncode(relayMaps); + // resultStr = resultStr.replaceAll("\"", "\\\""); + // var script = + // "window.nostr.callback(\"$resultId\", JSON.parse(\"$resultStr\"));"; + // webViewController!.evaluateJavascript(source: script); + // } + }); + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_nip04_encrypt", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_nip04_encrypt $jsMsg"); + var jsonObj = jsonDecode(jsMsg); + var resultId = jsonObj["resultId"]; + var msg = jsonObj["msg"]; + if (msg != null && msg is Map) { + var pubkey = msg["pubkey"]; + var plaintext = msg["plaintext"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.NIP04_ENCRYPT, + () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) async { + var resultStr = await signer.encrypt(pubkey, plaintext); + if (StringUtil.isBlank(resultStr)) { + return; + } + var script = + "window.nostr.callback(\"$resultId\", \"$resultStr\");"; + webViewController!.evaluateJavascript(source: script); + }); + } + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_nip04_decrypt", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_nip04_decrypt $jsMsg"); + var jsonObj = jsonDecode(jsMsg.message); + var resultId = jsonObj["resultId"]; + var msg = jsonObj["msg"]; + if (msg != null && msg is Map) { + var pubkey = msg["pubkey"]; + var ciphertext = msg["ciphertext"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.NIP04_DECRYPT, + () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) async { + var app = appProvider.getApp(AppType.WEB, code); + if (app != null) { + var resultStr = await signer.decrypt(pubkey, ciphertext); + if (StringUtil.isBlank(resultStr)) { + return; + } + var script = + "window.nostr.callback(\"$resultId\", \"$resultStr\");"; + webViewController!.evaluateJavascript(source: script); + } + }); + } + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_nip44_encrypt", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_nip04_encrypt $jsMsg"); + var jsonObj = jsonDecode(jsMsg); + var resultId = jsonObj["resultId"]; + var msg = jsonObj["msg"]; + if (msg != null && msg is Map) { + var pubkey = msg["pubkey"]; + var plaintext = msg["plaintext"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.NIP44_ENCRYPT, + () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) async { + var resultStr = await signer.nip44Encrypt(pubkey, plaintext); + if (StringUtil.isBlank(resultStr)) { + return; + } + var script = + "window.nostr.callback(\"$resultId\", \"$resultStr\");"; + webViewController!.evaluateJavascript(source: script); + }); + } + }, + ); + controller.addJavaScriptHandler( + handlerName: "Nowser_JS_nip44_decrypt", + callback: (jsMsgs) async { + var jsMsg = jsMsgs[0]; + // print("Nowser_JS_nip04_decrypt $jsMsg"); + var jsonObj = jsonDecode(jsMsg.message); + var resultId = jsonObj["resultId"]; + var msg = jsonObj["msg"]; + if (msg != null && msg is Map) { + var pubkey = msg["pubkey"]; + var ciphertext = msg["ciphertext"]; + + String? code = await getCode(); + if (code == null) { + return; + } + + checkPermission(context, AppType.WEB, code, AuthType.NIP44_DECRYPT, + () { + nip07Reject(resultId, "Forbid"); + }, (app, signer) async { + var resultStr = await signer.nip44Decrypt(pubkey, ciphertext); + if (StringUtil.isBlank(resultStr)) { + return; + } + var script = + "window.nostr.callback(\"$resultId\", \"$resultStr\");"; + webViewController!.evaluateJavascript(source: script); + }); + } + }, + ); + } + + void addInitScript(InAppWebViewController controller) { + controller.evaluateJavascript(source: """ +window.nostr = { +_call(channel, message) { + return new Promise((resolve, reject) => { + var resultId = "callbackResult_" + Math.floor(Math.random() * 100000000); + var arg = {"resultId": resultId}; + if (message) { + arg["msg"] = message; + } + var argStr = JSON.stringify(arg); + window.flutter_inappwebview + .callHandler(channel, argStr); + window.nostr._requests[resultId] = {resolve, reject} + }); +}, +_requests: {}, +callback(resultId, message) { + window.nostr._requests[resultId].resolve(message); +}, +reject(resultId, message) { + window.nostr._requests[resultId].reject(message); +}, +async getPublicKey() { + return window.nostr._call("Nowser_JS_getPublicKey"); +}, +async signEvent(event) { + return window.nostr._call("Nowser_JS_signEvent", JSON.stringify(event)); +}, +async getRelays() { + return window.nostr._call("Nowser_JS_getRelays"); +}, +nip04: { + async encrypt(pubkey, plaintext) { + return window.nostr._call("Nowser_JS_nip04_encrypt", {"pubkey": pubkey, "plaintext": plaintext}); + }, + async decrypt(pubkey, ciphertext) { + return window.nostr._call("Nowser_JS_nip04_decrypt", {"pubkey": pubkey, "ciphertext": ciphertext}); + }, +}, +nip44: { + async encrypt(pubkey, plaintext) { + return window.nostr._call("Nowser_JS_nip44_encrypt", {"pubkey": pubkey, "plaintext": plaintext}); + }, + async decrypt(pubkey, ciphertext) { + return window.nostr._call("Nowser_JS_nip44_decrypt", {"pubkey": pubkey, "ciphertext": ciphertext}); + }, +}, +}; +"""); + } + + Future getCode() async { + if (webViewController != null) { + var url = await webViewController!.getUrl(); + if (url != null) { + return url.host; + } + } + + return null; + } + + @override + Future getApp(int appType, String code) async { + String? name; + String? image; + if (webViewController != null) { + name = await webViewController!.getTitle(); + + var favicons = await webViewController!.getFavicons(); + if (favicons.isNotEmpty) { + image = favicons.first.url.toString(); + } + } + return App(appType: appType, code: code, name: name, image: image); + } } diff --git a/lib/const/auth_result.dart b/lib/const/auth_result.dart index b2abeaf..c0824da 100644 --- a/lib/const/auth_result.dart +++ b/lib/const/auth_result.dart @@ -1,5 +1,7 @@ class AuthResult { static const int OK = 1; - static const int CANCEL = -1; + static const int REJECT = -1; + + static const int ASK = 0; } diff --git a/lib/const/reasonable_permissions.dart b/lib/const/reasonable_permissions.dart new file mode 100644 index 0000000..f1823be --- /dev/null +++ b/lib/const/reasonable_permissions.dart @@ -0,0 +1,3 @@ +class ReasonablePermissions { + static const String text = "1;3;4;5;6;7,2-22242"; +} diff --git a/lib/const/zap_type.dart b/lib/const/zap_type.dart new file mode 100644 index 0000000..2afa1fa --- /dev/null +++ b/lib/const/zap_type.dart @@ -0,0 +1,5 @@ +class ZapType { + static const int LIGHTNING_APP = 1; + + static const int NWC = 2; +} diff --git a/lib/data/app.dart b/lib/data/app.dart index 34a6753..975c40b 100644 --- a/lib/data/app.dart +++ b/lib/data/app.dart @@ -1,25 +1,66 @@ class App { - int id; + int? id; - String pubkey; + String? pubkey; - int appType; + int? appType; - String code; + String? code; - String name; + String? name; String? image; - String? permissions; + int? connectType; - App({ - required this.id, - required this.pubkey, - required this.appType, - required this.code, - required this.name, - this.image, - this.permissions, - }); + String? alwaysAllow; + + String? alwaysReject; + + int? createdAt; + + int? updatedAt; + + App( + {this.id, + this.pubkey, + this.appType, + this.code, + this.name, + this.image, + this.connectType, + this.alwaysAllow, + this.alwaysReject, + this.createdAt, + this.updatedAt}); + + App.fromJson(Map json) { + id = json['id']; + pubkey = json['pubkey']; + appType = json['app_type']; + code = json['code']; + name = json['name']; + image = json['image']; + connectType = json['connect_type']; + alwaysAllow = json['always_allow']; + alwaysReject = json['always_reject']; + createdAt = json['created_at']; + updatedAt = json['updated_at']; + } + + Map toJson() { + final Map data = new Map(); + data['id'] = this.id; + data['pubkey'] = this.pubkey; + data['app_type'] = this.appType; + data['code'] = this.code; + data['name'] = this.name; + data['image'] = this.image; + data['connect_type'] = this.connectType; + data['always_allow'] = this.alwaysAllow; + data['always_reject'] = this.alwaysReject; + data['created_at'] = this.createdAt; + data['updated_at'] = this.updatedAt; + return data; + } } diff --git a/lib/data/app_db.dart b/lib/data/app_db.dart index 760e66e..85ff4ca 100644 --- a/lib/data/app_db.dart +++ b/lib/data/app_db.dart @@ -1 +1,41 @@ -class AppDB {} +import 'package:nowser/data/db.dart'; + +import 'package:sqflite/sqflite.dart'; + +import 'app.dart'; + +class AppDB { + static Future> all() async { + List objs = []; + var db = await DB.getCurrentDatabase(); + List> list = await db.rawQuery("select * from app"); + for (var i = 0; i < list.length; i++) { + var json = list[i]; + objs.add(App.fromJson(json)); + } + return objs; + } + + static Future get(int id, {DatabaseExecutor? db}) async { + db = await DB.getDB(db); + var list = await db.query("app", where: "id = ?", whereArgs: [id]); + if (list.isNotEmpty) { + return App.fromJson(list[0]); + } + } + + static Future insert(App o, {DatabaseExecutor? db}) async { + db = await DB.getDB(db); + return await db.insert("app", o.toJson()); + } + + static Future update(App o, {DatabaseExecutor? db}) async { + db = await DB.getDB(db); + await db.update("app", o.toJson(), where: "id = ?", whereArgs: [o.pubkey]); + } + + static Future delete(int id, {DatabaseExecutor? db}) async { + db = await DB.getDB(db); + db.execute("delete from app where id = ?"); + } +} diff --git a/lib/data/auth_log.dart b/lib/data/auth_log.dart index d2ddeb3..1a73800 100644 --- a/lib/data/auth_log.dart +++ b/lib/data/auth_log.dart @@ -1,9 +1,9 @@ class AuthLog { - int id; + int? id; - int appId; + int? appId; - int authType; + int? authType; int? eventKind; @@ -11,18 +11,41 @@ class AuthLog { String? content; - int authResult; + int? authResult; - int createdAt; + int? createdAt; - AuthLog({ - required this.id, - required this.appId, - required this.authType, - this.eventKind, - this.title, - this.content, - required this.authResult, - required this.createdAt, - }); + AuthLog( + {this.id, + this.appId, + this.authType, + this.eventKind, + this.title, + this.content, + this.authResult, + this.createdAt}); + + AuthLog.fromJson(Map json) { + id = json['id']; + appId = json['app_id']; + authType = json['auth_type']; + eventKind = json['event_kind']; + title = json['title']; + content = json['content']; + authResult = json['auth_result']; + createdAt = json['created_at']; + } + + Map toJson() { + final Map data = new Map(); + data['id'] = this.id; + data['app_id'] = this.appId; + data['auth_type'] = this.authType; + data['event_kind'] = this.eventKind; + data['title'] = this.title; + data['content'] = this.content; + data['auth_result'] = this.authResult; + data['created_at'] = this.createdAt; + return data; + } } diff --git a/lib/data/db.dart b/lib/data/db.dart index 59366e6..64c0640 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -36,10 +36,13 @@ class DB { static Future _onCreate(Database db, int version) async { // init db db.execute( - "create table app(id integer not null constraint app_pk primary key autoincrement,pubkey text not null,app_type integer not null,code text not null,name text not null,image text,permissions text);"); + "create table app(id integer not null constraint app_pk primary key autoincrement,pubkey text not null,app_type integer not null,code text not null,name text,image text,connect_type integer not null,always_allow text,always_reject text,created_at integer not null,updated_at integer not null);"); db.execute( "create table auth_log(id integer not null constraint auth_log_pk primary key autoincrement,app_id integer not null,auth_type integer not null,event_kind integer,title text,content text,auth_result integer not null,created_at integer not null);"); db.execute("create index auth_log_index on auth_log (app_id);"); + db.execute( + "create table zap_log(id integer not null constraint zap_log_pk primary key autoincrement,app_id integer not null constraint zap_log_index unique,zap_type integer not null,num integer not null,created_at integer not null);"); + db.execute("create index zap_log_index on zap_log (app_id);"); } static Future getCurrentDatabase() async { diff --git a/lib/data/zap_log.dart b/lib/data/zap_log.dart new file mode 100644 index 0000000..f7aeeea --- /dev/null +++ b/lib/data/zap_log.dart @@ -0,0 +1,37 @@ +class ZapLog { + int? id; + + int? appId; + + int? zapType; + + int? num; + + int? createdAt; + + ZapLog({ + this.id, + this.appId, + this.zapType, + this.num, + this.createdAt, + }); + + ZapLog.fromJson(Map json) { + id = json['id']; + appId = json['app_id']; + zapType = json['zap_type']; + num = json['num']; + createdAt = json['created_at']; + } + + Map toJson() { + final Map data = new Map(); + data['id'] = this.id; + data['app_id'] = this.appId; + data['zap_type'] = this.zapType; + data['num'] = this.num; + data['created_at'] = this.createdAt; + return data; + } +} diff --git a/lib/data/zap_log_db.dart b/lib/data/zap_log_db.dart new file mode 100644 index 0000000..53f1bad --- /dev/null +++ b/lib/data/zap_log_db.dart @@ -0,0 +1 @@ +class ZapLogDB {} diff --git a/lib/main.dart b/lib/main.dart index 04fc3f8..c975a00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:nostr_sdk/utils/string_util.dart'; import 'package:nowser/data/db.dart'; +import 'package:nowser/provider/app_provider.dart'; import 'package:nowser/provider/key_provider.dart'; import 'package:nowser/provider/web_provider.dart'; import 'package:nowser/router/index/index_router.dart'; @@ -31,12 +32,15 @@ late SettingProvider settingProvider; late KeyProvider keyProvider; +late AppProvider appProvider; + late Map routes; Future main() async { WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); keyProvider = KeyProvider(); + appProvider = AppProvider(); var dataUtilTask = DataUtil.getInstance(); var keyTask = keyProvider.init(); @@ -44,7 +48,8 @@ Future main() async { var dataFutureResultList = await Future.wait([dataUtilTask, keyTask, dbTask]); var settingTask = SettingProvider.getInstance(); - var futureResultList = await Future.wait([settingTask]); + var appTask = appProvider.reload(); + var futureResultList = await Future.wait([settingTask, appTask]); settingProvider = futureResultList[0] as SettingProvider; webProvider = WebProvider(); diff --git a/lib/provider/app_provider.dart b/lib/provider/app_provider.dart new file mode 100644 index 0000000..b98632a --- /dev/null +++ b/lib/provider/app_provider.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:nostr_sdk/utils/string_util.dart'; +import 'package:nowser/const/connect_type.dart'; +import 'package:nowser/data/app_db.dart'; + +import '../const/auth_result.dart'; +import '../data/app.dart'; + +class AppProvider extends ChangeNotifier { + List _list = []; + + Map> appPermissions = {}; + + Future reload() async { + appPermissions = {}; + var allApp = await AppDB.all(); + _list = allApp; + notifyListeners(); + } + + Future add(App app) async { + if (await AppDB.insert(app) > 0) { + _list.add(app); + notifyListeners(); + } + } + + String getAppCode(int appType, String code) { + return "${appType}_$code"; + } + + int checkPermission(int appType, String code, int authType, + {int? eventKind}) { + var app = getApp(appType, code); + if (app != null) { + if (app.connectType == ConnectType.FULLY_TRUST) { + return AuthResult.OK; + } else if (app.connectType == ConnectType.ALWAY_REJECT) { + return AuthResult.REJECT; + } else { + var appCode = getAppCode(appType, code); + var permissionsMap = appPermissions[appCode]; + if (permissionsMap == null) { + permissionsMap = _getPermissionMap(app); + appPermissions[appCode] = permissionsMap; + } + + var key = "$authType"; + if (eventKind != null) { + key = "$key-$eventKind"; + } + + var value = permissionsMap[key]; + if (value != null) { + return value; + } + } + } + + return AuthResult.ASK; + } + + Map _getPermissionMap(App app) { + Map m = {}; + _putPermissionMapValue(m, app.alwaysAllow, 1); + _putPermissionMapValue(m, app.alwaysReject, -1); + return m; + } + + void _putPermissionMapValue( + Map m, String? permissionText, int value) { + if (StringUtil.isNotBlank(permissionText)) { + var permissionStrs = permissionText!.split(";"); + for (var permissionStr in permissionStrs) { + var strs = permissionStr.split("-"); + + var kindStr = strs[0]; + if (strs.length == 1) { + m[kindStr] = value; + } else if (strs.length > 1) { + var eventKindsStr = strs[1]; + var eventKindStrs = eventKindsStr.split(","); + for (var eventKindStr in eventKindStrs) { + var key = "$kindStr-$eventKindStr"; + m[key] = value; + } + } + } + } + } + + App? getApp(int appType, String code) { + for (var app in _list) { + if (app.appType == appType && app.code == code) { + return app; + } + } + + return null; + } +} diff --git a/lib/provider/key_provider.dart b/lib/provider/key_provider.dart index 54b6ab8..0e790fa 100644 --- a/lib/provider/key_provider.dart +++ b/lib/provider/key_provider.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:nostr_sdk/client_utils/keys.dart'; +import 'package:nostr_sdk/signer/local_nostr_signer.dart'; +import 'package:nostr_sdk/signer/nostr_signer.dart'; import 'package:nostr_sdk/utils/string_util.dart'; class KeyProvider extends ChangeNotifier { @@ -104,4 +106,13 @@ class KeyProvider extends ChangeNotifier { bool exist(String privateKey) { return keys.contains(privateKey); } + + NostrSigner? getSigner(String pubkey) { + var key = keysMap[pubkey]; + if (StringUtil.isNotBlank(key)) { + return LocalNostrSigner(key!); + } + + return null; + } } diff --git a/lib/util/permission_check_mixin.dart b/lib/util/permission_check_mixin.dart new file mode 100644 index 0000000..adef65a --- /dev/null +++ b/lib/util/permission_check_mixin.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:nostr_sdk/signer/nostr_signer.dart'; +import 'package:nowser/component/auth_dialog/auth_app_connect_dialog.dart'; +import 'package:nowser/component/auth_dialog/auth_dialog.dart'; +import 'package:nowser/const/auth_result.dart'; +import 'package:nowser/data/auth_log.dart'; +import 'package:nowser/main.dart'; + +import '../const/connect_type.dart'; +import '../data/app.dart'; + +mixin PermissionCheckMixin { + Future checkPermission(BuildContext context, int appType, String code, + int authType, Function reject, Function(App, NostrSigner) confirm, + {int? eventKind, String? authDetail}) async { + var app = appProvider.getApp(appType, code); + if (app == null) { + // app is null, app connect + var newApp = await getApp(appType, code); + await AuthAppConnectDialog.show(context, newApp); + // reload from provider + app = appProvider.getApp(appType, code); + } + + if (app == null) { + // not allow connect + reject(); + return; + } + + var signer = getSigner(app.pubkey!); + if (signer == null) { + reject(); + return; + } + + if (app.connectType == ConnectType.FULLY_TRUST) { + saveAuthLog(app, authType, eventKind, authDetail, AuthResult.OK); + confirm(app, signer); + return; + } else if (app.connectType == ConnectType.REASONABLE) { + var permissionCheckResult = appProvider + .checkPermission(appType, code, authType, eventKind: eventKind); + print("permissionCheckResult $permissionCheckResult"); + if (permissionCheckResult == AuthResult.OK) { + saveAuthLog(app, authType, eventKind, authDetail, AuthResult.OK); + confirm(app, signer); + return; + } else if (permissionCheckResult == AuthResult.REJECT) { + saveAuthLog(app, authType, eventKind, authDetail, AuthResult.REJECT); + reject(); + return; + } + + var authResult = await AuthDialog.show(context, app, authType, + eventKind: eventKind, authDetail: authDetail); + if (authResult == AuthResult.OK) { + saveAuthLog(app, authType, eventKind, authDetail, AuthResult.OK); + confirm(app, signer); + return; + } + } + + saveAuthLog(app, authType, eventKind, authDetail, AuthResult.REJECT); + reject(); + return; + } + + void saveAuthLog(App app, int authType, int? eventKind, String? authDetail, + int authResult) { + // TODO + } + + // this method should override + Future getApp(int appType, String code) async { + // TODO name, image + return App(appType: appType, code: code); + } + + NostrSigner? getSigner(String pubkey) { + return keyProvider.getSigner(pubkey); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 96cb03e..88c8fd7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,9 +33,9 @@ dependencies: flutter_localizations: sdk: flutter cupertino_icons: ^1.0.6 - nostr_sdk: path: packages/nostr_sdk + receive_intent: ^0.2.5 provider: ^6.1.2 flutter_inappwebview: ^6.0.0