Compare commits

..

No commits in common. "b840db0cff1ee8e0b5254c99db38bdff93078eed" and "e64d21dc2e968653cbf29b2633e75bae8dadea12" have entirely different histories.

24 changed files with 111 additions and 1158 deletions

File diff suppressed because one or more lines are too long

5
.gitignore vendored
View File

@ -1,6 +1,3 @@
# VM hosting (machine-specific paths)
hosting/hosting.env
# Flutter/Dart
.dart_tool/
.packages
@ -80,5 +77,3 @@ coverage/
migrate_working_dir/
*.env
*.env.*
!server/.env.example
!hosting/hosting.env.example

View File

@ -5,12 +5,8 @@
"oAuthBrandDisplayName": "Cyber Hybrid Hub",
"supportEmail": "support@cyberhybridhub.firebaseapp.com",
"authorizedRedirectUris": [
"http://localhost",
"https://cyberhybridhub.firebaseapp.com",
"https://cyberhybridhub.com",
"https://www.cyberhybridhub.com"
],
"redirectUri": "https://cyberhybridhub.firebaseapp.com"
"http://localhost"
]
}
}
},

View File

@ -1,88 +0,0 @@
# 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

@ -1,17 +0,0 @@
[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

@ -1,20 +0,0 @@
[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

@ -1,57 +0,0 @@
# 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

@ -1,15 +0,0 @@
# 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

View File

@ -1,79 +0,0 @@
#!/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,13 +1,12 @@
/// 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.
/// 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.)
/// Popup is tried first; redirect is not used (sessionStorage breaks under ETP).
///
/// Find it: Firebase Console → Project settings → Your apps → Web app,
/// or Authentication → Sign-in method → Google → Web SDK configuration.

View File

@ -3,7 +3,6 @@ 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';
@ -16,7 +15,6 @@ class LandingScreen extends StatefulWidget {
class _LandingScreenState extends State<LandingScreen> {
String? _errorMessage;
String? _errorDetail;
@override
Widget build(BuildContext context) {
@ -46,12 +44,10 @@ class _LandingScreenState extends State<LandingScreen> {
child: wide
? _WideLayout(
errorMessage: _errorMessage,
errorDetail: _errorDetail,
onError: _handleError,
)
: _NarrowLayout(
errorMessage: _errorMessage,
errorDetail: _errorDetail,
onError: _handleError,
),
),
@ -65,23 +61,9 @@ class _LandingScreenState extends State<LandingScreen> {
);
}
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,
);
void _handleError(Object error) {
setState(() {
_errorMessage = _friendlyError(error);
_errorDetail = authVerboseLoggingEnabled
? describeAuthError(error)
: null;
});
}
@ -89,12 +71,9 @@ class _LandingScreenState extends State<LandingScreen> {
if (error is FirebaseAuthException) {
switch (error.code) {
case 'unauthorized-domain':
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.';
final String origin = kIsWeb ? Uri.base.origin : 'this app';
return 'Add $origin under Firebase Console → Authentication → '
'Settings → Authorized domains.';
case 'operation-not-allowed':
return 'Google sign-in is not enabled. Turn on the Google provider '
'in Firebase Console → Authentication → Sign-in method.';
@ -103,11 +82,7 @@ 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.';
}
}
@ -127,10 +102,6 @@ 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.';
}
}
@ -184,14 +155,9 @@ class _BackgroundGlow extends StatelessWidget {
}
class _NarrowLayout extends StatelessWidget {
const _NarrowLayout({
required this.errorMessage,
required this.errorDetail,
required this.onError,
});
const _NarrowLayout({required this.errorMessage, required this.onError});
final String? errorMessage;
final String? errorDetail;
final void Function(Object error) onError;
@override
@ -207,11 +173,7 @@ class _NarrowLayout extends StatelessWidget {
const SizedBox(height: 28),
const _BenefitsList(),
const SizedBox(height: 32),
_CtaSection(
errorMessage: errorMessage,
errorDetail: errorDetail,
onError: onError,
),
_CtaSection(errorMessage: errorMessage, onError: onError),
const SizedBox(height: 24),
const _TrustFooter(),
],
@ -221,14 +183,9 @@ class _NarrowLayout extends StatelessWidget {
}
class _WideLayout extends StatelessWidget {
const _WideLayout({
required this.errorMessage,
required this.errorDetail,
required this.onError,
});
const _WideLayout({required this.errorMessage, required this.onError});
final String? errorMessage;
final String? errorDetail;
final void Function(Object error) onError;
@override
@ -254,11 +211,7 @@ class _WideLayout extends StatelessWidget {
const SizedBox(width: 48),
Expanded(
flex: 4,
child: _CtaCard(
errorMessage: errorMessage,
errorDetail: errorDetail,
onError: onError,
),
child: _CtaCard(errorMessage: errorMessage, onError: onError),
),
],
),
@ -391,35 +344,21 @@ class _BenefitsList extends StatelessWidget {
}
class _CtaSection extends StatelessWidget {
const _CtaSection({
required this.errorMessage,
required this.errorDetail,
required this.onError,
});
const _CtaSection({required this.errorMessage, required this.onError});
final String? errorMessage;
final String? errorDetail;
final void Function(Object error) onError;
@override
Widget build(BuildContext context) {
return _CtaCard(
errorMessage: errorMessage,
errorDetail: errorDetail,
onError: onError,
);
return _CtaCard(errorMessage: errorMessage, onError: onError);
}
}
class _CtaCard extends StatelessWidget {
const _CtaCard({
required this.errorMessage,
required this.errorDetail,
required this.onError,
});
const _CtaCard({required this.errorMessage, required this.onError});
final String? errorMessage;
final String? errorDetail;
final void Function(Object error) onError;
@override
@ -464,10 +403,7 @@ class _CtaCard extends StatelessWidget {
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
child: Text(
errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
@ -475,22 +411,6 @@ class _CtaCard extends StatelessWidget {
),
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,13 +1,9 @@
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._();
@ -25,11 +21,7 @@ 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.
@ -46,45 +38,9 @@ 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;
@ -92,7 +48,6 @@ class AuthServiceFirebase {
_ensureGoogleSignInReady();
try {
final GoogleSignInAccount account = await _googleSignIn.authenticate();
final String? idToken = account.authentication.idToken;
if (idToken == null) {
@ -101,97 +56,47 @@ class AuthServiceFirebase {
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: 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.
/// Web: popup avoids Firebase redirect sessionStorage (broken under ETP).
/// Falls back to Google Identity Services + [signInWithCredential] when set up.
Future<void> _signInWithGoogleWeb() async {
authLog('web: sign-in start', context: _authEnvironmentContext());
try {
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!,
},
);
await _firebaseAuth.signInWithPopup(GoogleAuthProvider());
return;
} on FirebaseAuthException catch (e, st) {
authLog(
'web: signInWithPopup FirebaseAuthException',
error: e,
stackTrace: st,
context: firebaseExceptionContext(e),
);
} on FirebaseAuthException catch (e) {
if (_isUserCancelledAuth(e)) {
rethrow;
}
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());
}
}
/// 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(),
},
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.',
);
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;
}
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),
);
}
Future<void> signOut() async {
authLog('signOut');
if (_googleSignInReady) {
await _googleSignIn.signOut();
}
@ -203,17 +108,7 @@ class AuthServiceFirebase {
if (user == null) {
return null;
}
try {
return await user.getIdToken(forceRefresh);
} catch (e, st) {
authLog(
'getIdToken failed',
error: e,
stackTrace: st,
context: <String, Object?>{'uid': user.uid},
);
rethrow;
}
return user.getIdToken(forceRefresh);
}
void _ensureGoogleSignInReady() {
@ -229,20 +124,7 @@ class AuthServiceFirebase {
bool _isUserCancelledAuth(FirebaseAuthException e) {
return e.code == 'popup-closed-by-user' ||
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',
};
e.code == 'cancelled-popup-request';
}
AppUser? _mapUser(User? user) {

View File

@ -1,70 +0,0 @@
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

@ -1,66 +0,0 @@
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,7 +2,6 @@ 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({
@ -27,17 +26,10 @@ 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, st) {
authLog(
'GoogleSignInButton sign-in failed',
error: error,
stackTrace: st,
);
} catch (error) {
widget.onError?.call(error);
} finally {
if (mounted) {

View File

@ -15,14 +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).
/// 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.
@ -57,10 +52,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
double _scrollAccumulatedDy = 0;
_LockedDragAxis? _lockedDragAxis;
bool _acting = false;
_HoldInputSource _holdSaveInput = _HoldInputSource.none;
_HoldInputSource _holdDeferInput = _HoldInputSource.none;
bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false;
int _lastSnappedValue = 0;
late final FocusNode _focusNode;
late final AnimationController _snapController;
late final AnimationController _holdSaveController;
late final AnimationController _holdDeferController;
@ -84,12 +78,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool get _holdDeferEnabled => !widget.busy && !_acting;
bool get _holdSaveActive =>
_holdSaveInput != _HoldInputSource.none ||
_holdSaveController.value > 0;
_holdSavePointerDown || _holdSaveController.value > 0;
bool get _holdDeferActive =>
_holdDeferInput != _HoldInputSource.none ||
_holdDeferController.value > 0;
_holdDeferPointerDown || _holdDeferController.value > 0;
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
@ -98,7 +90,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override
void initState() {
super.initState();
_focusNode = FocusNode()..addListener(_onFocusChange);
_snapController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 140),
@ -111,49 +102,16 @@ 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;
@ -190,9 +148,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdSaveInput != _HoldInputSource.none &&
_holdSavePointerDown &&
mounted) {
_holdSaveInput = _HoldInputSource.none;
_holdSavePointerDown = false;
unawaited(_completeHoldSave());
}
}
@ -203,26 +161,26 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_onHoldEdgeDismissed();
}
} else if (status == AnimationStatus.completed &&
_holdDeferInput != _HoldInputSource.none &&
_holdDeferPointerDown &&
mounted) {
_holdDeferInput = _HoldInputSource.none;
_holdDeferPointerDown = false;
unawaited(_completeHoldDefer());
}
}
void _beginHoldSave(_HoldInputSource source) {
if (!_holdSaveEnabled || _holdDeferActive || _holdSaveInput != _HoldInputSource.none) {
void _onHoldSavePointerDown(PointerDownEvent event) {
if (!_holdSaveEnabled || _holdDeferActive) {
return;
}
_holdSaveInput = source;
_holdSavePointerDown = true;
_holdSaveController.forward(from: _holdSaveController.value);
}
void _endHoldSave(_HoldInputSource source) {
if (_holdSaveInput != source) {
void _onHoldSavePointerUp(PointerEvent event) {
if (!_holdSavePointerDown) {
return;
}
_holdSaveInput = _HoldInputSource.none;
_holdSavePointerDown = false;
if (_holdSaveController.value >= 1.0) {
unawaited(_completeHoldSave());
return;
@ -230,14 +188,6 @@ 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;
@ -257,19 +207,19 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}
}
void _beginHoldDefer(_HoldInputSource source) {
if (!_holdDeferEnabled || _holdSaveActive || _holdDeferInput != _HoldInputSource.none) {
void _onHoldDeferPointerDown(PointerDownEvent event) {
if (!_holdDeferEnabled || _holdSaveActive) {
return;
}
_holdDeferInput = source;
_holdDeferPointerDown = true;
_holdDeferController.forward(from: _holdDeferController.value);
}
void _endHoldDefer(_HoldInputSource source) {
if (_holdDeferInput != source) {
void _onHoldDeferPointerUp(PointerEvent event) {
if (!_holdDeferPointerDown) {
return;
}
_holdDeferInput = _HoldInputSource.none;
_holdDeferPointerDown = false;
if (_holdDeferController.value >= 1.0) {
unawaited(_completeHoldDefer());
return;
@ -277,14 +227,6 @@ 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;
@ -458,12 +400,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}
}
/// Steps the slider by [delta] integers, clamped to [-10, 10].
void _stepSliderBy(int delta) {
if (widget.busy || _acting || delta == 0) {
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
void _stepSliderFromScroll(double scrollDy) {
if (scrollDy == 0) {
return;
}
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
_sliderMin,
_sliderMax,
);
if (next == _snappedSliderValue) {
return;
}
@ -476,44 +421,6 @@ 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;
@ -603,19 +510,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
40,
);
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(
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: <Widget>[
@ -634,9 +529,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
},
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: widget.busy || _acting
? null
: (_) => _requestTileFocus(),
onPointerSignal: widget.busy || _acting
? null
: _onPointerSignal,
@ -718,7 +610,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
side: _HoldSwipeEdgeSide.left,
enabled: _holdDeferEnabled,
progress: _holdDeferController.value,
active: _holdDeferInput != _HoldInputSource.none,
active: _holdDeferPointerDown,
icon: Icons.reply,
ringColor: AppColors.accent,
tooltipEnabled: 'Hold to defer question',
@ -737,7 +629,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
side: _HoldSwipeEdgeSide.right,
enabled: _holdSaveEnabled,
progress: _holdSaveController.value,
active: _holdSaveInput != _HoldInputSource.none,
active: _holdSavePointerDown,
icon: Icons.save,
ringColor: AppColors.success,
tooltipEnabled: 'Hold to save answer',
@ -769,8 +661,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
),
),
],
),
),
);
},
);

View File

@ -1,61 +0,0 @@
#!/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

View File

@ -1,61 +0,0 @@
#!/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

@ -1,55 +0,0 @@
#!/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."

View File

@ -1,36 +0,0 @@
#!/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,11 +1,3 @@
# 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
# 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"

View File

@ -8,16 +8,12 @@
// * 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.
// * Dev (default): no-cache on every response.
// * Production (STATIC_SERVER_PRODUCTION=1): cache static assets; no-cache
// for HTML entrypoints.
// * No-cache headers on every response so a fresh `flutter build web`
// always reaches the browser without a hard refresh.
// * Rejects path traversal (`..` segments).
//
// Usage:
// 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.
// dart run scripts/web_static_server.dart <root-dir> <port>
import 'dart:async';
import 'dart:io';
@ -25,51 +21,25 @@ 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` before starting this server.',
'Hint: run `flutter build web --debug` before starting this server.',
);
exit(1);
}
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');
final HttpServer server = await HttpServer.bind('localhost', port);
stdout.writeln('Static server listening on http://localhost:$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;
@ -109,8 +79,14 @@ Future<void> _handle(HttpRequest req, Directory root) async {
req.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.parse(_mimeFor(target.path));
_applyCacheHeaders(req.response, target.path);
..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');
await target.openRead().pipe(req.response);
} catch (e, st) {
@ -124,31 +100,6 @@ 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();

View File

@ -1,39 +0,0 @@
# 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}" 127.0.0.1 \
dart run scripts/web_static_server.dart build/web "${WEB_PORT}" \
2>&1 | prefix_lines web
) &
WEB_PID=$!