Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04@sha256:81380e4c9c14e8a629
USER root

# Copy pinned artifacts so they can be used as defaults when build-args are not provided
COPY .devcontainer/pinned-artifacts.json /tmp/pinned-artifacts.json
COPY pinned-artifacts.json /tmp/pinned-artifacts.json

# 1. System Dependencies, Java 17, and SSH Setup
RUN apt-get update && apt-get install -y \
Expand Down
10 changes: 8 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass

# ⚠️ App version displayed in footer and health checks
# 🔨 Build-time (Infisical `/build-time` folder)
NEXT_PUBLIC_APP_VERSION=4.4.5
NEXT_PUBLIC_APP_VERSION=4.4.6

# ⚠️ Your production domain WITHOUT https://
# All URL-based variables are derived from this.
Expand Down Expand Up @@ -111,6 +111,12 @@ NEXT_PUBLIC_SUPABASE_URL=
# 🔨 Build-time (Infisical `/build-time` folder — optional)
NEXT_PUBLIC_SUPABASE_DEV_URL=

# ℹ️ Supabase browser proxy — Development override (optional)
# Used by the browser client and CSP checks in local development when you want
# to point the app at a different proxy target than production.
# 🔨 Build-time (Infisical `/build-time` folder — optional)
NEXT_PUBLIC_SUPABASE_DEV_PROXY_URL=

# ℹ️ Supabase browser proxy — Tier 1: Cloudflare Worker (ISP bypass, optional)
# Deploy workers/supabase-proxy/index.js as a CF Worker, then set this to the
# worker URL. The browser client tries CF first, falls back to AWS (Tier 2),
Expand Down Expand Up @@ -359,7 +365,7 @@ JWE_PRIVATE_KEY=

# ⚠️ Minimum supported app version required to bypass forced update
# 🚀 Runtime (Infisical `/runtime` folder → Server Env Var)
MIN_APP_VERSION=4.4.5
MIN_APP_VERSION=4.4.6

# ℹ️ Enforce Firebase App Check for all mobile clients in production
# Valid: "true", "false" (default: false in dev, true recommended in prod)
Expand Down
1 change: 1 addition & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:debuggable="false"
android:enableOnBackInvokedCallback="false"
tools:ignore="HardcodedDebugMode">
<activity
android:name=".MainActivity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,23 @@ import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.view.MotionEvent

class MainActivity : FlutterActivity() {
private val CHANNEL = "com.devakesu.apps.ghostclass/security"
private var isCurrentWindowObscured = false

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_DOWN) {
isCurrentWindowObscured = false
}
val isObscured = (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0 ||
(event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED) != 0
if (isObscured) {
isCurrentWindowObscured = true
}
return super.dispatchTouchEvent(event)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Expand All @@ -33,7 +47,7 @@ class MainActivity : FlutterActivity() {
System.exit(0)
}
"isWindowObscured" -> {
result.success(false)
result.success(isCurrentWindowObscured)
}
else -> {
result.notImplemented()
Expand All @@ -42,3 +56,4 @@ class MainActivity : FlutterActivity() {
}
}
}

