Compare commits
2 Commits
e64d21dc2e
...
b840db0cff
| Author | SHA1 | Date | |
|---|---|---|---|
| b840db0cff | |||
| 4035684b4d |
File diff suppressed because one or more lines are too long
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
88
hosting/README.md
Normal 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.
|
||||
17
hosting/cyberhybridhub-api.service
Normal file
17
hosting/cyberhybridhub-api.service
Normal 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
|
||||
20
hosting/cyberhybridhub-web.service
Normal file
20
hosting/cyberhybridhub-web.service
Normal 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
|
||||
57
hosting/haproxy-openwrt.example.cfg
Normal file
57
hosting/haproxy-openwrt.example.cfg
Normal 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
|
||||
15
hosting/hosting.env.example
Normal file
15
hosting/hosting.env.example
Normal 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
79
hosting/install-systemd.sh
Executable 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}/"
|
||||
@ -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.
|
||||
|
||||
@ -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,7 +464,10 @@ class _CtaCard extends StatelessWidget {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
@ -411,6 +475,22 @@ 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),
|
||||
],
|
||||
|
||||
@ -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,6 +92,7 @@ class AuthServiceFirebase {
|
||||
|
||||
_ensureGoogleSignInReady();
|
||||
|
||||
try {
|
||||
final GoogleSignInAccount account = await _googleSignIn.authenticate();
|
||||
final String? idToken = account.authentication.idToken;
|
||||
if (idToken == null) {
|
||||
@ -56,47 +101,97 @@ class AuthServiceFirebase {
|
||||
|
||||
final credential = GoogleAuthProvider.credential(idToken: idToken);
|
||||
await _firebaseAuth.signInWithCredential(credential);
|
||||
}
|
||||
|
||||
/// Web: popup avoids Firebase redirect sessionStorage (broken under ETP).
|
||||
/// Falls back to Google Identity Services + [signInWithCredential] when set up.
|
||||
Future<void> _signInWithGoogleWeb() async {
|
||||
try {
|
||||
await _firebaseAuth.signInWithPopup(GoogleAuthProvider());
|
||||
return;
|
||||
} on FirebaseAuthException catch (e) {
|
||||
if (_isUserCancelledAuth(e)) {
|
||||
authLog('signInWithCredential succeeded', context: <String, Object?>{
|
||||
'uid': _firebaseAuth.currentUser?.uid ?? '',
|
||||
});
|
||||
} catch (e, st) {
|
||||
authLog('native sign-in failed', error: e, stackTrace: st);
|
||||
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.',
|
||||
/// 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 {
|
||||
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, st) {
|
||||
authLog(
|
||||
'web: signInWithPopup FirebaseAuthException',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
context: firebaseExceptionContext(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',
|
||||
);
|
||||
}
|
||||
|
||||
if (!_googleSignInReady) {
|
||||
await _googleSignIn.initialize(clientId: googleWebOAuthClientId);
|
||||
_googleSignInReady = true;
|
||||
await _signInWithGoogleRedirect(triggerCode: e.runtimeType.toString());
|
||||
}
|
||||
}
|
||||
|
||||
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
70
lib/utils/auth_log.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
66
lib/utils/firebase_auth_domain_check.dart
Normal file
66
lib/utils/firebase_auth_domain_check.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,7 +603,19 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
40,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
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>[
|
||||
@ -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',
|
||||
@ -661,6 +769,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
61
scripts/add-firebase-auth-domain.sh
Executable file
61
scripts/add-firebase-auth-domain.sh
Executable 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
61
scripts/build-production-web.sh
Executable 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"
|
||||
55
scripts/check-firebase-auth-domains.sh
Executable file
55
scripts/check-firebase-auth-domains.sh
Executable 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
36
scripts/deploy-production.sh
Executable 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"
|
||||
@ -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
|
||||
|
||||
@ -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
39
server/.env.example
Normal 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
|
||||
@ -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=$!
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user