Compare commits

..

2 Commits

Author SHA1 Message Date
b840db0cff latests 2026-06-04 19:31:04 +00:00
4035684b4d Add arrow-key controls to the guessing question tile.
Up/down step the answer by one; hold left/right to defer or save like the edge hold buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:16:10 +00:00
24 changed files with 1158 additions and 111 deletions

File diff suppressed because one or more lines are too long

5
.gitignore vendored
View File

@ -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

View File

@ -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"
}
}
},

88
hosting/README.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

79
hosting/install-systemd.sh Executable file
View File

@ -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}/"

View File

@ -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.

View File

@ -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<LandingScreen> {
String? _errorMessage;
String? _errorDetail;
@override
Widget build(BuildContext context) {
@ -44,10 +46,12 @@ class _LandingScreenState extends State<LandingScreen> {
child: wide
? _WideLayout(
errorMessage: _errorMessage,
errorDetail: _errorDetail,
onError: _handleError,
)
: _NarrowLayout(
errorMessage: _errorMessage,
errorDetail: _errorDetail,
onError: _handleError,
),
),
@ -61,9 +65,23 @@ class _LandingScreenState extends State<LandingScreen> {
);
}
void _handleError(Object error) {
void _handleError(Object error, [StackTrace? stackTrace]) {
authLog(
'LandingScreen sign-in error',
error: error,
stackTrace: stackTrace,
context: kIsWeb
? <String, Object?>{
'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<LandingScreen> {
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<LandingScreen> {
'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<LandingScreen> {
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: <Widget>[
Text(
errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 14,
),
textAlign: TextAlign.center,
),
if (errorDetail != null &&
errorDetail != errorMessage) ...<Widget>[
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),

View File

@ -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<void> 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<void> _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: <String, Object?>{
'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<void> 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: <String, Object?>{
'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<void> _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: <String, Object?>{
'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<void> _signInWithGoogleRedirect({String? triggerCode}) async {
authLog(
'web: attempting signInWithRedirect after popup failure',
context: <String, Object?>{
'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<void> 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: <String, Object?>{'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<String, Object?> _authEnvironmentContext() {
final FirebaseOptions options = DefaultFirebaseOptions.currentPlatform;
return <String, Object?>{
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) {

70
lib/utils/auth_log.dart Normal file
View File

@ -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<String, Object?>? context,
}) {
if (!authVerboseLoggingEnabled) {
return;
}
final StringBuffer buf = StringBuffer('[Auth] $message');
if (context != null && context.isNotEmpty) {
buf.write(' | ');
buf.write(
context.entries
.map((MapEntry<String, Object?> 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<String, Object?> firebaseExceptionContext(FirebaseAuthException e) {
return <String, Object?>{
'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,
};
}

View File

@ -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<void> 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: <String, Object?>{
'status': response.statusCode,
'host': host,
},
);
return;
}
final Object? decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return;
}
final List<String> domains =
(decoded['authorizedDomains'] as List<dynamic>?)
?.map((dynamic d) => d.toString())
.toList() ??
<String>[];
final bool allowed = domains.contains(host);
authLog(
allowed
? 'Firebase authorizedDomains includes this host'
: 'Firebase authorizedDomains MISSING this host — add it in Console',
context: <String, Object?>{
'host': host,
'projectId': decoded['projectId'] ?? DefaultFirebaseOptions.web.projectId,
'authorizedDomains': domains.join(', '),
},
);
} catch (e, st) {
authLog(
'Firebase authorizedDomains check failed',
error: e,
stackTrace: st,
);
}
}

View File

@ -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<GoogleSignInButton> {
Future<void> _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) {

View File

@ -15,9 +15,14 @@ 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).
/// Arrow up/down step the value by one; hold arrow left/right to defer or save
/// (same as the edge hold buttons).
///
/// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
/// cardinal axis; diagonal swipes are ignored.
@ -52,9 +57,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
double _scrollAccumulatedDy = 0;
_LockedDragAxis? _lockedDragAxis;
bool _acting = false;
bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false;
_HoldInputSource _holdSaveInput = _HoldInputSource.none;
_HoldInputSource _holdDeferInput = _HoldInputSource.none;
int _lastSnappedValue = 0;
late final FocusNode _focusNode;
late final AnimationController _snapController;
late final AnimationController _holdSaveController;
late final AnimationController _holdDeferController;
@ -78,10 +84,12 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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;
@ -90,6 +98,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override
void initState() {
super.initState();
_focusNode = FocusNode()..addListener(_onFocusChange);
_snapController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 140),
@ -102,16 +111,49 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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
void dispose() {
_focusNode.dispose();
_snapController.dispose();
_holdSaveController.dispose();
_holdDeferController.dispose();
super.dispose();
}
void _onFocusChange() {
if (_focusNode.hasFocus) {
return;
}
_cancelKeyboardHolds();
}
void _cancelKeyboardHolds() {
if (_holdSaveInput == _HoldInputSource.keyboard) {
_endHoldSave(_HoldInputSource.keyboard);
}
if (_holdDeferInput == _HoldInputSource.keyboard) {
_endHoldDefer(_HoldInputSource.keyboard);
}
}
void _syncHoldSaveDrag() {
if (!mounted || _acting || _holdDeferActive) {
return;
@ -148,9 +190,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdSavePointerDown &&
_holdSaveInput != _HoldInputSource.none &&
mounted) {
_holdSavePointerDown = false;
_holdSaveInput = _HoldInputSource.none;
unawaited(_completeHoldSave());
}
}
@ -161,26 +203,26 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdDeferPointerDown &&
_holdDeferInput != _HoldInputSource.none &&
mounted) {
_holdDeferPointerDown = false;
_holdDeferInput = _HoldInputSource.none;
unawaited(_completeHoldDefer());
}
}
void _onHoldSavePointerDown(PointerDownEvent event) {
if (!_holdSaveEnabled || _holdDeferActive) {
void _beginHoldSave(_HoldInputSource source) {
if (!_holdSaveEnabled || _holdDeferActive || _holdSaveInput != _HoldInputSource.none) {
return;
}
_holdSavePointerDown = true;
_holdSaveInput = source;
_holdSaveController.forward(from: _holdSaveController.value);
}
void _onHoldSavePointerUp(PointerEvent event) {
if (!_holdSavePointerDown) {
void _endHoldSave(_HoldInputSource source) {
if (_holdSaveInput != source) {
return;
}
_holdSavePointerDown = false;
_holdSaveInput = _HoldInputSource.none;
if (_holdSaveController.value >= 1.0) {
unawaited(_completeHoldSave());
return;
@ -188,6 +230,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
unawaited(_holdSaveController.reverse());
}
void _onHoldSavePointerDown(PointerDownEvent event) {
_beginHoldSave(_HoldInputSource.pointer);
}
void _onHoldSavePointerUp(PointerEvent event) {
_endHoldSave(_HoldInputSource.pointer);
}
Future<void> _completeHoldSave() async {
if (_acting || widget.busy || _atZero || !mounted) {
return;
@ -207,19 +257,19 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}
}
void _onHoldDeferPointerDown(PointerDownEvent event) {
if (!_holdDeferEnabled || _holdSaveActive) {
void _beginHoldDefer(_HoldInputSource source) {
if (!_holdDeferEnabled || _holdSaveActive || _holdDeferInput != _HoldInputSource.none) {
return;
}
_holdDeferPointerDown = true;
_holdDeferInput = source;
_holdDeferController.forward(from: _holdDeferController.value);
}
void _onHoldDeferPointerUp(PointerEvent event) {
if (!_holdDeferPointerDown) {
void _endHoldDefer(_HoldInputSource source) {
if (_holdDeferInput != source) {
return;
}
_holdDeferPointerDown = false;
_holdDeferInput = _HoldInputSource.none;
if (_holdDeferController.value >= 1.0) {
unawaited(_completeHoldDefer());
return;
@ -227,6 +277,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
unawaited(_holdDeferController.reverse());
}
void _onHoldDeferPointerDown(PointerDownEvent event) {
_beginHoldDefer(_HoldInputSource.pointer);
}
void _onHoldDeferPointerUp(PointerEvent event) {
_endHoldDefer(_HoldInputSource.pointer);
}
Future<void> _completeHoldDefer() async {
if (_acting || widget.busy || !mounted) {
return;
@ -400,15 +458,12 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}
}
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
void _stepSliderFromScroll(double scrollDy) {
if (scrollDy == 0) {
/// Steps the slider by [delta] integers, clamped to [-10, 10].
void _stepSliderBy(int delta) {
if (widget.busy || _acting || delta == 0) {
return;
}
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
_sliderMin,
_sliderMax,
);
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
if (next == _snappedSliderValue) {
return;
}
@ -421,6 +476,44 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
});
}
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
void _stepSliderFromScroll(double scrollDy) {
if (scrollDy == 0) {
return;
}
_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;
const _HoldInputSource kb = _HoldInputSource.keyboard;
if (key == LogicalKeyboardKey.arrowRight) {
if (event is KeyDownEvent) {
_beginHoldSave(kb);
} else if (event is KeyUpEvent) {
_endHoldSave(kb);
}
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
if (event is KeyDownEvent) {
_beginHoldDefer(kb);
} else if (event is KeyUpEvent) {
_endHoldDefer(kb);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _accumulateScrollDelta(double dy) {
if (widget.busy || _acting || dy == 0) {
return;
@ -510,10 +603,22 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
40,
);
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: <Widget>[
return Focus(
focusNode: _focusNode,
autofocus: true,
descendantsAreFocusable: false,
onKeyEvent: _onKeyEvent,
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
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: <Widget>[
Transform.translate(
offset: Offset(_dragOffset, 0),
child: AnimatedBuilder(
@ -529,6 +634,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
},
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: widget.busy || _acting
? null
: (_) => _requestTileFocus(),
onPointerSignal: widget.busy || _acting
? null
: _onPointerSignal,
@ -610,7 +718,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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',
@ -629,7 +737,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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',
@ -660,7 +768,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
),
],
],
),
),
);
},
);

View File

@ -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

61
scripts/build-production-web.sh Executable file
View File

@ -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"

View File

@ -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."

36
scripts/deploy-production.sh Executable file
View File

@ -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"

View File

@ -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

View File

@ -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 <root-dir> <port>
// dart run scripts/web_static_server.dart <root-dir> <port> [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<void> main(List<String> 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<void> _handle(HttpRequest req, Directory root) async {
try {
String requestPath = req.uri.path;
@ -79,14 +109,8 @@ Future<void> _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<void> _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();

39
server/.env.example Normal file
View File

@ -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

View File

@ -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=$!