8 changes: 6 additions & 2 deletions mobile/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ class AppConfig {

/// The Supabase Origin used to bypass "Forbidden: missing Origin header" errors.
/// Spoofed to match the official app domain.
static String get supabaseOrigin => webUrl;
static String get supabaseOrigin =>
AppSecrets.isDev ? 'https://localhost:3000' : webUrl;

// ─── Backend & Bridge Config ───────────────────────────────────────────────

Expand Down Expand Up @@ -75,7 +76,7 @@ class AppConfig {

/// Current application version (derived from Infisical compilation injection).
static String get appVersion =>
const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.5');
const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.6');

/// Commit SHA injected by CI for release builds.
static String get appCommitSha =>
Expand Down Expand Up @@ -188,6 +189,9 @@ class AppConfig {

return decoded;
} on Object {
if (!AppSecrets.isDev) {
assert(false, 'AppConfig._d: value appears to be unencoded: $encoded');
}
// Return the raw string as fallback if it's not actually base64
// This allows migration and reduces breakage for unencoded dev strings
final fallback = encoded.trim();
Expand Down
112 changes: 47 additions & 65 deletions mobile/lib/logic/attendance_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,74 +93,14 @@ String normalizeDate(dynamic date) {

// 2. Dash-separated (YYYY-MM-DD or DD-MM-YYYY)
if (base.contains('-')) {
final parts = base.split('-');
if (parts.length == 3) {
final a = parts[0].trim();
final b = parts[1].trim();
final c = parts[2].trim();

if (RegExp(r'^\d+$').hasMatch(a) &&
RegExp(r'^\d+$').hasMatch(b) &&
RegExp(r'^\d+$').hasMatch(c)) {
var year = (a.length == 4) ? int.parse(a) : int.parse(c);
if (year < 100) year += 2000;
final month = int.parse(b);
final day = (a.length == 4) ? int.parse(c) : int.parse(a);

if (month < 1 || month > 12 || day < 1 || day > 31) return '';

final parsed = DateTime.tryParse(
"${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}",
);
if (parsed == null ||
parsed.year != year ||
parsed.month != month ||
parsed.day != day) {
return '';
}

final yearStr = year.toString().padLeft(4, '0');
final monthStr = month.toString().padLeft(2, '0');
final dayStr = day.toString().padLeft(2, '0');
return '$yearStr$monthStr$dayStr';
}
}
final parsedDate = _parseSeparatedDate(base, '-');
if (parsedDate != null) return parsedDate;
}

// 3. Slash-separated (DD/MM/YYYY or YYYY/MM/DD)
if (base.contains('/')) {
final parts = base.split('/');
if (parts.length == 3) {
final a = parts[0].trim();
final b = parts[1].trim();
final c = parts[2].trim();

if (RegExp(r'^\d+$').hasMatch(a) &&
RegExp(r'^\d+$').hasMatch(b) &&
RegExp(r'^\d+$').hasMatch(c)) {
var year = (a.length == 4) ? int.parse(a) : int.parse(c);
if (year < 100) year += 2000;
final month = int.parse(b);
final day = (a.length == 4) ? int.parse(c) : int.parse(a);

if (month < 1 || month > 12 || day < 1 || day > 31) return '';

final parsed = DateTime.tryParse(
"${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}",
);
if (parsed == null ||
parsed.year != year ||
parsed.month != month ||
parsed.day != day) {
return '';
}

final yearStr = year.toString().padLeft(4, '0');
final monthStr = month.toString().padLeft(2, '0');
final dayStr = day.toString().padLeft(2, '0');
return '$yearStr$monthStr$dayStr';
}
}
final parsedDate = _parseSeparatedDate(base, '/');
if (parsedDate != null) return parsedDate;
}

AppLogger.e(
Expand All @@ -170,6 +110,43 @@ String normalizeDate(dynamic date) {
return '';
}

String? _parseSeparatedDate(String base, String sep) {
final parts = base.split(sep);
if (parts.length != 3) return null;

final a = parts[0].trim();
final b = parts[1].trim();
final c = parts[2].trim();

if (!RegExp(r'^\d+$').hasMatch(a) ||
!RegExp(r'^\d+$').hasMatch(b) ||
!RegExp(r'^\d+$').hasMatch(c)) {
return null;
}

var year = (a.length == 4) ? int.parse(a) : int.parse(c);
if (year < 100) year += 2000;
final month = int.parse(b);
final day = (a.length == 4) ? int.parse(c) : int.parse(a);

if (month < 1 || month > 12 || day < 1 || day > 31) return '';

final parsed = DateTime.tryParse(
"${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}",
);
if (parsed == null ||
parsed.year != year ||
parsed.month != month ||
parsed.day != day) {
return '';
}

final yearStr = year.toString().padLeft(4, '0');
final monthStr = month.toString().padLeft(2, '0');
final dayStr = day.toString().padLeft(2, '0');
return '$yearStr$monthStr$dayStr';
}

/// Normalizes session identifiers (e.g., "1st Hour", "Session I") to a numeric string.
String normalizeSession(dynamic session) {
if (session == null) return '';
Expand Down Expand Up @@ -386,7 +363,12 @@ bool isValidCourseName(String text) {
}

String standardizeCourseCode(String input) {
return input.trim().toUpperCase().replaceAll(RegExp(r'[\s\u00A0-]'), '');
return input
.trim()
.toUpperCase()
.replaceAll(RegExp(r'\s'), '')
.replaceAll('\u00A0', '')
.replaceAll('-', '');
}

const Set<String> remarkPlaceholders = {
Expand Down
5 changes: 3 additions & 2 deletions mobile/lib/logic/encrypted_value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ class EncryptedValue {
/// Overwrite entropy buffers to reduce risk of key reconstruction after logout.
/// Call this when the app is performing a full logout or memory wipe.
static void clearEntropy() {
final random = Random.secure();
for (var i = 0; i < _entropyA.length; i++) {
_entropyA[i] = 0;
_entropyA[i] = random.nextInt(256);
}
for (var i = 0; i < _entropyB.length; i++) {
_entropyB[i] = 0;
_entropyB[i] = random.nextInt(256);
}
}

Expand Down
18 changes: 16 additions & 2 deletions mobile/lib/logic/error_handler.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -53,7 +55,13 @@ mixin ErrorHandlerMixin<T extends StatefulWidget> on State<T> {
message: dialogMessage,
technicalDetails: error.message,
retryLabel: isCritical ? 'Close App' : 'Restart App',
onRetry: SystemNavigator.pop,
onRetry: () async {
if (Platform.isAndroid) {
await SystemNavigator.pop();
} else {
exit(0);
}
},
);
return;
}
Expand All @@ -78,7 +86,13 @@ mixin ErrorHandlerMixin<T extends StatefulWidget> on State<T> {
technicalDetails:
'${error.type.name.toUpperCase()}: ${error.message}',
retryLabel: 'Restart App',
onRetry: SystemNavigator.pop,
onRetry: () async {
if (Platform.isAndroid) {
await SystemNavigator.pop();
} else {
exit(0);
}
},
);
return;
}
Expand Down
30 changes: 20 additions & 10 deletions mobile/lib/logic/ezygo_batch_fetcher.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:ghostclass/logic/app_exception.dart';
import 'package:ghostclass/services/logger.dart';
Expand Down Expand Up @@ -33,20 +34,26 @@ class EzygoBatchFetcher {
static const Duration _cacheTtl = Duration(seconds: 60);

// In-flight request map for deduplication
static final Map<String, Future<Response<dynamic>>> _inFlight = {};
final Map<String, Future<Response<dynamic>>> _inFlight = {};

// Rate limiting (parity with Next.js MAX_CONCURRENT = 3)
static const int _maxConcurrent = 3;
static int _activeRequests = 0;
static final List<Completer<void>> _queue = [];
int _activeRequests = 0;
final List<Completer<void>> _queue = [];

// Local result cache
static final Map<String, _CacheEntry> _cache = {};
final Map<String, _CacheEntry> _cache = {};

// Tracker for log throttling
static DateTime? _lastCircuitBreakerLog;
DateTime? _lastCircuitBreakerLog;

static int _generation = 0;
int _generation = 0;

String _hashToken(String token) {
if (token.isEmpty) return '';
final bytes = utf8.encode(token);
return sha256.convert(bytes).toString();
}

/// Executes an authenticated request with deduplication and caching.
///
Expand All @@ -63,7 +70,8 @@ class EzygoBatchFetcher {
// Generate a unique cache key based on the request identity
// We include method, path, token, and a hash of the body data to avoid collisions.
final dataKey = data != null ? _encodeStableRequestData(data) : '';
final cacheKey = '$method|$path|$token|$dataKey';
final hashedToken = _hashToken(token);
final cacheKey = '$method|$path|$hashedToken|$dataKey';
final startGeneration = _generation;

// 0. Security Barrier: If the backend connection is compromised, block immediately.
Expand Down Expand Up @@ -233,7 +241,7 @@ class EzygoBatchFetcher {
}

void _releaseSlot() {
_activeRequests--;
_activeRequests = (_activeRequests - 1).clamp(0, _maxConcurrent);
if (_queue.isNotEmpty) {
_activeRequests++;
_queue.removeAt(0).complete();
Expand Down Expand Up @@ -267,10 +275,12 @@ class EzygoBatchFetcher {
}

/// Manually clears the local cache (e.g. on logout or manual refresh).
void clearAll() {
void clearAll({bool setOutageState = true}) {
_cache.clear();
_inFlight.clear();
_setOutage(false);
if (setOutageState) {
_setOutage(false);
}
_generation++;

// Reject all pending completers in the queue with a cancellation error
Expand Down
Loading
Loading