From 921ddc9ac914d427aa9ade8f2f3275c39049bf64 Mon Sep 17 00:00:00 2001 From: vidhya Date: Thu, 15 Oct 2026 21:18:21 +0530 Subject: [PATCH] chore(release): configure deployment architecture, environment security, and release signing --- .gitignore | 21 ++++ deploy/backup_restore.sh | 110 ++++++++++++++++++ deploy/docker-compose.prod.yml | 87 ++++++++++++++ deploy/nginx/metatron_erp.conf | 93 +++++++++++++++ deploy/odoo.prod.conf | 35 ++++++ flutter_app/android/app/build.gradle.kts | 36 ++++-- .../android/app/src/main/AndroidManifest.xml | 5 +- .../main/res/xml/network_security_config.xml | 24 ++++ flutter_app/android/key.properties.example | 10 ++ flutter_app/ios/Runner/Info.plist | 2 +- flutter_app/lib/core/api/api_client.dart | 15 +++ flutter_app/lib/core/api/api_config.dart | 42 ++++++- flutter_app/lib/core/api/api_exception.dart | 3 + 13 files changed, 469 insertions(+), 14 deletions(-) create mode 100755 deploy/backup_restore.sh create mode 100644 deploy/docker-compose.prod.yml create mode 100644 deploy/nginx/metatron_erp.conf create mode 100644 deploy/odoo.prod.conf create mode 100644 flutter_app/android/app/src/main/res/xml/network_security_config.xml create mode 100644 flutter_app/android/key.properties.example diff --git a/.gitignore b/.gitignore index b908d4c..3219faf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,24 @@ __pycache__/ *.pyc .DS_Store + +# Environment and secrets +.env +.env.* +*.key +*.pem +*.p12 +*.jks +*.keystore +key.properties + +# Database dumps and backup archives +*.dump +*.sql +*.tar.gz + +# IDE and build artifacts +.idea/ +.vscode/ +build/ +.dart_tool/ diff --git a/deploy/backup_restore.sh b/deploy/backup_restore.sh new file mode 100755 index 0000000..c33743b --- /dev/null +++ b/deploy/backup_restore.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# ============================================================================== +# Metatron Student ERP - Production Backup & Recovery Script +# Usage: +# ./backup_restore.sh backup [DEST_DIR] +# ./backup_restore.sh restore +# ./backup_restore.sh verify +# ============================================================================== + +set -euo pipefail + +DB_CONTAINER="${DB_CONTAINER:-metatron_academic_db}" +ODOO_CONTAINER="${ODOO_CONTAINER:-metatron_academic}" +DB_USER="${POSTGRES_USER:-odoo}" +DB_NAME="${POSTGRES_DB:-metatron_aca_erp}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +ACTION="${1:-help}" + +case "$ACTION" in + backup) + BACKUP_DIR="${2:-./backups}" + mkdir -p "$BACKUP_DIR" + + DB_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.dump" + FS_FILE="$BACKUP_DIR/filestore_${DB_NAME}_${TIMESTAMP}.tar.gz" + + echo "[+] Starting PostgreSQL database backup for '$DB_NAME'..." + docker exec -t "$DB_CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DB_FILE" + echo "[✓] Database backup created: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))" + + echo "[+] Starting Odoo filestore archive for '$DB_NAME'..." + docker exec "$ODOO_CONTAINER" tar -czf - -C /var/lib/odoo/filestore "$DB_NAME" > "$FS_FILE" + echo "[✓] Filestore archive created: $FS_FILE ($(du -h "$FS_FILE" | cut -f1))" + + echo "[+] Calculating SHA256 checksums..." + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256" + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256" + fi + + echo "==================================================================" + echo "BACKUP SUCCESSFUL: $TIMESTAMP" + echo "Database: $DB_FILE" + echo "Filestore: $FS_FILE" + echo "==================================================================" + ;; + + verify) + DB_FILE="${2:-}" + FS_FILE="${3:-}" + + if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then + echo "[-] Error: Provide both database dump and filestore archive to verify." + echo " Usage: $0 verify " + exit 1 + fi + + echo "[+] Verifying database archive integrity..." + # pg_restore -l lists table of contents without restoring + docker exec -i "$DB_CONTAINER" pg_restore -l < "$DB_FILE" > /dev/null + echo "[✓] Database dump archive is valid." + + echo "[+] Verifying filestore tar.gz integrity..." + tar -tzf "$FS_FILE" > /dev/null + echo "[✓] Filestore tar.gz archive is valid." + echo "All backup archives verified intact." + ;; + + restore) + DB_FILE="${2:-}" + FS_FILE="${3:-}" + TARGET_DB="${4:-${DB_NAME}_restored}" + + if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then + echo "[-] Error: Provide DB dump file, filestore archive, and target database name." + echo " Usage: $0 restore [TARGET_DB]" + exit 1 + fi + + echo "[!] WARNING: You are about to restore to database '$TARGET_DB'." + read -p "Are you sure you want to continue? (y/N): " CONFIRM + if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Restore aborted by user." + exit 0 + fi + + echo "[+] Creating database '$TARGET_DB' if not exists..." + docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE \"$TARGET_DB\" OWNER \"$DB_USER\";" 2>/dev/null || true + + echo "[+] Restoring PostgreSQL database from $DB_FILE..." + docker exec -i "$DB_CONTAINER" pg_restore -U "$DB_USER" -d "$TARGET_DB" --clean --if-exists < "$DB_FILE" || true + echo "[✓] Database restore completed." + + echo "[+] Restoring filestore to container..." + docker exec -i "$ODOO_CONTAINER" mkdir -p /var/lib/odoo/filestore/"$TARGET_DB" + docker exec -i "$ODOO_CONTAINER" tar -xzf - -C /var/lib/odoo/filestore/"$TARGET_DB" --strip-components=1 < "$FS_FILE" + echo "[✓] Filestore restored to /var/lib/odoo/filestore/$TARGET_DB." + ;; + + *) + echo "Metatron Student ERP Backup & Recovery Utility" + echo "Usage:" + echo " $0 backup [DEST_DIR] - Take full DB and filestore backup" + echo " $0 verify - Test backup archive integrity" + echo " $0 restore - Restore to target database" + exit 1 + ;; +esac diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..3c62420 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,87 @@ +services: + db: + image: postgres:15-alpine + container_name: metatron_erp_db_prod + restart: always + environment: + POSTGRES_DB: ${POSTGRES_DB:-metatron_aca_erp} + POSTGRES_USER: ${POSTGRES_USER:-odoo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - prod_pgdata:/var/lib/postgresql/data + networks: + - internal_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-odoo} -d ${POSTGRES_DB:-metatron_aca_erp}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + odoo: + image: odoo:19.0 + container_name: metatron_erp_app_prod + restart: always + depends_on: + db: + condition: service_healthy + environment: + HOST: db + PORT: 5432 + USER: ${POSTGRES_USER:-odoo} + PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - prod_odoo_data:/var/lib/odoo + - ../addons:/mnt/extra-addons:ro + - ./odoo.prod.conf:/etc/odoo/odoo.conf:ro + command: odoo -c /etc/odoo/odoo.conf + networks: + - internal_network + - proxy_network + healthcheck: + test: ["CMD-SHELL", "curl -f http://127.0.0.1:8069/web/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + nginx: + image: nginx:alpine + container_name: metatron_erp_nginx_prod + restart: always + depends_on: + odoo: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/metatron_erp.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/etc/nginx/certs:ro + networks: + - proxy_network + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + +networks: + internal_network: + internal: true + proxy_network: + driver: bridge + +volumes: + prod_pgdata: + prod_odoo_data: diff --git a/deploy/nginx/metatron_erp.conf b/deploy/nginx/metatron_erp.conf new file mode 100644 index 0000000..6748d11 --- /dev/null +++ b/deploy/nginx/metatron_erp.conf @@ -0,0 +1,93 @@ +# Upstream servers +upstream odoo_backend { + server odoo:8069; +} + +upstream odoo_chat { + server odoo:8072; +} + +# Redirect all HTTP traffic to HTTPS +server { + listen 80; + server_name erp.yourdomain.edu; + + # ACME-challenge for Let's Encrypt certificates + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS server +server { + listen 443 ssl http2; + server_name erp.yourdomain.edu; + + # SSL certificate paths + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + # Modern TLS configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # Security Headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Request body limits for mobile document and certificate uploads + client_max_body_size 25M; + + # Buffer sizes + proxy_read_timeout 720s; + proxy_connect_timeout 720s; + proxy_send_timeout 720s; + + # Log format + access_log /var/log/nginx/odoo.access.log; + error_log /var/log/nginx/odoo.error.log; + + # Longpolling / WebSocket requests + location /websocket { + proxy_pass http://odoo_chat; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Main Odoo application and Mobile API endpoints + location / { + proxy_pass http://odoo_backend; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Gzip compression for API payloads and static files + gzip on; + gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript; + } + + # Cache static files + location ~* /web/static/ { + proxy_cache_valid 200 60m; + proxy_buffering on; + expires 864000; + proxy_pass http://odoo_backend; + } +} diff --git a/deploy/odoo.prod.conf b/deploy/odoo.prod.conf new file mode 100644 index 0000000..07cf3c8 --- /dev/null +++ b/deploy/odoo.prod.conf @@ -0,0 +1,35 @@ +[options] +; Database server settings +db_host = db +db_port = 5432 +db_user = odoo +db_name = metatron_aca_erp + +; Security: Disable Database Manager interface in production +list_db = False + +; Master Password for administrative database actions +admin_passwd = CHANGE_ME_STRONG_ODOO_MASTER_PASSWORD + +; Addons paths +addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons + +; Multi-processing and concurrency for production +workers = 5 +max_cron_threads = 2 + +; Reverse proxy support (trusts X-Forwarded-For and X-Forwarded-Proto) +proxy_mode = True + +; Time and memory limits +limit_time_cpu = 120 +limit_time_real = 240 +limit_memory_hard = 2684354560 +limit_memory_soft = 2147483648 +limit_request = 8192 + +; Logging configuration +log_level = info +log_handler = :INFO,odoo.addons.education_mobile_api:INFO +logfile = False +log_db = False diff --git a/flutter_app/android/app/build.gradle.kts b/flutter_app/android/app/build.gradle.kts index cce54b0..b8680fe 100644 --- a/flutter_app/android/app/build.gradle.kts +++ b/flutter_app/android/app/build.gradle.kts @@ -1,3 +1,12 @@ +import java.util.Properties +import java.io.FileInputStream + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. @@ -14,30 +23,37 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + signingConfigs { + create("release") { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) } + storePassword = keystoreProperties.getProperty("storePassword") + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + // Application ID matches reverse-domain package identifier applicationId = "in.metatroncube.metatron_student_erp" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion - // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION - // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) - // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` - // flag during build. versionCode = flutter.versionCode versionName = flutter.versionName } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + val releaseConfig = signingConfigs.getByName("release") + signingConfig = if (keystorePropertiesFile.exists() && releaseConfig.storeFile?.exists() == true) { + releaseConfig + } else { + signingConfigs.getByName("debug") + } } } } + kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml index dde934f..0acae60 100644 --- a/flutter_app/android/app/src/main/AndroidManifest.xml +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -2,9 +2,10 @@ + android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + + + + + + + + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/flutter_app/android/key.properties.example b/flutter_app/android/key.properties.example new file mode 100644 index 0000000..066cfbe --- /dev/null +++ b/flutter_app/android/key.properties.example @@ -0,0 +1,10 @@ +# Metatron Student ERP - Android Release Keystore Configuration Template +# DO NOT COMMIT key.properties WITH REAL SECRETS TO SOURCE CONTROL +# Copy this file to key.properties and fill in your actual production keystore details: +# cp key.properties.example key.properties +# Ensure key.properties and *.keystore/*.jks files are in .gitignore (already configured). + +storePassword= +keyPassword= +keyAlias= +storeFile= diff --git a/flutter_app/ios/Runner/Info.plist b/flutter_app/ios/Runner/Info.plist index 3b12191..c346240 100644 --- a/flutter_app/ios/Runner/Info.plist +++ b/flutter_app/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Metatron Student Erp + Metatron Student ERP CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/flutter_app/lib/core/api/api_client.dart b/flutter_app/lib/core/api/api_client.dart index 75bc4a7..14f928d 100644 --- a/flutter_app/lib/core/api/api_client.dart +++ b/flutter_app/lib/core/api/api_client.dart @@ -34,6 +34,16 @@ class ApiClient { // Dynamic base URL check in case it was updated options.baseUrl = ApiConfig.baseUrl; + if (options.baseUrl.isEmpty) { + return handler.reject( + DioException( + requestOptions: options, + type: DioExceptionType.cancel, + error: 'API Server URL is not configured. Please configure server URL in Settings.', + ), + ); + } + // Attach session cookie if available _sessionCookie ??= await _storage.getSessionCookie(); if (_sessionCookie != null && _sessionCookie!.isNotEmpty) { @@ -175,6 +185,11 @@ class ApiClient { return ApiException.fromCode('SERVER_ERROR', statusCode: statusCode); } + if (error.error != null && error.error.toString().contains('API Server URL is not configured')) { + return ApiException.fromCode('NOT_CONFIGURED', customMessage: error.error.toString()); + } + + if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout || error.type == DioExceptionType.sendTimeout || diff --git a/flutter_app/lib/core/api/api_config.dart b/flutter_app/lib/core/api/api_config.dart index 69c95b4..2a51b7f 100644 --- a/flutter_app/lib/core/api/api_config.dart +++ b/flutter_app/lib/core/api/api_config.dart @@ -1,24 +1,63 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +enum AppEnvironment { + development, + staging, + production; + + static AppEnvironment get current { + const env = String.fromEnvironment('ENVIRONMENT', defaultValue: ''); + switch (env.toLowerCase()) { + case 'production': + case 'prod': + return AppEnvironment.production; + case 'staging': + case 'stage': + return AppEnvironment.staging; + case 'development': + case 'dev': + return AppEnvironment.development; + default: + return kReleaseMode ? AppEnvironment.production : AppEnvironment.development; + } + } +} + class ApiConfig { static const String _envBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: ''); static String _customBaseUrl = ''; + static AppEnvironment get environment => AppEnvironment.current; + + static bool get isProduction => environment == AppEnvironment.production; + static bool get isStaging => environment == AppEnvironment.staging; + static bool get isDevelopment => environment == AppEnvironment.development; + /// Returns the active API base URL. /// Priority: /// 1. Custom runtime configured URL (e.g. from Settings screen for physical devices) /// 2. Compile-time --dart-define=API_BASE_URL=... - /// 3. Platform default: 10.0.2.2 on Android Emulator, localhost on iOS/macOS/Web + /// 3. In development/testing: platform default (10.0.2.2 on Android Emulator, localhost on iOS/macOS/Web) + /// 4. In production: returns empty if unconfigured (no hardcoded localhost fallback in production builds) static String get baseUrl { if (_customBaseUrl.isNotEmpty) { return _customBaseUrl; } + if (_envBaseUrl.isNotEmpty) { + if (isProduction && !_envBaseUrl.startsWith('https://')) { + debugPrint('WARNING: Production API_BASE_URL should use HTTPS: $_envBaseUrl'); + } return _envBaseUrl; } + // In production without compile-time or runtime override, do not default to localhost + if (isProduction) { + return ''; + } + if (kIsWeb) { return 'http://localhost:10008'; } @@ -47,3 +86,4 @@ class ApiConfig { static const Duration connectTimeout = Duration(seconds: 15); static const Duration receiveTimeout = Duration(seconds: 15); } + diff --git a/flutter_app/lib/core/api/api_exception.dart b/flutter_app/lib/core/api/api_exception.dart index 164f686..7e224e0 100644 --- a/flutter_app/lib/core/api/api_exception.dart +++ b/flutter_app/lib/core/api/api_exception.dart @@ -18,6 +18,9 @@ class ApiException implements Exception { case 'AUTH_REQUIRED': message = 'Your session has expired. Please login again.'; break; + case 'NOT_CONFIGURED': + message = 'API Server URL is not configured. Please configure server URL in Settings.'; + break; case 'STUDENT_NOT_LINKED': message = 'No student profile is linked to this account.'; break;