diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 161b639..2302a1b 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/nathan/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/nathan/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/nathan/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/nathan/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_web-0.1.0/","dependencies":["google_sign_in_web"],"dev_dependency":false},{"name":"google_sign_in_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/nathan/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_all_platforms","dependencies":["google_sign_in_all_platforms_desktop","google_sign_in_all_platforms_mobile","google_sign_in_all_platforms_web"]},{"name":"google_sign_in_all_platforms_desktop","dependencies":["url_launcher"]},{"name":"google_sign_in_all_platforms_mobile","dependencies":["google_sign_in"]},{"name":"google_sign_in_all_platforms_web","dependencies":["google_sign_in","google_sign_in_web","google_sign_in_all_platforms_mobile"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqlite3_flutter_libs","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-05-19 23:28:49.730528","version":"3.44.0","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_mobile","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_mobile-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.11/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/chh/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.23/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"firebase_auth","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth-6.5.1/","native_build":true,"dependencies":["firebase_core"],"dev_dependency":false},{"name":"firebase_core","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core-4.9.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_desktop","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_desktop-0.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/chh/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"sqlite3_flutter_libs","path":"/home/chh/.pub-cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"connectivity_plus","path":"/home/chh/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/","dependencies":[],"dev_dependency":false},{"name":"firebase_auth_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_auth_web-6.2.1/","dependencies":["firebase_core_web"],"dev_dependency":false},{"name":"firebase_core_web","path":"/home/chh/.pub-cache/hosted/pub.dev/firebase_core_web-3.7.0/","dependencies":[],"dev_dependency":false},{"name":"google_sign_in_all_platforms_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_all_platforms_web-0.1.0/","dependencies":["google_sign_in_web"],"dev_dependency":false},{"name":"google_sign_in_web","path":"/home/chh/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.3/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/chh/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/chh/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.3/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"connectivity_plus","dependencies":[]},{"name":"firebase_auth","dependencies":["firebase_auth_web","firebase_core"]},{"name":"firebase_auth_web","dependencies":["firebase_core","firebase_core_web"]},{"name":"firebase_core","dependencies":["firebase_core_web"]},{"name":"firebase_core_web","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_all_platforms","dependencies":["google_sign_in_all_platforms_desktop","google_sign_in_all_platforms_mobile","google_sign_in_all_platforms_web"]},{"name":"google_sign_in_all_platforms_desktop","dependencies":["url_launcher"]},{"name":"google_sign_in_all_platforms_mobile","dependencies":["google_sign_in"]},{"name":"google_sign_in_all_platforms_web","dependencies":["google_sign_in","google_sign_in_web","google_sign_in_all_platforms_mobile"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"sqlite3_flutter_libs","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-06-04 13:56:09.675305","version":"3.44.1","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2158584..817e1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# VM hosting (machine-specific paths) +hosting/hosting.env + # Flutter/Dart .dart_tool/ .packages @@ -77,3 +80,5 @@ coverage/ migrate_working_dir/ *.env *.env.* +!server/.env.example +!hosting/hosting.env.example diff --git a/firebase.json b/firebase.json index 5cfd924..ad03481 100644 --- a/firebase.json +++ b/firebase.json @@ -5,8 +5,12 @@ "oAuthBrandDisplayName": "Cyber Hybrid Hub", "supportEmail": "support@cyberhybridhub.firebaseapp.com", "authorizedRedirectUris": [ - "http://localhost" - ] + "http://localhost", + "https://cyberhybridhub.firebaseapp.com", + "https://cyberhybridhub.com", + "https://www.cyberhybridhub.com" + ], + "redirectUri": "https://cyberhybridhub.firebaseapp.com" } } }, diff --git a/hosting/README.md b/hosting/README.md new file mode 100644 index 0000000..2b37b47 --- /dev/null +++ b/hosting/README.md @@ -0,0 +1,88 @@ +# VM hosting (Option A) + +Production layout on the app VM: + +| Service | systemd unit | Listen | Process | +|---------|----------------|--------|---------| +| Dart API | `cyberhybridhub-api` | `0.0.0.0:3000` | `dart run bin/server.dart` in `server/` | +| Flutter web | `cyberhybridhub-web` | `0.0.0.0:80` | `scripts/web_static_server.dart` → `build/web` | + +**OpenWrt / HAProxy** terminates HTTPS on `:443` and should proxy: + +- `/` → `10.10.101.106:80` (Flutter static) +- `/v1`, `/hubs` → `10.10.101.106:3000` (API + SignalR) + +Port **3000 is not published on the WAN**. See `haproxy-openwrt.example.cfg`. + +Local development uses `./startup.sh` (API 3000, web 8080, localhost-only). + +## API URL for Flutter web builds + +Set the same public origin the browser uses (no `:3000` on WAN): + +| File | Variable | +|------|----------| +| `hosting/hosting.env` | `API_PUBLIC_URL=https://cyberhybridhub.com` | +| `server/.env` | `API_BASE_URL=https://cyberhybridhub.com` (optional; used if hosting unset) | + +Build (reads hosting → server → default): + +```bash +./scripts/build-production-web.sh +``` + +Verify baked URL: + +```bash +grep -o 'https://cyberhybridhub.com[^"]*' build/web/main.dart.js | head -5 +``` + +Should show `https://cyberhybridhub.com` for API calls, not `:3000`. + +## First-time setup + +```bash +cd /home/chh/cyberhybridhub.com + +cp server/.env.example server/.env +nano server/.env + +cp hosting/hosting.env.example hosting/hosting.env +nano hosting/hosting.env + +source scripts/flutter-env.sh +./scripts/deploy-production.sh + +chmod +x hosting/install-systemd.sh scripts/*.sh +./hosting/install-systemd.sh +``` + +## OpenWrt HAProxy + +Copy `hosting/haproxy-openwrt.example.cfg` into `/etc/haproxy.cfg` (merge with mygit rules), then: + +```sh +haproxy -c -f /etc/haproxy.cfg && /etc/init.d/haproxy reload +curl -skI https://cyberhybridhub.com/v1/me/profile | head -3 +``` + +## Updates + +```bash +git pull +./scripts/deploy-production.sh +sudo systemctl restart cyberhybridhub-api cyberhybridhub-web +``` + +## Firebase authorized domains + +```bash +./scripts/check-firebase-auth-domains.sh +``` + +Must include `cyberhybridhub.com` (and `www.cyberhybridhub.com` if used). + +## Legacy Fresh app + +The old unit `cyberhybridhub.service` pointed at `/home/chh/publish/server` on port 80. +`install-systemd.sh` stops and disables it when installing the Flutter units. diff --git a/hosting/cyberhybridhub-api.service b/hosting/cyberhybridhub-api.service new file mode 100644 index 0000000..0117130 --- /dev/null +++ b/hosting/cyberhybridhub-api.service @@ -0,0 +1,17 @@ +[Unit] +Description=CyberHybridHub Dart API +After=network.target postgresql.service +Wants=postgresql.service + +[Service] +Type=simple +User=@APP_USER@ +WorkingDirectory=@APP_ROOT@/server +Environment=PATH=@FLUTTER_BIN_DIR@:/usr/bin:/bin +EnvironmentFile=-@APP_ROOT@/server/.env +ExecStart=@DART_BIN@ run bin/server.dart +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/hosting/cyberhybridhub-web.service b/hosting/cyberhybridhub-web.service new file mode 100644 index 0000000..2686e63 --- /dev/null +++ b/hosting/cyberhybridhub-web.service @@ -0,0 +1,20 @@ +[Unit] +Description=CyberHybridHub Flutter web (static) +After=network.target cyberhybridhub-api.service +Requires=cyberhybridhub-api.service + +[Service] +Type=simple +User=@APP_USER@ +WorkingDirectory=@APP_ROOT@ +Environment=PATH=@FLUTTER_BIN_DIR@:/usr/bin:/bin +Environment=STATIC_SERVER_PRODUCTION=1 +ExecStartPre=/usr/bin/test -f @APP_ROOT@/build/web/index.html +ExecStart=@DART_BIN@ run scripts/web_static_server.dart @APP_ROOT@/build/web @WEB_PORT@ 0.0.0.0 +Restart=on-failure +RestartSec=5 +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/hosting/haproxy-openwrt.example.cfg b/hosting/haproxy-openwrt.example.cfg new file mode 100644 index 0000000..f66106b --- /dev/null +++ b/hosting/haproxy-openwrt.example.cfg @@ -0,0 +1,57 @@ +# OpenWrt HAProxy example: TLS on :443, API paths to VM :3000, web to VM :80. +# Merge into /etc/haproxy.cfg on the router (adjust IPs as needed). +# +# Verify after reload: +# curl -skI https://cyberhybridhub.com/v1/me/profile | head -3 # expect 401 +# curl -skI https://cyberhybridhub.com/ | head -3 # expect 200 + +global + log 127.0.0.1 local0 + maxconn 4096 + daemon + +defaults + mode http + log global + option httplog + timeout connect 10s + timeout client 30s + timeout server 30s + +frontend https_frontend + bind *:443 ssl crt /etc/haproxy/certs/cyberhybridhub.pem alpn h2,http/1.1 + http-response set-header Strict-Transport-Security "max-age=15768000" + acl host_cyberhybridhub hdr(host) -i cyberhybridhub.com + acl host_www hdr(host) -i www.cyberhybridhub.com + acl host_mygit hdr(host) -i mygit.cyberhybridhub.com + acl path_api path_beg /v1 /hubs + http-request deny deny_status 403 if !host_cyberhybridhub !host_www !host_mygit + use_backend cyberhybridhub_api if host_cyberhybridhub path_api + use_backend cyberhybridhub_api if host_www path_api + use_backend cyberhybridhub_backend if host_cyberhybridhub + use_backend cyberhybridhub_backend if host_www + use_backend mygit_http if host_mygit + +backend cyberhybridhub_backend + mode http + option forwardfor + http-request set-header X-Forwarded-Proto https if { ssl_fc } + server cyberhybridhub_server 10.10.101.106:80 check + +backend cyberhybridhub_api + mode http + option forwardfor + http-request set-header X-Forwarded-Proto https if { ssl_fc } + timeout tunnel 1h + server api 10.10.101.106:3000 check + +backend mygit_http + mode http + option forwardfor + http-request set-header X-Forwarded-Proto https if { ssl_fc } + server mygit_server 10.10.101.5:80 check + +frontend http_redirect + bind *:80 + mode http + http-request redirect scheme https code 301 diff --git a/hosting/hosting.env.example b/hosting/hosting.env.example new file mode 100644 index 0000000..c20ba2e --- /dev/null +++ b/hosting/hosting.env.example @@ -0,0 +1,15 @@ +# Copy to hosting/hosting.env and edit for this VM, then run: +# ./hosting/install-systemd.sh + +APP_ROOT=/home/chh/cyberhybridhub.com +APP_USER=chh +FLUTTER_BIN_DIR=/home/chh/github-open/flutter/bin +# Use Flutter SDK dart (install: git clone flutter to ~/github-open/flutter) +DART_BIN=/home/chh/github-open/flutter/bin/dart + +# Public URL for Flutter web build (no trailing slash, no :3000 on WAN setups). +# Must be https when the site is served over TLS. +API_PUBLIC_URL=https://cyberhybridhub.com + +WEB_PORT=80 +API_PORT=3000 diff --git a/hosting/install-systemd.sh b/hosting/install-systemd.sh new file mode 100755 index 0000000..1468810 --- /dev/null +++ b/hosting/install-systemd.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Install cyberhybridhub-api + cyberhybridhub-web systemd units (requires sudo). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="$ROOT/hosting/hosting.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing $ENV_FILE" >&2 + echo " cp hosting/hosting.env.example hosting/hosting.env" >&2 + echo " # edit paths and API_PUBLIC_URL for this VM" >&2 + exit 1 +fi + +# shellcheck source=/dev/null +source "$ENV_FILE" + +: "${APP_ROOT:?APP_ROOT required in hosting/hosting.env}" +: "${APP_USER:?APP_USER required}" +: "${FLUTTER_BIN_DIR:?FLUTTER_BIN_DIR required}" +: "${DART_BIN:=/usr/bin/dart}" +: "${WEB_PORT:=80}" +: "${API_PORT:=3000}" + +if [ ! -x "$DART_BIN" ]; then + echo "DART_BIN not executable: $DART_BIN" >&2 + echo " Install Flutter (see hosting/README.md) or set DART_BIN in hosting/hosting.env" >&2 + exit 1 +fi + +if [ ! -f "$APP_ROOT/build/web/index.html" ]; then + echo "Missing $APP_ROOT/build/web/index.html" >&2 + echo " Run: ./scripts/deploy-production.sh" >&2 + exit 1 +fi + +if [ ! -f "$APP_ROOT/server/.env" ]; then + echo "Missing $APP_ROOT/server/.env" >&2 + exit 1 +fi + +substitute() { + local template=$1 + local dest=$2 + sed \ + -e "s|@APP_ROOT@|${APP_ROOT}|g" \ + -e "s|@APP_USER@|${APP_USER}|g" \ + -e "s|@FLUTTER_BIN_DIR@|${FLUTTER_BIN_DIR}|g" \ + -e "s|@DART_BIN@|${DART_BIN}|g" \ + -e "s|@WEB_PORT@|${WEB_PORT}|g" \ + -e "s|@API_PORT@|${API_PORT}|g" \ + "$template" >"$dest" +} + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +substitute "$ROOT/hosting/cyberhybridhub-api.service" "$TMPDIR/cyberhybridhub-api.service" +substitute "$ROOT/hosting/cyberhybridhub-web.service" "$TMPDIR/cyberhybridhub-web.service" + +echo "Installing systemd units..." +sudo cp "$TMPDIR/cyberhybridhub-api.service" /etc/systemd/system/ +sudo cp "$TMPDIR/cyberhybridhub-web.service" /etc/systemd/system/ + +echo "Disabling legacy Fresh unit (if present)..." +sudo systemctl disable cyberhybridhub 2>/dev/null || true +sudo systemctl stop cyberhybridhub 2>/dev/null || true + +sudo systemctl daemon-reload +sudo systemctl enable cyberhybridhub-api cyberhybridhub-web +sudo systemctl restart cyberhybridhub-api cyberhybridhub-web + +echo "" +sudo systemctl status cyberhybridhub-api cyberhybridhub-web --no-pager || true +echo "" +echo "Verify:" +echo " sudo ss -tlnp | grep -E ':${WEB_PORT}|:${API_PORT}'" +echo " curl -sI http://127.0.0.1:${WEB_PORT}/" +echo " curl -sI http://127.0.0.1:${API_PORT}/" diff --git a/lib/config/auth_config.dart b/lib/config/auth_config.dart index ea41360..8cef898 100644 --- a/lib/config/auth_config.dart +++ b/lib/config/auth_config.dart @@ -1,12 +1,13 @@ /// Web OAuth 2.0 client ID (Google Cloud → Credentials → Web application). -/// /// Used for: /// - **Android**: `serverClientId` when not in `google-services.json` /// - **Linux desktop**: `google_sign_in_all_platforms` clientId /// /// **Web (required for Firefox / strict privacy):** `clientId` for the /// Google Identity Services fallback when Firebase popup sign-in fails. -/// Popup is tried first; redirect is not used (sessionStorage breaks under ETP). +/// Web: Firebase popup is tried first; full-page redirect is used if popup fails. +/// (google_sign_in `authenticate()` is not available on web — use renderButton +/// only if you replace the custom sign-in button with GIS UI.) /// /// Find it: Firebase Console → Project settings → Your apps → Web app, /// or Authentication → Sign-in method → Google → Web SDK configuration. diff --git a/lib/screens/landing_screen.dart b/lib/screens/landing_screen.dart index d3593a5..51fe415 100644 --- a/lib/screens/landing_screen.dart +++ b/lib/screens/landing_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; +import '../utils/auth_log.dart'; import '../widgets/benefit_row.dart'; import '../widgets/google_sign_in_button.dart'; @@ -15,6 +16,7 @@ class LandingScreen extends StatefulWidget { class _LandingScreenState extends State { String? _errorMessage; + String? _errorDetail; @override Widget build(BuildContext context) { @@ -44,10 +46,12 @@ class _LandingScreenState extends State { child: wide ? _WideLayout( errorMessage: _errorMessage, + errorDetail: _errorDetail, onError: _handleError, ) : _NarrowLayout( errorMessage: _errorMessage, + errorDetail: _errorDetail, onError: _handleError, ), ), @@ -61,9 +65,23 @@ class _LandingScreenState extends State { ); } - void _handleError(Object error) { + void _handleError(Object error, [StackTrace? stackTrace]) { + authLog( + 'LandingScreen sign-in error', + error: error, + stackTrace: stackTrace, + context: kIsWeb + ? { + 'pageOrigin': Uri.base.origin, + 'pageHost': Uri.base.host, + } + : null, + ); setState(() { _errorMessage = _friendlyError(error); + _errorDetail = authVerboseLoggingEnabled + ? describeAuthError(error) + : null; }); } @@ -71,9 +89,12 @@ class _LandingScreenState extends State { if (error is FirebaseAuthException) { switch (error.code) { case 'unauthorized-domain': - final String origin = kIsWeb ? Uri.base.origin : 'this app'; - return 'Add $origin under Firebase Console → Authentication → ' - 'Settings → Authorized domains.'; + case 'auth/unauthorized-domain': + final String host = kIsWeb ? Uri.base.host : 'your domain'; + return 'Firebase does not authorize "$host" yet. In project ' + 'cyberhybridhub → Authentication → Settings → Authorized domains, ' + 'add exactly: $host (no https://). Run ' + 'scripts/check-firebase-auth-domains.sh on the server to verify.'; case 'operation-not-allowed': return 'Google sign-in is not enabled. Turn on the Google provider ' 'in Firebase Console → Authentication → Sign-in method.'; @@ -82,7 +103,11 @@ class _LandingScreenState extends State { 'or try again.'; case 'popup-closed-by-user': case 'cancelled-popup-request': + case 'auth/popup-closed-by-user': return 'Sign-in was cancelled. Tap below to try again.'; + case 'auth/unauthorized-domain': + return 'This site is not authorized for Firebase Auth. Add your ' + 'domain in Firebase Console → Authentication → Authorized domains.'; } } @@ -102,6 +127,10 @@ class _LandingScreenState extends State { if (raw.contains('googleWebOAuthClientId')) { return raw.replaceFirst('Bad state: ', ''); } + if (raw.contains('authenticate is not supported on the web')) { + return 'Sign-in is retrying with a full-page redirect. If nothing happens, ' + 'allow popups for this site or hard-refresh and try again.'; + } return 'Could not sign in. Please try again.'; } } @@ -155,9 +184,14 @@ class _BackgroundGlow extends StatelessWidget { } class _NarrowLayout extends StatelessWidget { - const _NarrowLayout({required this.errorMessage, required this.onError}); + const _NarrowLayout({ + required this.errorMessage, + required this.errorDetail, + required this.onError, + }); final String? errorMessage; + final String? errorDetail; final void Function(Object error) onError; @override @@ -173,7 +207,11 @@ class _NarrowLayout extends StatelessWidget { const SizedBox(height: 28), const _BenefitsList(), const SizedBox(height: 32), - _CtaSection(errorMessage: errorMessage, onError: onError), + _CtaSection( + errorMessage: errorMessage, + errorDetail: errorDetail, + onError: onError, + ), const SizedBox(height: 24), const _TrustFooter(), ], @@ -183,9 +221,14 @@ class _NarrowLayout extends StatelessWidget { } class _WideLayout extends StatelessWidget { - const _WideLayout({required this.errorMessage, required this.onError}); + const _WideLayout({ + required this.errorMessage, + required this.errorDetail, + required this.onError, + }); final String? errorMessage; + final String? errorDetail; final void Function(Object error) onError; @override @@ -211,7 +254,11 @@ class _WideLayout extends StatelessWidget { const SizedBox(width: 48), Expanded( flex: 4, - child: _CtaCard(errorMessage: errorMessage, onError: onError), + child: _CtaCard( + errorMessage: errorMessage, + errorDetail: errorDetail, + onError: onError, + ), ), ], ), @@ -344,21 +391,35 @@ class _BenefitsList extends StatelessWidget { } class _CtaSection extends StatelessWidget { - const _CtaSection({required this.errorMessage, required this.onError}); + const _CtaSection({ + required this.errorMessage, + required this.errorDetail, + required this.onError, + }); final String? errorMessage; + final String? errorDetail; final void Function(Object error) onError; @override Widget build(BuildContext context) { - return _CtaCard(errorMessage: errorMessage, onError: onError); + return _CtaCard( + errorMessage: errorMessage, + errorDetail: errorDetail, + onError: onError, + ); } } class _CtaCard extends StatelessWidget { - const _CtaCard({required this.errorMessage, required this.onError}); + const _CtaCard({ + required this.errorMessage, + required this.errorDetail, + required this.onError, + }); final String? errorMessage; + final String? errorDetail; final void Function(Object error) onError; @override @@ -403,13 +464,32 @@ class _CtaCard extends StatelessWidget { ), borderRadius: BorderRadius.circular(8), ), - child: Text( - errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 14, - ), - textAlign: TextAlign.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + if (errorDetail != null && + errorDetail != errorMessage) ...[ + const SizedBox(height: 8), + Text( + errorDetail!, + style: TextStyle( + color: Theme.of(context).colorScheme.error + .withValues(alpha: 0.85), + fontSize: 11, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ], + ], ), ), const SizedBox(height: 16), diff --git a/lib/services/auth_service_firebase.dart b/lib/services/auth_service_firebase.dart index ee5ce78..97a8126 100644 --- a/lib/services/auth_service_firebase.dart +++ b/lib/services/auth_service_firebase.dart @@ -1,9 +1,13 @@ import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:google_sign_in/google_sign_in.dart'; import '../config/auth_config.dart'; +import '../firebase_options.dart'; import '../models/app_user.dart'; +import '../utils/auth_log.dart'; +import '../utils/firebase_auth_domain_check.dart'; class AuthServiceFirebase { AuthServiceFirebase._(); @@ -21,7 +25,11 @@ class AuthServiceFirebase { AppUser? get currentUser => _mapUser(_firebaseAuth.currentUser); Future initialize() async { + authLog('initialize', context: _authEnvironmentContext()); + if (kIsWeb) { + await logFirebaseAuthorizedDomainsIfWeb(); + await _completeWebRedirectIfAny(); // Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup // already loads Google Identity Services, and double-initializing logs // a "google.accounts.id.initialize() is called multiple times" warning. @@ -38,9 +46,45 @@ class AuthServiceFirebase { await _googleSignIn.initialize(); } _googleSignInReady = true; + authLog('google_sign_in initialized (mobile/desktop)'); + } + + /// After a redirect-based OAuth round-trip, Firebase completes sign-in here. + Future _completeWebRedirectIfAny() async { + authLog('web: checking getRedirectResult (return from redirect flow)'); + try { + final UserCredential credential = await _firebaseAuth.getRedirectResult(); + final User? user = credential.user; + if (user != null) { + authLog( + 'web: redirect sign-in completed', + context: { + 'uid': user.uid, + if (user.email != null) 'email': user.email!, + 'provider': credential.credential?.providerId ?? 'unknown', + }, + ); + } else { + authLog('web: getRedirectResult — no user (normal on fresh load)'); + } + } on FirebaseAuthException catch (e, st) { + authLog( + 'web: getRedirectResult FirebaseAuthException', + error: e, + stackTrace: st, + context: firebaseExceptionContext(e), + ); + } catch (e, st) { + authLog( + 'web: getRedirectResult failed', + error: e, + stackTrace: st, + ); + } } Future signInWithGoogle() async { + authLog('signInWithGoogle start', context: _authEnvironmentContext()); if (kIsWeb) { await _signInWithGoogleWeb(); return; @@ -48,55 +92,106 @@ class AuthServiceFirebase { _ensureGoogleSignInReady(); - final GoogleSignInAccount account = await _googleSignIn.authenticate(); - final String? idToken = account.authentication.idToken; - if (idToken == null) { - throw StateError('Google Sign-In did not return an ID token.'); - } + try { + final GoogleSignInAccount account = await _googleSignIn.authenticate(); + final String? idToken = account.authentication.idToken; + if (idToken == null) { + throw StateError('Google Sign-In did not return an ID token.'); + } - final credential = GoogleAuthProvider.credential(idToken: idToken); - await _firebaseAuth.signInWithCredential(credential); + final credential = GoogleAuthProvider.credential(idToken: idToken); + await _firebaseAuth.signInWithCredential(credential); + authLog('signInWithCredential succeeded', context: { + 'uid': _firebaseAuth.currentUser?.uid ?? '', + }); + } catch (e, st) { + authLog('native sign-in failed', error: e, stackTrace: st); + rethrow; + } } - /// Web: popup avoids Firebase redirect sessionStorage (broken under ETP). - /// Falls back to Google Identity Services + [signInWithCredential] when set up. + /// Web: try Firebase popup first; on failure use full-page redirect. + /// + /// [GoogleSignIn.authenticate] is not implemented on web (google_sign_in 7.x); + /// use [signInWithRedirect] instead of a GIS programmatic fallback. Future _signInWithGoogleWeb() async { + authLog('web: sign-in start', context: _authEnvironmentContext()); + try { - await _firebaseAuth.signInWithPopup(GoogleAuthProvider()); + authLog('web: attempting signInWithPopup(GoogleAuthProvider)'); + final UserCredential credential = await _firebaseAuth.signInWithPopup( + GoogleAuthProvider(), + ); + authLog( + 'web: signInWithPopup succeeded', + context: { + 'uid': credential.user?.uid ?? '', + if (credential.user?.email != null) 'email': credential.user!.email!, + }, + ); return; - } on FirebaseAuthException catch (e) { + } on FirebaseAuthException catch (e, st) { + authLog( + 'web: signInWithPopup FirebaseAuthException', + error: e, + stackTrace: st, + context: firebaseExceptionContext(e), + ); if (_isUserCancelledAuth(e)) { rethrow; } - } - - if (googleWebOAuthClientId == null) { - throw StateError( - 'Google sign-in failed in this browser (often Firefox with strict ' - 'privacy). Set googleWebOAuthClientId in lib/config/auth_config.dart ' - '(Firebase Console → Authentication → Google → Web SDK configuration) ' - 'and add ${Uri.base.origin} to that client\'s authorized JavaScript ' - 'origins in Google Cloud Console.', + await _signInWithGoogleRedirect(triggerCode: e.code); + return; + } catch (e, st) { + authLog( + 'web: signInWithPopup unexpected error', + error: e, + stackTrace: st, ); + if (e is UnimplementedError) { + authLog( + 'web: caught UnimplementedError from popup path — using redirect', + ); + } + await _signInWithGoogleRedirect(triggerCode: e.runtimeType.toString()); } + } - if (!_googleSignInReady) { - await _googleSignIn.initialize(clientId: googleWebOAuthClientId); - _googleSignInReady = true; - } - - final GoogleSignInAccount account = await _googleSignIn.authenticate(); - final String? idToken = account.authentication.idToken; - if (idToken == null) { - throw StateError('Google Sign-In did not return an ID token.'); - } - - await _firebaseAuth.signInWithCredential( - GoogleAuthProvider.credential(idToken: idToken), + /// Full-page redirect when popup/iframe auth fails. + Future _signInWithGoogleRedirect({String? triggerCode}) async { + authLog( + 'web: attempting signInWithRedirect after popup failure', + context: { + 'trigger': triggerCode ?? 'unknown', + ..._authEnvironmentContext(), + }, ); + try { + await _firebaseAuth.signInWithRedirect(GoogleAuthProvider()); + authLog( + 'web: signInWithRedirect invoked — page will navigate away; ' + 'on return, getRedirectResult runs in initialize()', + ); + } on FirebaseAuthException catch (e, st) { + authLog( + 'web: signInWithRedirect FirebaseAuthException', + error: e, + stackTrace: st, + context: firebaseExceptionContext(e), + ); + rethrow; + } catch (e, st) { + authLog( + 'web: signInWithRedirect failed', + error: e, + stackTrace: st, + ); + rethrow; + } } Future signOut() async { + authLog('signOut'); if (_googleSignInReady) { await _googleSignIn.signOut(); } @@ -108,7 +203,17 @@ class AuthServiceFirebase { if (user == null) { return null; } - return user.getIdToken(forceRefresh); + try { + return await user.getIdToken(forceRefresh); + } catch (e, st) { + authLog( + 'getIdToken failed', + error: e, + stackTrace: st, + context: {'uid': user.uid}, + ); + rethrow; + } } void _ensureGoogleSignInReady() { @@ -124,7 +229,20 @@ class AuthServiceFirebase { bool _isUserCancelledAuth(FirebaseAuthException e) { return e.code == 'popup-closed-by-user' || - e.code == 'cancelled-popup-request'; + e.code == 'cancelled-popup-request' || + e.code == 'auth/popup-closed-by-user'; + } + + Map _authEnvironmentContext() { + final FirebaseOptions options = DefaultFirebaseOptions.currentPlatform; + return { + if (kIsWeb) 'pageOrigin': Uri.base.origin, + if (kIsWeb) 'pageHost': Uri.base.host, + 'authDomain': options.authDomain ?? '', + 'projectId': options.projectId, + 'hasGoogleWebClientId': googleWebOAuthClientId != null, + 'platform': kIsWeb ? 'web' : 'native', + }; } AppUser? _mapUser(User? user) { diff --git a/lib/utils/auth_log.dart b/lib/utils/auth_log.dart new file mode 100644 index 0000000..8216711 --- /dev/null +++ b/lib/utils/auth_log.dart @@ -0,0 +1,70 @@ +import 'dart:developer' as developer; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb; + +/// Verbose auth logging (browser console / `journalctl` when enabled). +/// +/// On by default for web builds. Force in any build: +/// --dart-define=AUTH_VERBOSE_LOG=true +const bool _authVerboseFromEnv = bool.fromEnvironment('AUTH_VERBOSE_LOG'); + +bool get authVerboseLoggingEnabled => + kDebugMode || _authVerboseFromEnv || kIsWeb; + +/// Structured auth diagnostics (no tokens or secrets). +void authLog( + String message, { + Object? error, + StackTrace? stackTrace, + Map? context, +}) { + if (!authVerboseLoggingEnabled) { + return; + } + + final StringBuffer buf = StringBuffer('[Auth] $message'); + if (context != null && context.isNotEmpty) { + buf.write(' | '); + buf.write( + context.entries + .map((MapEntry e) => '${e.key}=${e.value}') + .join(' '), + ); + } + if (error != null) { + buf.write(' | error=${describeAuthError(error)}'); + } + + developer.log( + buf.toString(), + name: 'cyberhybridhub.auth', + error: error, + stackTrace: stackTrace, + ); +} + +/// User-facing detail string; safe to show in support UI (no secrets). +String describeAuthError(Object error) { + if (error is FirebaseAuthException) { + final StringBuffer buf = StringBuffer('FirebaseAuthException'); + buf.write(' code=${error.code}'); + if (error.message != null && error.message!.isNotEmpty) { + buf.write(' message=${error.message}'); + } + if (error.email != null && error.email!.isNotEmpty) { + buf.write(' email=${error.email}'); + } + return buf.toString(); + } + return error.toString(); +} + +Map firebaseExceptionContext(FirebaseAuthException e) { + return { + 'code': e.code, + if (e.message != null) 'message': e.message!, + if (e.email != null) 'email': e.email!, + if (e.credential != null) 'credentialProvider': e.credential!.providerId, + }; +} diff --git a/lib/utils/firebase_auth_domain_check.dart b/lib/utils/firebase_auth_domain_check.dart new file mode 100644 index 0000000..69a48c6 --- /dev/null +++ b/lib/utils/firebase_auth_domain_check.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:http/http.dart' as http; + +import '../firebase_options.dart'; +import 'auth_log.dart'; + +/// Fetches live Firebase Auth authorized domains (public config API) and logs +/// whether [Uri.base.host] is allowed. Helps when the console UI disagrees with +/// what the backend actually has registered. +Future logFirebaseAuthorizedDomainsIfWeb() async { + if (!kIsWeb) { + return; + } + + final String host = Uri.base.host; + final String apiKey = DefaultFirebaseOptions.web.apiKey; + final Uri uri = Uri.parse( + 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig' + '?key=$apiKey', + ); + + try { + final http.Response response = await http.get(uri); + if (response.statusCode != 200) { + authLog( + 'could not load Firebase project config', + context: { + 'status': response.statusCode, + 'host': host, + }, + ); + return; + } + + final Object? decoded = jsonDecode(response.body); + if (decoded is! Map) { + return; + } + + final List domains = + (decoded['authorizedDomains'] as List?) + ?.map((dynamic d) => d.toString()) + .toList() ?? + []; + + final bool allowed = domains.contains(host); + authLog( + allowed + ? 'Firebase authorizedDomains includes this host' + : 'Firebase authorizedDomains MISSING this host — add it in Console', + context: { + 'host': host, + 'projectId': decoded['projectId'] ?? DefaultFirebaseOptions.web.projectId, + 'authorizedDomains': domains.join(', '), + }, + ); + } catch (e, st) { + authLog( + 'Firebase authorizedDomains check failed', + error: e, + stackTrace: st, + ); + } +} diff --git a/lib/widgets/google_sign_in_button.dart b/lib/widgets/google_sign_in_button.dart index 14af37b..70e7f11 100644 --- a/lib/widgets/google_sign_in_button.dart +++ b/lib/widgets/google_sign_in_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; import '../theme/app_theme.dart'; +import '../utils/auth_log.dart'; class GoogleSignInButton extends StatefulWidget { const GoogleSignInButton({ @@ -26,10 +27,17 @@ class _GoogleSignInButtonState extends State { Future _signIn() async { setState(() => _isLoading = true); + authLog('GoogleSignInButton tapped'); try { await AuthService.instance.signInWithGoogle(); + authLog('GoogleSignInButton sign-in completed without error'); widget.onSignedIn?.call(); - } catch (error) { + } catch (error, st) { + authLog( + 'GoogleSignInButton sign-in failed', + error: error, + stackTrace: st, + ); widget.onError?.call(error); } finally { if (mounted) { diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index ffefa31..009c38d 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -15,6 +15,9 @@ const double _centerSideInset = _holdEdgeButtonWidth + 8; enum _LockedDragAxis { horizontal, vertical, rejected } +/// Whether a hold-to-swipe edge action was started from pointer or keyboard. +enum _HoldInputSource { none, pointer, keyboard } + /// Swipeable question card: right submits, left defers (does not resolve). /// /// The center band supports vertical drag as a slider from -10 (down) to 10 (up). @@ -54,10 +57,8 @@ class _SwipeQuestionTileState extends State double _scrollAccumulatedDy = 0; _LockedDragAxis? _lockedDragAxis; bool _acting = false; - bool _holdSavePointerDown = false; - bool _holdDeferPointerDown = false; - bool _keyboardHoldSave = false; - bool _keyboardHoldDefer = false; + _HoldInputSource _holdSaveInput = _HoldInputSource.none; + _HoldInputSource _holdDeferInput = _HoldInputSource.none; int _lastSnappedValue = 0; late final FocusNode _focusNode; late final AnimationController _snapController; @@ -83,10 +84,12 @@ class _SwipeQuestionTileState extends State bool get _holdDeferEnabled => !widget.busy && !_acting; bool get _holdSaveActive => - _holdSavePointerDown || _holdSaveController.value > 0; + _holdSaveInput != _HoldInputSource.none || + _holdSaveController.value > 0; bool get _holdDeferActive => - _holdDeferPointerDown || _holdDeferController.value > 0; + _holdDeferInput != _HoldInputSource.none || + _holdDeferController.value > 0; bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive; @@ -108,6 +111,22 @@ class _SwipeQuestionTileState extends State AnimationController(vsync: this, duration: _holdEdgeDuration) ..addListener(_syncHoldDeferDrag) ..addStatusListener(_onHoldDeferStatus); + WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus()); + } + + @override + void didUpdateWidget(SwipeQuestionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.questionId != widget.questionId) { + WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus()); + } + } + + void _requestTileFocus() { + if (!mounted || widget.busy) { + return; + } + _focusNode.requestFocus(); } @override @@ -123,13 +142,15 @@ class _SwipeQuestionTileState extends State if (_focusNode.hasFocus) { return; } - if (_keyboardHoldSave) { - _keyboardHoldSave = false; - _endHoldSave(); + _cancelKeyboardHolds(); + } + + void _cancelKeyboardHolds() { + if (_holdSaveInput == _HoldInputSource.keyboard) { + _endHoldSave(_HoldInputSource.keyboard); } - if (_keyboardHoldDefer) { - _keyboardHoldDefer = false; - _endHoldDefer(); + if (_holdDeferInput == _HoldInputSource.keyboard) { + _endHoldDefer(_HoldInputSource.keyboard); } } @@ -169,9 +190,9 @@ class _SwipeQuestionTileState extends State _onHoldEdgeDismissed(); } } else if (status == AnimationStatus.completed && - _holdSavePointerDown && + _holdSaveInput != _HoldInputSource.none && mounted) { - _holdSavePointerDown = false; + _holdSaveInput = _HoldInputSource.none; unawaited(_completeHoldSave()); } } @@ -182,26 +203,26 @@ class _SwipeQuestionTileState extends State _onHoldEdgeDismissed(); } } else if (status == AnimationStatus.completed && - _holdDeferPointerDown && + _holdDeferInput != _HoldInputSource.none && mounted) { - _holdDeferPointerDown = false; + _holdDeferInput = _HoldInputSource.none; unawaited(_completeHoldDefer()); } } - void _beginHoldSave() { - if (!_holdSaveEnabled || _holdDeferActive) { + void _beginHoldSave(_HoldInputSource source) { + if (!_holdSaveEnabled || _holdDeferActive || _holdSaveInput != _HoldInputSource.none) { return; } - _holdSavePointerDown = true; + _holdSaveInput = source; _holdSaveController.forward(from: _holdSaveController.value); } - void _endHoldSave() { - if (!_holdSavePointerDown) { + void _endHoldSave(_HoldInputSource source) { + if (_holdSaveInput != source) { return; } - _holdSavePointerDown = false; + _holdSaveInput = _HoldInputSource.none; if (_holdSaveController.value >= 1.0) { unawaited(_completeHoldSave()); return; @@ -210,11 +231,11 @@ class _SwipeQuestionTileState extends State } void _onHoldSavePointerDown(PointerDownEvent event) { - _beginHoldSave(); + _beginHoldSave(_HoldInputSource.pointer); } void _onHoldSavePointerUp(PointerEvent event) { - _endHoldSave(); + _endHoldSave(_HoldInputSource.pointer); } Future _completeHoldSave() async { @@ -236,19 +257,19 @@ class _SwipeQuestionTileState extends State } } - void _beginHoldDefer() { - if (!_holdDeferEnabled || _holdSaveActive) { + void _beginHoldDefer(_HoldInputSource source) { + if (!_holdDeferEnabled || _holdSaveActive || _holdDeferInput != _HoldInputSource.none) { return; } - _holdDeferPointerDown = true; + _holdDeferInput = source; _holdDeferController.forward(from: _holdDeferController.value); } - void _endHoldDefer() { - if (!_holdDeferPointerDown) { + void _endHoldDefer(_HoldInputSource source) { + if (_holdDeferInput != source) { return; } - _holdDeferPointerDown = false; + _holdDeferInput = _HoldInputSource.none; if (_holdDeferController.value >= 1.0) { unawaited(_completeHoldDefer()); return; @@ -257,11 +278,11 @@ class _SwipeQuestionTileState extends State } void _onHoldDeferPointerDown(PointerDownEvent event) { - _beginHoldDefer(); + _beginHoldDefer(_HoldInputSource.pointer); } void _onHoldDeferPointerUp(PointerEvent event) { - _endHoldDefer(); + _endHoldDefer(_HoldInputSource.pointer); } Future _completeHoldDefer() async { @@ -463,42 +484,29 @@ class _SwipeQuestionTileState extends State _stepSliderBy(scrollDy > 0 ? -1 : 1); } + /// Arrow left/right (held): defer / save swipe. Up/down use [CallbackShortcuts]. KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (widget.busy || _acting) { return KeyEventResult.ignored; } final LogicalKeyboardKey key = event.logicalKey; - - if (key == LogicalKeyboardKey.arrowUp || - key == LogicalKeyboardKey.arrowDown) { - if (event is KeyUpEvent) { - return KeyEventResult.handled; - } - if (event is KeyDownEvent || event is KeyRepeatEvent) { - _stepSliderBy(key == LogicalKeyboardKey.arrowUp ? 1 : -1); - return KeyEventResult.handled; - } - } + const _HoldInputSource kb = _HoldInputSource.keyboard; if (key == LogicalKeyboardKey.arrowRight) { if (event is KeyDownEvent) { - _keyboardHoldSave = true; - _beginHoldSave(); - } else if (event is KeyUpEvent && _keyboardHoldSave) { - _keyboardHoldSave = false; - _endHoldSave(); + _beginHoldSave(kb); + } else if (event is KeyUpEvent) { + _endHoldSave(kb); } return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft) { if (event is KeyDownEvent) { - _keyboardHoldDefer = true; - _beginHoldDefer(); - } else if (event is KeyUpEvent && _keyboardHoldDefer) { - _keyboardHoldDefer = false; - _endHoldDefer(); + _beginHoldDefer(kb); + } else if (event is KeyUpEvent) { + _endHoldDefer(kb); } return KeyEventResult.handled; } @@ -598,8 +606,16 @@ class _SwipeQuestionTileState extends State return Focus( focusNode: _focusNode, autofocus: true, + descendantsAreFocusable: false, onKeyEvent: _onKeyEvent, - child: Stack( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowUp, repeat: true): + () => _stepSliderBy(1), + const SingleActivator(LogicalKeyboardKey.arrowDown, repeat: true): + () => _stepSliderBy(-1), + }, + child: Stack( alignment: Alignment.center, clipBehavior: Clip.none, children: [ @@ -618,6 +634,9 @@ class _SwipeQuestionTileState extends State }, child: Listener( behavior: HitTestBehavior.translucent, + onPointerDown: widget.busy || _acting + ? null + : (_) => _requestTileFocus(), onPointerSignal: widget.busy || _acting ? null : _onPointerSignal, @@ -699,7 +718,7 @@ class _SwipeQuestionTileState extends State side: _HoldSwipeEdgeSide.left, enabled: _holdDeferEnabled, progress: _holdDeferController.value, - active: _holdDeferPointerDown, + active: _holdDeferInput != _HoldInputSource.none, icon: Icons.reply, ringColor: AppColors.accent, tooltipEnabled: 'Hold to defer question', @@ -718,7 +737,7 @@ class _SwipeQuestionTileState extends State side: _HoldSwipeEdgeSide.right, enabled: _holdSaveEnabled, progress: _holdSaveController.value, - active: _holdSavePointerDown, + active: _holdSaveInput != _HoldInputSource.none, icon: Icons.save, ringColor: AppColors.success, tooltipEnabled: 'Hold to save answer', @@ -750,6 +769,7 @@ class _SwipeQuestionTileState extends State ), ), ], + ), ), ); }, diff --git a/scripts/add-firebase-auth-domain.sh b/scripts/add-firebase-auth-domain.sh new file mode 100755 index 0000000..f18c1f8 --- /dev/null +++ b/scripts/add-firebase-auth-domain.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Append hostname(s) to Firebase Auth authorizedDomains via Identity Platform API v2. +# Usage: ./scripts/add-firebase-auth-domain.sh [domain ...] +# Example: ./scripts/add-firebase-auth-domain.sh cyberhybridhub.com www.cyberhybridhub.com +set -euo pipefail + +PROJECT="${FIREBASE_PROJECT_ID:-cyberhybridhub}" +DOMAINS=("$@") + +if [ "${#DOMAINS[@]}" -eq 0 ]; then + DOMAINS=(cyberhybridhub.com www.cyberhybridhub.com) +fi + +if ! command -v gcloud >/dev/null 2>&1; then + echo "gcloud not found. Add domains manually in Firebase Console, or install gcloud and run:" >&2 + echo " gcloud auth login" >&2 + exit 1 +fi + +TOKEN="$(gcloud auth print-access-token)" +BASE="https://identitytoolkit.googleapis.com/v2/projects/${PROJECT}/config" + +echo "Reading current config for project ${PROJECT}..." +CURRENT="$(curl -s -H "Authorization: Bearer ${TOKEN}" -H "x-goog-user-project: ${PROJECT}" "${BASE}")" + +if ! echo "$CURRENT" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then + echo "Failed to read config:" >&2 + echo "$CURRENT" >&2 + exit 1 +fi + +UPDATED="$(echo "$CURRENT" | DOMAINS="${DOMAINS[*]}" python3 -c " +import json, os, sys +data = json.load(sys.stdin) +domains = list(data.get('authorizedDomains', [])) +for d in os.environ['DOMAINS'].split(): + d = d.strip().lower() + if d and d not in domains: + domains.append(d) +data['authorizedDomains'] = domains +print(json.dumps({'authorizedDomains': domains})) +")" + +echo "Patching authorizedDomains: ${DOMAINS[*]}" +RESPONSE="$(curl -s -X PATCH \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "x-goog-user-project: ${PROJECT}" \ + -H "Content-Type: application/json" \ + "${BASE}?updateMask=authorizedDomains" \ + -d "${UPDATED}")" + +if echo "$RESPONSE" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then + echo "$RESPONSE" | python3 -m json.tool + echo "" + echo "Done. Verify:" + echo " ./scripts/check-firebase-auth-domains.sh" +else + echo "Patch failed:" >&2 + echo "$RESPONSE" >&2 + exit 1 +fi diff --git a/scripts/build-production-web.sh b/scripts/build-production-web.sh new file mode 100755 index 0000000..cb256ae --- /dev/null +++ b/scripts/build-production-web.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build release Flutter web for VM hosting. +# API URL: shell API_BASE_URL > hosting/hosting.env API_PUBLIC_URL > +# server/.env API_BASE_URL > default https://cyberhybridhub.com +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [ -f "$ROOT/scripts/flutter-env.sh" ]; then + # shellcheck source=/dev/null + source "$ROOT/scripts/flutter-env.sh" +fi + +if ! command -v flutter >/dev/null 2>&1; then + echo "flutter not found. Install Flutter or set FLUTTER_ROOT, then:" >&2 + echo " source scripts/flutter-env.sh" >&2 + exit 1 +fi + +_CLI_OVERRIDE="${API_BASE_URL:-}" + +_load_env_api_url() { + local file=$1 + [ -f "$file" ] || return 0 + local line + line="$(grep -E '^API_BASE_URL=' "$file" 2>/dev/null | tail -1 || true)" + [ -n "$line" ] || return 0 + line="${line#API_BASE_URL=}" + line="${line%\"}" + line="${line#\"}" + printf '%s' "$line" +} + +API_BASE_URL="https://cyberhybridhub.com" + +_server_api="$(_load_env_api_url "$ROOT/server/.env")" +if [ -n "$_server_api" ]; then + API_BASE_URL="$_server_api" +fi + +if [ -f "$ROOT/hosting/hosting.env" ]; then + # shellcheck source=/dev/null + source "$ROOT/hosting/hosting.env" + API_BASE_URL="${API_PUBLIC_URL:-$API_BASE_URL}" +fi + +if [ -n "$_CLI_OVERRIDE" ]; then + API_BASE_URL="$_CLI_OVERRIDE" +fi + +echo "Building release web with API_BASE_URL=$API_BASE_URL" +flutter build web --release \ + --dart-define=API_BASE_URL="$API_BASE_URL" \ + --dart-define=AUTH_VERBOSE_LOG=true + +if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then + "$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true +fi + +echo "Done: $ROOT/build/web" diff --git a/scripts/check-firebase-auth-domains.sh b/scripts/check-firebase-auth-domains.sh new file mode 100755 index 0000000..ffde9d3 --- /dev/null +++ b/scripts/check-firebase-auth-domains.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Print Firebase Auth authorized domains and verify production hostnames. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API_KEY="${FIREBASE_WEB_API_KEY:-AIzaSyDbYBh6nO-UasqwM-dkVVJAvw5wMhRTsIs}" + +# Hostnames the app uses (no scheme, no port). +REQUIRED_DOMAINS=( + cyberhybridhub.com +) + +OPTIONAL_DOMAINS=( + www.cyberhybridhub.com +) + +echo "Fetching authorized domains for project cyberhybridhub..." +JSON="$(curl -s "https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig?key=${API_KEY}")" +echo "$JSON" | python3 -m json.tool + +DOMAINS="$(echo "$JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('\n'.join(d.get('authorizedDomains',[])))")" + +echo "" +echo "=== Check ===" +MISSING=0 +for domain in "${REQUIRED_DOMAINS[@]}"; do + if echo "$DOMAINS" | grep -Fxq "$domain"; then + echo "OK $domain" + else + echo "FAIL $domain (required — Google sign-in will not work on https://$domain)" + MISSING=1 + fi +done +for domain in "${OPTIONAL_DOMAINS[@]}"; do + if echo "$DOMAINS" | grep -Fxq "$domain"; then + echo "OK $domain" + else + echo "WARN $domain (optional; add if users visit https://$domain)" + fi +done + +if [ "$MISSING" -ne 0 ]; then + echo "" + echo "Fix (pick one):" + echo " 1. Firebase Console → project cyberhybridhub → Authentication → Settings" + echo " → Authorized domains → Add domain → cyberhybridhub.com (no https://)" + echo " 2. After editing firebase.json redirect URIs:" + echo " firebase login && firebase deploy --only auth --project cyberhybridhub" + echo " 3. Run: ./scripts/add-firebase-auth-domain.sh cyberhybridhub.com" + echo " (needs gcloud auth with Firebase Admin permissions)" + exit 1 +fi + +echo "" +echo "All required domains are authorized." diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 0000000..3f781ef --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Prepare a production deploy (no sudo). After this succeeds, install/restart systemd: +# ./hosting/install-systemd.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "==> Flutter web (release)" +"$ROOT/scripts/build-production-web.sh" + +echo "==> API dependencies" +( + cd "$ROOT/server" + if ! command -v dart >/dev/null 2>&1; then + echo "dart not found on PATH" >&2 + exit 1 + fi + dart pub get +) + +if [ ! -f "$ROOT/server/.env" ]; then + echo "" + echo "Warning: $ROOT/server/.env is missing." + echo " Create and configure it before starting cyberhybridhub-api." + if [ -f "$ROOT/server/.env.example" ]; then + echo " cp server/.env.example server/.env" + fi +fi + +echo "" +echo "Deploy artifacts ready." +echo "Next (requires sudo):" +echo " cp hosting/hosting.env.example hosting/hosting.env # if not done" +echo " edit hosting/hosting.env" +echo " ./hosting/install-systemd.sh" diff --git a/scripts/flutter-env.sh b/scripts/flutter-env.sh index f35c8e1..e2e8e5c 100644 --- a/scripts/flutter-env.sh +++ b/scripts/flutter-env.sh @@ -1,3 +1,11 @@ -# Source this file before flutter/dart commands in any shell: -# source /home/nathan/cyberhybridhub.com/scripts/flutter-env.sh -export PATH="$HOME/github-open/flutter/bin:$PATH" +# Source before flutter/dart commands: +# source /home/chh/cyberhybridhub.com/scripts/flutter-env.sh +# +# Override install location: +# export FLUTTER_ROOT="$HOME/path/to/flutter" + +if [ -n "${FLUTTER_ROOT:-}" ] && [ -d "$FLUTTER_ROOT/bin" ]; then + export PATH="$FLUTTER_ROOT/bin:$PATH" +elif [ -d "$HOME/github-open/flutter/bin" ]; then + export PATH="$HOME/github-open/flutter/bin:$PATH" +fi diff --git a/scripts/web_static_server.dart b/scripts/web_static_server.dart index 00e97a6..554bcbe 100644 --- a/scripts/web_static_server.dart +++ b/scripts/web_static_server.dart @@ -8,12 +8,16 @@ // * Serves files from a root directory passed on the command line. // * SPA fallback: requests with no file extension that don't match a real // file fall back to index.html so client-side routing works. -// * No-cache headers on every response so a fresh `flutter build web` -// always reaches the browser without a hard refresh. +// * Dev (default): no-cache on every response. +// * Production (STATIC_SERVER_PRODUCTION=1): cache static assets; no-cache +// for HTML entrypoints. // * Rejects path traversal (`..` segments). // // Usage: -// dart run scripts/web_static_server.dart +// dart run scripts/web_static_server.dart [bind-host] +// +// bind-host: 127.0.0.1 (default), localhost, 0.0.0.0, or any +// Override bind via WEB_BIND_HOST env. import 'dart:async'; import 'dart:io'; @@ -21,25 +25,51 @@ import 'dart:io'; Future main(List args) async { final String rootArg = args.isNotEmpty ? args[0] : 'build/web'; final int port = args.length > 1 ? int.parse(args[1]) : 8080; + final String bindHost = args.length > 2 + ? args[2] + : (Platform.environment['WEB_BIND_HOST'] ?? '127.0.0.1'); final Directory rootDir = Directory(rootArg).absolute; if (!rootDir.existsSync()) { stderr.writeln('Static root not found: ${rootDir.path}'); stderr.writeln( - 'Hint: run `flutter build web --debug` before starting this server.', + 'Hint: run `flutter build web` before starting this server.', ); exit(1); } - final HttpServer server = await HttpServer.bind('localhost', port); - stdout.writeln('Static server listening on http://localhost:$port'); + final InternetAddress address = _parseBindHost(bindHost); + final HttpServer server = await HttpServer.bind(address, port); + final String displayHost = address.type == InternetAddressType.unix + ? bindHost + : (address.isLoopback ? '127.0.0.1' : '0.0.0.0'); + stdout.writeln('Static server listening on http://$displayHost:$port'); stdout.writeln('Serving: ${rootDir.path}'); + if (Platform.environment['STATIC_SERVER_PRODUCTION'] == '1') { + stdout.writeln('Production mode: cache enabled for static assets'); + } await for (final HttpRequest req in server) { unawaited(_handle(req, rootDir)); } } +InternetAddress _parseBindHost(String host) { + final String normalized = host.trim().toLowerCase(); + if (normalized == '0.0.0.0' || + normalized == 'any' || + normalized == '*' || + normalized == 'all') { + return InternetAddress.anyIPv4; + } + if (normalized == 'localhost' || + normalized == '127.0.0.1' || + normalized == 'loopback') { + return InternetAddress.loopbackIPv4; + } + return InternetAddress(normalized); +} + Future _handle(HttpRequest req, Directory root) async { try { String requestPath = req.uri.path; @@ -79,14 +109,8 @@ Future _handle(HttpRequest req, Directory root) async { req.response ..statusCode = HttpStatus.ok - ..headers.contentType = ContentType.parse(_mimeFor(target.path)) - // Disable caching so dev rebuilds always reach the browser. - ..headers.set( - 'Cache-Control', - 'no-store, no-cache, must-revalidate, max-age=0', - ) - ..headers.set('Pragma', 'no-cache') - ..headers.set('Expires', '0'); + ..headers.contentType = ContentType.parse(_mimeFor(target.path)); + _applyCacheHeaders(req.response, target.path); await target.openRead().pipe(req.response); } catch (e, st) { @@ -100,6 +124,31 @@ Future _handle(HttpRequest req, Directory root) async { } } +void _applyCacheHeaders(HttpResponse response, String filePath) { + final bool production = + Platform.environment['STATIC_SERVER_PRODUCTION'] == '1'; + if (!production) { + response.headers.set( + 'Cache-Control', + 'no-store, no-cache, must-revalidate, max-age=0', + ); + response.headers.set('Pragma', 'no-cache'); + response.headers.set('Expires', '0'); + return; + } + + final String lower = filePath.toLowerCase(); + if (lower.endsWith('.html') || lower.endsWith('.htm')) { + response.headers.set( + 'Cache-Control', + 'no-cache, must-revalidate', + ); + return; + } + + response.headers.set('Cache-Control', 'public, max-age=86400'); +} + String _mimeFor(String filePath) { final int dot = filePath.lastIndexOf('.'); final String ext = dot < 0 ? '' : filePath.substring(dot + 1).toLowerCase(); diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..49c3749 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,39 @@ +# Copy to server/.env and configure for this VM. +# Required for cyberhybridhub-api systemd service. + +DATABASE_URL=postgresql://postgres:PASSWORD@localhost:5432/cyberhybridhub +FIREBASE_WEB_API_KEY= + +PORT=3000 + +QUESTION_WORKER_ENABLED=true +QUESTION_WORKER_INTERVAL_SECONDS=60 +QUESTION_PIPELINE_TEST_MODE=false +PROSPECTIVE_ANSWER_CLOSENESS_ENABLED=false + +# Required when MARKET_HISTORY_SYNC_ENABLED=true (server refuses to start otherwise). +TRADING_ENABLED=true +TRADING_WORKER_INGEST_ENABLED=true +TRADING_WORKER_EVAL_ENABLED=true +TRADING_DEV_ENDPOINTS_ENABLED=false + +# Must be true for /v1/admin/* routes and the home-screen admin icon probe. +ADMIN_PORTAL_ENABLED=true +# Comma-separated Firebase Auth UIDs (Console → Authentication → Users). +ADMIN_FIREBASE_UIDS= + +# Alpaca (required when TRADING_ENABLED=true or MARKET_HISTORY_SYNC_ENABLED=true) +ALPACA_API_KEY_ID= +ALPACA_API_SECRET_KEY= +ALPACA_TRADING_BASE_URL=https://paper-api.alpaca.markets +ALPACA_DATA_BASE_URL=https://data.alpaca.markets +ALPACA_DATA_FEED=iex +ALPACA_ALLOW_LIVE=false + +MARKET_HISTORY_SYNC_ENABLED=true +MARKET_HISTORY_WINDOW_DAYS=5 +MARKET_HISTORY_RETENTION_DAYS=5 + +# Public API URL baked into Flutter web builds (see scripts/build-production-web.sh). +# Use same origin as the site when HAProxy proxies /v1 and /hubs on :443. +API_BASE_URL=https://cyberhybridhub.com diff --git a/startup.sh b/startup.sh index 8a1a29d..a1d0ce5 100755 --- a/startup.sh +++ b/startup.sh @@ -103,7 +103,7 @@ fi log "Starting web app on http://localhost:${WEB_PORT} ..." ( cd "$ROOT" - dart run scripts/web_static_server.dart build/web "${WEB_PORT}" \ + dart run scripts/web_static_server.dart build/web "${WEB_PORT}" 127.0.0.1 \ 2>&1 | prefix_lines web ) & WEB_PID=$!