diff --git a/backend/.env.example b/backend/.env.example index c774344..d56eed1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,6 +2,8 @@ NODE_ENV=development PORT=3000 +# Nginx reverse proxy hop count (express-rate-limit + req.ip). Do not set to true in code; use 1+. +# TRUST_PROXY_HOPS=1 # Database: sqlite (default) or postgres DB_DIALECT=sqlite @@ -21,9 +23,8 @@ SQLITE_STORAGE=./data/luckychit.sqlite JWT_SECRET=change-me-in-production -# Public draw result links (no login). Use your API origin WITHOUT /api — e.g. https://api.example.com -# Dev fallback: if unset and NODE_ENV!=production, defaults to http://localhost:PORT -PUBLIC_BASE_URL=http://localhost:3000 +# Public draw links (/share/draw/...): origin WITHOUT /api. Optional if Nginx sends Host + X-Forwarded-Proto. +# PUBLIC_BASE_URL=https://your-domain.com # Signed tokens for /share/draw/:token — defaults to permanent links (no JWT expiry) # Set to false to use SHARE_TOKEN_TTL_DAYS instead diff --git a/backend/src/controllers/chitGroupController.js b/backend/src/controllers/chitGroupController.js index 39eef01..1c307e0 100644 --- a/backend/src/controllers/chitGroupController.js +++ b/backend/src/controllers/chitGroupController.js @@ -616,8 +616,11 @@ const generateFinancialData = (chitGroup) => { const monthlyInstallment = chitGroup.monthly_installment; const commission = chitGroup.foreman_commission_amount; - // Use actual start_date if available, otherwise default to current date - const startDate = chitGroup.start_date ? new Date(chitGroup.start_date) : new Date(); + // Use actual start_date if valid; invalid/legacy DB values → today (avoids NaN → "undefined-N" labels). + let startDate = chitGroup.start_date ? new Date(chitGroup.start_date) : new Date(); + if (Number.isNaN(startDate.getTime())) { + startDate = new Date(); + } const startMonth = startDate.getMonth(); // 0-11 (0 = January) const startYear = startDate.getFullYear(); @@ -676,6 +679,9 @@ const getMonthName = (monthIndex) => { 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; + if (typeof monthIndex !== 'number' || Number.isNaN(monthIndex) || monthIndex < 0 || monthIndex > 11) { + return '?'; + } return months[monthIndex]; }; diff --git a/backend/src/routes/share.js b/backend/src/routes/share.js index fb35b06..6f0e786 100644 --- a/backend/src/routes/share.js +++ b/backend/src/routes/share.js @@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken'); const { authenticateToken: auth, requireManager } = require('../middleware/auth'); const { Payment, MonthlyDraw, ChitGroup, GroupMember, User, Notification } = require('../models'); const WhatsAppHelper = require('../utils/whatsapp-helper'); +const { resolvePublicBaseUrl } = require('../utils/public-base-url'); /** * @route POST /api/share/payment-receipt @@ -150,16 +151,12 @@ router.post('/draw-link', auth, requireManager, async (req, res) => { signOpts ); - let publicBaseUrl = (process.env.PUBLIC_BASE_URL || process.env.APP_BASE_URL || '').replace(/\/$/, ''); - if (!publicBaseUrl && process.env.NODE_ENV !== 'production') { - const p = process.env.PORT || 3000; - publicBaseUrl = `http://localhost:${p}`; - } + const publicBaseUrl = resolvePublicBaseUrl(req); if (!publicBaseUrl) { return res.status(500).json({ success: false, message: - 'PUBLIC_BASE_URL not configured (set to your public API origin, e.g. https://api.example.com)' + 'PUBLIC_BASE_URL not configured (set to your site origin without /api, e.g. https://chitfund.deepteklabs.com)' }); } const shareUrl = `${publicBaseUrl}/share/draw/${token}`; diff --git a/backend/src/server.js b/backend/src/server.js index 60b6243..5067ff7 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -24,6 +24,11 @@ const ReminderService = require('./services/reminder-service'); const app = express(); const PORT = process.env.PORT || 3000; +// Behind Nginx: required for correct req.ip, HTTPS, PUBLIC_BASE_URL inference, and express-rate-limit. +// Must be a number (hop count), not `true` — express-rate-limit rejects trust proxy === true. +const _trustHops = Number.parseInt(process.env.TRUST_PROXY_HOPS || '1', 10); +app.set('trust proxy', Number.isFinite(_trustHops) && _trustHops >= 1 ? _trustHops : 1); + // Security middleware app.use(helmet()); @@ -51,7 +56,7 @@ app.use(cors({ const limiter = rateLimit({ windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, // 15 minutes max: Number.parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 300, - message: 'Too many requests from this IP, please try again later.' + message: 'Too many requests from this IP, please try again later.', }); app.use('/api', limiter); diff --git a/backend/src/utils/public-base-url.js b/backend/src/utils/public-base-url.js new file mode 100644 index 0000000..cb600e9 --- /dev/null +++ b/backend/src/utils/public-base-url.js @@ -0,0 +1,35 @@ +/** + * Origin for public links served by this app (no trailing slash, no `/api`). + * Uses PUBLIC_BASE_URL / APP_BASE_URL when set; otherwise infers from proxy headers + * when Host is not loopback (so production works without env if Nginx sends standard headers). + */ +function resolvePublicBaseUrl(req) { + const envBase = (process.env.PUBLIC_BASE_URL || process.env.APP_BASE_URL || '') + .trim() + .replace(/\/$/, ''); + if (envBase) return envBase; + + const rawHost = (req.get('x-forwarded-host') || req.get('host') || '').split(',')[0].trim(); + const isLoopback = + !rawHost || + /^localhost(:\d+)?$/i.test(rawHost) || + /^127\.\d+\.\d+\.\d+(:\d+)?$/i.test(rawHost) || + /^\[::1\](:\d+)?$/i.test(rawHost); + + if (isLoopback) { + if (process.env.NODE_ENV === 'production') { + return ''; + } + const p = process.env.PORT || 3000; + return `http://localhost:${String(p)}`; + } + + let proto = (req.get('x-forwarded-proto') || '').split(',')[0].trim(); + if (!proto && req.secure) proto = 'https'; + if (!proto) proto = req.protocol || 'https'; + if (proto !== 'http' && proto !== 'https') proto = 'https'; + + return `${proto}://${rawHost}`.replace(/\/$/, ''); +} + +module.exports = { resolvePublicBaseUrl }; diff --git a/luckychit/lib/core/models/monthly_draw.dart b/luckychit/lib/core/models/monthly_draw.dart index 487772d..26befb0 100644 --- a/luckychit/lib/core/models/monthly_draw.dart +++ b/luckychit/lib/core/models/monthly_draw.dart @@ -1,5 +1,16 @@ import 'user.dart'; +DateTime? _parseDrawDate(dynamic value) { + if (value == null) return null; + final s = value.toString().trim(); + if (s.isEmpty) return null; + try { + return DateTime.parse(s); + } catch (_) { + return null; + } +} + class MonthlyDraw { final String id; final String groupId; @@ -42,25 +53,36 @@ class MonthlyDraw { }); factory MonthlyDraw.fromJson(Map json) { + final rawWinner = json['winner']; + Map? winnerMap; + if (rawWinner is Map) { + winnerMap = rawWinner; + } else if (rawWinner is Map) { + winnerMap = Map.from(rawWinner); + } + + final drawDate = + _parseDrawDate(json['draw_date'] ?? json['drawDate']) ?? DateTime.fromMillisecondsSinceEpoch(0); + return MonthlyDraw( - id: json['id'], - groupId: json['group_id'], + id: json['id']?.toString() ?? '', + groupId: (json['group_id'] ?? json['groupId'])?.toString() ?? '', month: _parseInt(json['month']), year: _parseInt(json['year']), - drawDate: DateTime.parse(json['draw_date']), - eligibleMembers: _parseEligibleMembers(json['eligible_members']), - winnerId: json['winner_id'], - prizeAmount: _parseDouble(json['prize_amount']), - serverSeed: json['server_seed'] ?? '', - serverSeedHash: json['server_seed_hash'] ?? '', - clientSeed: json['client_seed'] ?? '', + drawDate: drawDate, + eligibleMembers: _parseEligibleMembers(json['eligible_members'] ?? json['eligibleMembers']), + winnerId: (json['winner_id'] ?? json['winnerId'])?.toString(), + prizeAmount: _parseDouble(json['prize_amount'] ?? json['prizeAmount']), + serverSeed: (json['server_seed'] ?? json['serverSeed'] ?? '').toString(), + serverSeedHash: (json['server_seed_hash'] ?? json['serverSeedHash'] ?? '').toString(), + clientSeed: (json['client_seed'] ?? json['clientSeed'] ?? '').toString(), nonce: _parseInt(json['nonce']), - resultHash: json['result_hash'] ?? '', - status: json['status'], - notes: json['notes'], - createdAt: DateTime.parse(json['created_at']), - updatedAt: DateTime.parse(json['updated_at']), - winner: json['winner'] != null ? User.fromJson(json['winner']) : null, + resultHash: (json['result_hash'] ?? json['resultHash'] ?? '').toString(), + status: json['status']?.toString() ?? 'pending', + notes: json['notes']?.toString(), + createdAt: _parseDrawDate(json['created_at'] ?? json['createdAt']) ?? DateTime.now(), + updatedAt: _parseDrawDate(json['updated_at'] ?? json['updatedAt']) ?? DateTime.now(), + winner: winnerMap != null ? User.fromJson(winnerMap) : null, ); } diff --git a/luckychit/lib/core/services/chit_group_service.dart b/luckychit/lib/core/services/chit_group_service.dart index 494206d..79d7235 100644 --- a/luckychit/lib/core/services/chit_group_service.dart +++ b/luckychit/lib/core/services/chit_group_service.dart @@ -441,8 +441,14 @@ class ChitGroupService extends GetxController { final response = await _apiService.getGroupMonthlyDraws(groupId, status: status); if (response['success']) { - final drawsData = response['data']['monthlyDraws'] as List; - _monthlyDraws.value = drawsData.map((json) => MonthlyDraw.fromJson(json)).toList(); + final data = response['data'] as Map?; + final rawList = data?['monthlyDraws'] ?? data?['monthly_draws']; + final drawsData = rawList is List ? rawList : []; + _monthlyDraws.value = drawsData + .map((json) => MonthlyDraw.fromJson( + json is Map ? json : Map.from(json as Map), + )) + .toList(); } else { Get.snackbar('Error', response['message']); }