dubs-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +280 -0
- package/CLAUDE.md +46 -0
- package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
- package/CURRENT_SESSION.md +171 -0
- package/CURRENT_SESSION_DRAW.md +516 -0
- package/MARCH_MADNESS_SURVIVOR.md +254 -0
- package/PANDA.md +166 -0
- package/Procfile +4 -0
- package/README.md +476 -0
- package/controllers/livescoresController.js +376 -0
- package/controllers/pickemController.js +554 -0
- package/controllers/survivorAdminController.js +887 -0
- package/controllers/survivorController.js +623 -0
- package/cron/oracleMonitor.js +77 -0
- package/cron/pickemOracleMonitor.js +73 -0
- package/data/jackpot-history.json +952 -0
- package/data/ncaaTeams.js +406 -0
- package/documentation/API_SECURITY_GUIDE.md +327 -0
- package/documentation/ARCADE_API.md +593 -0
- package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
- package/documentation/ARCADE_QUICKSTART.md +242 -0
- package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
- package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
- package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
- package/documentation/CLAIM_STATUS_FIX.md +67 -0
- package/documentation/CLI_TOOL_GUIDE.md +372 -0
- package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
- package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
- package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
- package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
- package/documentation/DATABASE_QUERIES.md +269 -0
- package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
- package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
- package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
- package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
- package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
- package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
- package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
- package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
- package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
- package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
- package/documentation/HEROKU_DEPLOYMENT.md +134 -0
- package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
- package/documentation/JACKPOT_API.md +521 -0
- package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
- package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
- package/documentation/JWT_QUICK_SETUP.md +268 -0
- package/documentation/JWT_TESTING_GUIDE.md +404 -0
- package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
- package/documentation/KEEPER_SETUP.md +206 -0
- package/documentation/KEEPER_STATE_MACHINE.md +423 -0
- package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
- package/documentation/LOCAL_VOTING_TEST.md +279 -0
- package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
- package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
- package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
- package/documentation/PNL_TRACKING_SETUP.md +189 -0
- package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
- package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
- package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
- package/documentation/QUICK_AUTH_SETUP.md +99 -0
- package/documentation/QUICK_DEPLOY.md +224 -0
- package/documentation/QUICK_FIX.md +114 -0
- package/documentation/QUICK_START.md +152 -0
- package/documentation/REFEREE_MODE_GUIDE.md +392 -0
- package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
- package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
- package/documentation/RUN_MIGRATION_NOW.md +39 -0
- package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
- package/documentation/SETUP_GUIDE.md +184 -0
- package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
- package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
- package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
- package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
- package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
- package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
- package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
- package/ecosystem.config.js +65 -0
- package/env.template +125 -0
- package/middleware/apiKeyAuth.js +136 -0
- package/middleware/authenticate.js +214 -0
- package/middleware/developerUserAuth.js +76 -0
- package/middleware/socketAuth.js +69 -0
- package/package.json +49 -0
- package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
- package/postman/Dubs-API-v1.postman_collection.json +205 -0
- package/postman/Dubs_Developer_API.postman_collection.json +662 -0
- package/postman/QUICKSTART.md +118 -0
- package/postman/QUICK_REFERENCE.md +246 -0
- package/postman/README.md +71 -0
- package/postman/VOTING_API_GUIDE.md +426 -0
- package/refactor/Animations.md +148 -0
- package/refactor/Chat.md +252 -0
- package/routes/actionsRoutes.js +699 -0
- package/routes/adminRoutes.js +370 -0
- package/routes/analyticsRoutes.js +1262 -0
- package/routes/arcadeRoutes.js +557 -0
- package/routes/authRoutes.js +2310 -0
- package/routes/avatarRoutes.js +85 -0
- package/routes/botRoutes.js +211 -0
- package/routes/chatRoutes.js +377 -0
- package/routes/cryptoPriceRoutes.js +105 -0
- package/routes/developerRoutes.js +4201 -0
- package/routes/deviceRoutes.js +214 -0
- package/routes/dmRoutes.js +167 -0
- package/routes/esportsRoutes.js +806 -0
- package/routes/exchangeRateRoutes.js +233 -0
- package/routes/gamesRoutes.js +3028 -0
- package/routes/jackpotRoutes.js +754 -0
- package/routes/keeperMonitoringRoutes.js +156 -0
- package/routes/keeperWebhookRoutes.js +466 -0
- package/routes/livescoresRoutes.js +31 -0
- package/routes/pickemAdminRoutes.js +199 -0
- package/routes/pickemRoutes.js +231 -0
- package/routes/playerStatsRoutes.js +147 -0
- package/routes/portfolioRoutes.js +217 -0
- package/routes/promoRoutes.js +418 -0
- package/routes/referralEarningsRoutes.js +392 -0
- package/routes/socialRoutes.js +459 -0
- package/routes/sportsRoutes.js +1271 -0
- package/routes/survivorAdminRoutes.js +345 -0
- package/routes/survivorRoutes.js +756 -0
- package/routes/uploadRoutes.js +256 -0
- package/routes/userProfileRoutes.js +244 -0
- package/routes/whatsNewRoutes.js +331 -0
- package/scripts/.claude/settings.local.json +15 -0
- package/scripts/README.md +170 -0
- package/scripts/RESTART_EVERYTHING.sh +104 -0
- package/scripts/add-claim-columns.sql +48 -0
- package/scripts/add-crypto-prices-cache.sql +27 -0
- package/scripts/add-exchange-rates-cache.sql +40 -0
- package/scripts/add-game-invite-column.sql +23 -0
- package/scripts/add-game-invite-notification.sql +33 -0
- package/scripts/add-game-invite-telegram-pref.sql +16 -0
- package/scripts/add-game-joined-notification.sql +16 -0
- package/scripts/add-game-joined-pref.js +40 -0
- package/scripts/add-game-joined-preference.sql +6 -0
- package/scripts/add-game-start-notifications.sql +41 -0
- package/scripts/add-notification-flags-to-games.sql +55 -0
- package/scripts/add-pending-game-dismissals.sql +19 -0
- package/scripts/add-preferred-currency.sql +34 -0
- package/scripts/add-winner-columns.js +61 -0
- package/scripts/add_mention_system.sql +53 -0
- package/scripts/add_payment_system.sql +96 -0
- package/scripts/add_sports_event_id_column.sql +22 -0
- package/scripts/analyze-cohort-data-heroku.js +276 -0
- package/scripts/analyze-cohort-data.js +295 -0
- package/scripts/analyze-prod-cohorts.sh +10 -0
- package/scripts/backfill-matchup-images.js +245 -0
- package/scripts/backfill-missing-signatures.js +175 -0
- package/scripts/backfill-referral-earnings.js +202 -0
- package/scripts/check-chat-schema.js +130 -0
- package/scripts/check-db.sh +14 -0
- package/scripts/check_oracle_in_game.js +54 -0
- package/scripts/cleanup-database.js +193 -0
- package/scripts/clear-notification-cache.js +85 -0
- package/scripts/convert-mnemonic.js +50 -0
- package/scripts/create-users-table.sql +44 -0
- package/scripts/debug-cohort-counts.js +248 -0
- package/scripts/debug-winner-calc.js +84 -0
- package/scripts/deploy-payment-system.sh +118 -0
- package/scripts/deploy-to-heroku.sh +63 -0
- package/scripts/diagnose-locked-round.js +143 -0
- package/scripts/dubs-cli.js +720 -0
- package/scripts/dump-account.js +65 -0
- package/scripts/find-vrf-offset.js +48 -0
- package/scripts/fix-chat-notifications-constraint.sql +122 -0
- package/scripts/fix-claim-columns.js +124 -0
- package/scripts/fix-constraint-now.js +44 -0
- package/scripts/fix-lock-timestamps.js +96 -0
- package/scripts/fix-locked-round.sh +126 -0
- package/scripts/fix-missing-badges.sql +91 -0
- package/scripts/fix-payment-notifications.sql +41 -0
- package/scripts/force-new-round.js +55 -0
- package/scripts/force-resolve-and-claim.js +278 -0
- package/scripts/important/README.md +115 -0
- package/scripts/important/authority-force-lock.js +197 -0
- package/scripts/important/authority-resolve-game.js +267 -0
- package/scripts/important/check-game-status.js +373 -0
- package/scripts/important/list-pending-games-by-version.js +270 -0
- package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
- package/scripts/initialize-jackpot.js +111 -0
- package/scripts/jackpot/.claude/settings.local.json +10 -0
- package/scripts/jackpot/force-reset.js +84 -0
- package/scripts/jackpot/initialize-mainnet.js +100 -0
- package/scripts/jackpot/keeper.js +742 -0
- package/scripts/jackpot/status.js +107 -0
- package/scripts/jackpot/update-round-duration.js +143 -0
- package/scripts/keeper-bot.js +112 -0
- package/scripts/list-pending-games.js +131 -0
- package/scripts/migrate-chat-v2.js +127 -0
- package/scripts/migrate-chat-winners.js +84 -0
- package/scripts/migrate-chat.sh +17 -0
- package/scripts/migrate-game-invite.js +83 -0
- package/scripts/migrate-heroku-game-notifications.sh +159 -0
- package/scripts/migrations/001_analytics_tables.sql +422 -0
- package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
- package/scripts/migrations/003_referral_earnings.sql +208 -0
- package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
- package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
- package/scripts/migrations/005_push_notifications.sql +55 -0
- package/scripts/migrations/006_add_draw_team_players.sql +28 -0
- package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
- package/scripts/migrations/007_add_gif_url.sql +8 -0
- package/scripts/migrations/008_add_connect4_columns.sql +139 -0
- package/scripts/migrations/008_add_pool_tracking.sql +22 -0
- package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
- package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
- package/scripts/migrations/011_create_developer_tables.sql +67 -0
- package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
- package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
- package/scripts/migrations/013_add_network_mode.sql +18 -0
- package/scripts/migrations/014_create_developer_app_users.sql +19 -0
- package/scripts/migrations/015_add_ui_config.sql +4 -0
- package/scripts/migrations/016_add_resolution_secret.sql +4 -0
- package/scripts/migrations/017_add_external_game_id.sql +3 -0
- package/scripts/migrations/018_create_pickem_tables.sql +115 -0
- package/scripts/migrations/019_expo_push_tokens.sql +19 -0
- package/scripts/migrations/create_whats_new_tables.sql +88 -0
- package/scripts/migrations/drop_live_games_tables.sql +34 -0
- package/scripts/open-jackpot-round.js +85 -0
- package/scripts/purge-all-data.sh +329 -0
- package/scripts/purge-all-data.sql +142 -0
- package/scripts/purge-heroku-data.sh +149 -0
- package/scripts/purge-heroku-data.sql +62 -0
- package/scripts/rebuild-heroku-database.sh +113 -0
- package/scripts/recover-funds.js +357 -0
- package/scripts/regenerate-epl-images.js +278 -0
- package/scripts/resize-s3-matchup-images.js +374 -0
- package/scripts/resolve-direct.js +88 -0
- package/scripts/resolve-mock-game.js +124 -0
- package/scripts/resolve-pickem-game.js +55 -0
- package/scripts/resolve-round-manual.js +83 -0
- package/scripts/resolve-stuck-game.js +382 -0
- package/scripts/resolve-stuck-round.js +42 -0
- package/scripts/run-connect4-migration.sh +16 -0
- package/scripts/run-mention-migration.sh +32 -0
- package/scripts/run-payment-migration.sh +51 -0
- package/scripts/run-preferred-currency-migration.sh +31 -0
- package/scripts/run-referral-earnings-migration.sh +32 -0
- package/scripts/run-survivor-outcome-migration.sh +16 -0
- package/scripts/seed-test-users.js +346 -0
- package/scripts/setup-auth-tables.js +78 -0
- package/scripts/setup-complete-database.sql +992 -0
- package/scripts/setup-database-fresh.sh +359 -0
- package/scripts/setup-heroku-keeper.sh +48 -0
- package/scripts/setup-keeper-database.js +83 -0
- package/scripts/setup-keeper-state-db.sql +110 -0
- package/scripts/setup-oracle.sh +39 -0
- package/scripts/setup-pnl-tracking.js +111 -0
- package/scripts/start-devnet.sh +14 -0
- package/scripts/test-arcade-devnet.sh +160 -0
- package/scripts/test-arcade-match.sh +109 -0
- package/scripts/test-automatic-mode.sh +239 -0
- package/scripts/test-connect4-cancel-claim.js +370 -0
- package/scripts/test-connect4-e2e.js +369 -0
- package/scripts/test-connect4-resolve.js +369 -0
- package/scripts/test-game-state-endpoint.js +136 -0
- package/scripts/test-invite-notification.js +86 -0
- package/scripts/test-jackpot-api.sh +71 -0
- package/scripts/test-poll-confirmation.js +267 -0
- package/scripts/test-resolve-game.js +271 -0
- package/scripts/test-resolve-signature.js +223 -0
- package/scripts/test-signature-preservation.js +124 -0
- package/scripts/test-state-machine.js +291 -0
- package/scripts/test-webhook-receiver.js +60 -0
- package/scripts/update-notification-constraint.js +52 -0
- package/scripts/verify-account-layout.js +145 -0
- package/scripts/verify-winner-algorithm.js +278 -0
- package/server.js +5259 -0
- package/services/arcadeMatchService.js +763 -0
- package/services/automaticGameOracle.js +1596 -0
- package/services/chatService.js +1612 -0
- package/services/connect4GameService.js +1049 -0
- package/services/connect4NotificationService.js +374 -0
- package/services/cryptoPriceService.js +223 -0
- package/services/customGameResolver.js +260 -0
- package/services/db.js +79 -0
- package/services/directMessageService.js +389 -0
- package/services/discordNotifications.js +160 -0
- package/services/exchangeRateService.js +289 -0
- package/services/expoPushService.js +314 -0
- package/services/gamesCacheService.js +539 -0
- package/services/jackpotHistory.js +331 -0
- package/services/jackpotService.js +856 -0
- package/services/keeperStateService.js +355 -0
- package/services/matchupImageService.js +591 -0
- package/services/notificationCacheService.js +407 -0
- package/services/pickemOracle.js +440 -0
- package/services/playerStatsService.js +389 -0
- package/services/portfolioService.js +555 -0
- package/services/promoService.js +757 -0
- package/services/promoTreasuryService.js +239 -0
- package/services/pushNotifications.js +353 -0
- package/services/redisService.js +422 -0
- package/services/referralEarningsService.js +728 -0
- package/services/s3Service.js +396 -0
- package/services/socialService.js +1202 -0
- package/services/survivorOracle.js +469 -0
- package/services/survivorSimulator.js +475 -0
- package/services/telegramNotifications.js +461 -0
- package/services/userProfileStatsService.js +1185 -0
- package/services/whatsNewService.js +388 -0
- package/utils/urlHelper.js +95 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 📸 S3 Service for Avatar and Matchup Image Uploads
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { S3Client, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
|
6
|
+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
|
7
|
+
const sharp = require('sharp');
|
|
8
|
+
|
|
9
|
+
class S3Service {
|
|
10
|
+
constructor() {
|
|
11
|
+
// Check for AWS credentials
|
|
12
|
+
this.credentialsAvailable = !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY);
|
|
13
|
+
|
|
14
|
+
if (!this.credentialsAvailable) {
|
|
15
|
+
console.warn('⚠️ AWS credentials not set - uploads will fail');
|
|
16
|
+
console.warn(' Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to .env');
|
|
17
|
+
this.client = null;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Region must match bucket location: dev in us-east-1, prod in us-east-2
|
|
22
|
+
this.region = process.env.NODE_ENV === 'production'
|
|
23
|
+
? 'us-east-2'
|
|
24
|
+
: 'us-east-1';
|
|
25
|
+
|
|
26
|
+
// Bucket configuration based on environment
|
|
27
|
+
// Avatars bucket (existing)
|
|
28
|
+
this.bucketName = process.env.NODE_ENV === 'production'
|
|
29
|
+
? 'dubs-avatars-prod'
|
|
30
|
+
: 'dubs-avatars-dev';
|
|
31
|
+
|
|
32
|
+
// What's New bucket (dedicated for feature announcements)
|
|
33
|
+
this.whatsNewBucketName = process.env.NODE_ENV === 'production'
|
|
34
|
+
? 'dubs-whats-new-prod'
|
|
35
|
+
: 'dubs-whats-new-dev';
|
|
36
|
+
|
|
37
|
+
// S3 Client must use the same region as the bucket
|
|
38
|
+
this.client = new S3Client({
|
|
39
|
+
region: this.region,
|
|
40
|
+
credentials: {
|
|
41
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
42
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
console.log(`📸 S3 Service initialized:`);
|
|
47
|
+
console.log(` - Avatars: ${this.bucketName} (${this.region})`);
|
|
48
|
+
console.log(` - What's New: ${this.whatsNewBucketName} (${this.region})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate presigned URL for direct browser upload
|
|
53
|
+
*/
|
|
54
|
+
async getUploadUrl(walletAddress, fileExtension) {
|
|
55
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
56
|
+
throw new Error('AWS credentials not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env file');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fileName = `${walletAddress}-${Date.now()}.${fileExtension}`;
|
|
60
|
+
const key = `avatars/${fileName}`;
|
|
61
|
+
|
|
62
|
+
const command = new PutObjectCommand({
|
|
63
|
+
Bucket: this.bucketName,
|
|
64
|
+
Key: key,
|
|
65
|
+
ContentType: this.getContentType(fileExtension),
|
|
66
|
+
// Public read access handled by bucket policy
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Generate presigned URL valid for 5 minutes
|
|
70
|
+
const uploadUrl = await getSignedUrl(this.client, command, { expiresIn: 300 });
|
|
71
|
+
|
|
72
|
+
// Public URL for accessing the file
|
|
73
|
+
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
uploadUrl,
|
|
77
|
+
publicUrl,
|
|
78
|
+
key,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get content type based on file extension
|
|
84
|
+
*/
|
|
85
|
+
getContentType(extension) {
|
|
86
|
+
const types = {
|
|
87
|
+
'jpg': 'image/jpeg',
|
|
88
|
+
'jpeg': 'image/jpeg',
|
|
89
|
+
'png': 'image/png',
|
|
90
|
+
'gif': 'image/gif',
|
|
91
|
+
'webp': 'image/webp',
|
|
92
|
+
};
|
|
93
|
+
return types[extension.toLowerCase()] || 'application/octet-stream';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate file extension
|
|
98
|
+
*/
|
|
99
|
+
isValidFileType(extension) {
|
|
100
|
+
const allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
101
|
+
return allowed.includes(extension.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Normalize team name for use in file keys
|
|
106
|
+
* Converts to lowercase, replaces spaces with underscores, removes special chars
|
|
107
|
+
*/
|
|
108
|
+
normalizeTeamName(teamName) {
|
|
109
|
+
return teamName
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.trim()
|
|
112
|
+
.replace(/[^a-z0-9\s]/g, '') // Remove special characters
|
|
113
|
+
.replace(/\s+/g, '_') // Replace spaces with underscores
|
|
114
|
+
.replace(/_+/g, '_') // Collapse multiple underscores
|
|
115
|
+
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a team-based key for matchup images
|
|
120
|
+
* Format: matchups/{league}/{away_team}_{home_team}.jpg
|
|
121
|
+
* This allows reusing the same image for all games with the same teams
|
|
122
|
+
*/
|
|
123
|
+
getMatchupImageKey(awayTeam, homeTeam, league) {
|
|
124
|
+
const normalizedAway = this.normalizeTeamName(awayTeam);
|
|
125
|
+
const normalizedHome = this.normalizeTeamName(homeTeam);
|
|
126
|
+
const normalizedLeague = league.toUpperCase();
|
|
127
|
+
return `matchups/${normalizedLeague}/${normalizedAway}_${normalizedHome}.jpg`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if a matchup image already exists in S3
|
|
132
|
+
*/
|
|
133
|
+
async matchupImageExists(key) {
|
|
134
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const command = new HeadObjectCommand({
|
|
140
|
+
Bucket: this.bucketName,
|
|
141
|
+
Key: key,
|
|
142
|
+
});
|
|
143
|
+
await this.client.send(command);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
// For other errors, log but assume it doesn't exist
|
|
150
|
+
console.warn(`[S3] Error checking if image exists: ${err.message}`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Upload a matchup image directly from buffer
|
|
157
|
+
* Used for server-side generated matchup images
|
|
158
|
+
* Optimizes image size by resizing to half dimensions and converting to JPEG for better compression
|
|
159
|
+
* Uses team-based keys to allow image reuse across games with the same teams
|
|
160
|
+
*
|
|
161
|
+
* @param {string} awayTeam - Away team name
|
|
162
|
+
* @param {string} homeTeam - Home team name
|
|
163
|
+
* @param {string} league - League abbreviation (NHL, NBA, NFL, MLB)
|
|
164
|
+
* @param {Buffer} imageBuffer - PNG image buffer (from canvas)
|
|
165
|
+
* @returns {Promise<{publicUrl: string, key: string, wasReused: boolean}>}
|
|
166
|
+
*/
|
|
167
|
+
async uploadMatchupImage(awayTeam, homeTeam, league, imageBuffer, forceOverwrite = false) {
|
|
168
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
169
|
+
throw new Error('AWS credentials not configured');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Generate team-based key (reusable across all games with same teams)
|
|
173
|
+
const key = this.getMatchupImageKey(awayTeam, homeTeam, league);
|
|
174
|
+
|
|
175
|
+
// Check if image already exists (skip check if forceOverwrite is true)
|
|
176
|
+
if (!forceOverwrite) {
|
|
177
|
+
const exists = await this.matchupImageExists(key);
|
|
178
|
+
if (exists) {
|
|
179
|
+
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
|
180
|
+
console.log(`[S3] ♻️ Reusing existing matchup image: ${key}`);
|
|
181
|
+
return {
|
|
182
|
+
publicUrl,
|
|
183
|
+
key,
|
|
184
|
+
wasReused: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
console.log(`[S3] 🔄 Force overwriting matchup image: ${key}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(`[S3] Optimizing matchup image: ${key} (original: ${imageBuffer.length} bytes)`);
|
|
192
|
+
|
|
193
|
+
// Optimize image: convert PNG to JPEG without resizing (maintain full quality)
|
|
194
|
+
let optimizedBuffer;
|
|
195
|
+
try {
|
|
196
|
+
// Get original dimensions
|
|
197
|
+
const metadata = await sharp(imageBuffer).metadata();
|
|
198
|
+
const originalWidth = metadata.width;
|
|
199
|
+
const originalHeight = metadata.height;
|
|
200
|
+
|
|
201
|
+
// Convert to JPEG without resizing (maintain full 600x315 quality)
|
|
202
|
+
optimizedBuffer = await sharp(imageBuffer)
|
|
203
|
+
.jpeg({
|
|
204
|
+
quality: 90, // High quality for crisp images
|
|
205
|
+
mozjpeg: true, // Use mozjpeg encoder for better compression
|
|
206
|
+
})
|
|
207
|
+
.toBuffer();
|
|
208
|
+
|
|
209
|
+
const sizeSaved = imageBuffer.length - optimizedBuffer.length;
|
|
210
|
+
const percentSaved = ((sizeSaved / imageBuffer.length) * 100).toFixed(1);
|
|
211
|
+
|
|
212
|
+
console.log(`[S3] Optimized: ${originalWidth}x${originalHeight} (PNG → JPEG, no resize)`);
|
|
213
|
+
console.log(`[S3] Size: ${imageBuffer.length} bytes → ${optimizedBuffer.length} bytes (saved ${percentSaved}%)`);
|
|
214
|
+
} catch (optimizeError) {
|
|
215
|
+
console.warn(`[S3] ⚠️ Image optimization failed, using original: ${optimizeError.message}`);
|
|
216
|
+
optimizedBuffer = imageBuffer; // Fallback to original if optimization fails
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const command = new PutObjectCommand({
|
|
220
|
+
Bucket: this.bucketName,
|
|
221
|
+
Key: key,
|
|
222
|
+
Body: optimizedBuffer,
|
|
223
|
+
ContentType: 'image/jpeg',
|
|
224
|
+
CacheControl: 'public, max-age=31536000', // Cache for 1 year (images don't change)
|
|
225
|
+
// NOTE: ACL won't work because bucket has "Object Ownership: Bucket owner enforced"
|
|
226
|
+
// Public access must be granted via bucket policy. Add this policy in AWS Console:
|
|
227
|
+
// {
|
|
228
|
+
// "Version": "2012-10-17",
|
|
229
|
+
// "Statement": [{
|
|
230
|
+
// "Sid": "PublicReadMatchups",
|
|
231
|
+
// "Effect": "Allow",
|
|
232
|
+
// "Principal": "*",
|
|
233
|
+
// "Action": "s3:GetObject",
|
|
234
|
+
// "Resource": "arn:aws:s3:::dubs-avatars-prod/matchups/*"
|
|
235
|
+
// }]
|
|
236
|
+
// }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await this.client.send(command);
|
|
240
|
+
|
|
241
|
+
// Add cache-busting timestamp to URL to ensure browsers fetch the new image
|
|
242
|
+
// This is important when overwriting existing images (e.g., EPL regeneration)
|
|
243
|
+
const cacheBuster = Date.now();
|
|
244
|
+
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}?v=${cacheBuster}`;
|
|
245
|
+
|
|
246
|
+
console.log(`[S3] ✅ Matchup image uploaded: ${publicUrl} (${optimizedBuffer.length} bytes)`);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
publicUrl,
|
|
250
|
+
key,
|
|
251
|
+
wasReused: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check if S3 credentials are available
|
|
257
|
+
*/
|
|
258
|
+
isConfigured() {
|
|
259
|
+
return this.credentialsAvailable && this.client !== null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get the S3 key for a profile OG image
|
|
264
|
+
* @param {string} username - Username
|
|
265
|
+
* @param {string} variant - 'default' or 'twitter'
|
|
266
|
+
*/
|
|
267
|
+
getProfileOGImageKey(username, variant = 'default') {
|
|
268
|
+
// Normalize username for safe file naming
|
|
269
|
+
const normalizedUsername = username
|
|
270
|
+
.toLowerCase()
|
|
271
|
+
.trim()
|
|
272
|
+
.replace(/[^a-z0-9_-]/g, '_');
|
|
273
|
+
const suffix = variant === 'twitter' ? '-twitter' : '';
|
|
274
|
+
return `og-profiles/${normalizedUsername}${suffix}.png`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get the public URL for a profile OG image
|
|
279
|
+
* @param {string} username - Username
|
|
280
|
+
* @param {string} variant - 'default' or 'twitter'
|
|
281
|
+
*/
|
|
282
|
+
getProfileOGImageUrl(username, variant = 'default') {
|
|
283
|
+
const key = this.getProfileOGImageKey(username, variant);
|
|
284
|
+
return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Check if a profile OG image exists in S3
|
|
289
|
+
* @param {string} username - Username
|
|
290
|
+
* @param {string} variant - 'default' or 'twitter'
|
|
291
|
+
*/
|
|
292
|
+
async profileOGImageExists(username, variant = 'default') {
|
|
293
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const key = this.getProfileOGImageKey(username, variant);
|
|
299
|
+
const command = new HeadObjectCommand({
|
|
300
|
+
Bucket: this.bucketName,
|
|
301
|
+
Key: key,
|
|
302
|
+
});
|
|
303
|
+
await this.client.send(command);
|
|
304
|
+
return true;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
console.warn(`[S3] Error checking if profile OG image exists: ${err.message}`);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Upload a profile OG image to S3
|
|
316
|
+
* Used for the beautiful ShareablePNLCard captured via html2canvas
|
|
317
|
+
*
|
|
318
|
+
* @param {string} username - Username for the profile
|
|
319
|
+
* @param {Buffer} imageBuffer - PNG image buffer
|
|
320
|
+
* @param {string} variant - 'default' or 'twitter' (1200x628 for Twitter cards)
|
|
321
|
+
* @returns {Promise<{publicUrl: string, key: string}>}
|
|
322
|
+
*/
|
|
323
|
+
async uploadProfileOGImage(username, imageBuffer, variant = 'default') {
|
|
324
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
325
|
+
throw new Error('AWS credentials not configured');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const key = this.getProfileOGImageKey(username, variant);
|
|
329
|
+
|
|
330
|
+
console.log(`[S3] Uploading profile OG image (${variant}): ${key} (${imageBuffer.length} bytes)`);
|
|
331
|
+
|
|
332
|
+
const command = new PutObjectCommand({
|
|
333
|
+
Bucket: this.bucketName,
|
|
334
|
+
Key: key,
|
|
335
|
+
Body: imageBuffer,
|
|
336
|
+
ContentType: 'image/png',
|
|
337
|
+
CacheControl: 'public, max-age=3600', // Cache for 1 hour (profiles change more often than matchups)
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await this.client.send(command);
|
|
341
|
+
|
|
342
|
+
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
|
343
|
+
|
|
344
|
+
console.log(`[S3] ✅ Profile OG image (${variant}) uploaded: ${publicUrl}`);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
publicUrl,
|
|
348
|
+
key,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Upload a What's New GIF to S3
|
|
354
|
+
* Uses dedicated whats-new bucket
|
|
355
|
+
*
|
|
356
|
+
* @param {Buffer} imageBuffer - GIF/image buffer
|
|
357
|
+
* @param {string} originalFilename - Original filename for extension detection
|
|
358
|
+
* @param {string} contentType - MIME type of the file
|
|
359
|
+
* @returns {Promise<{publicUrl: string, key: string}>}
|
|
360
|
+
*/
|
|
361
|
+
async uploadWhatsNewGif(imageBuffer, originalFilename, contentType) {
|
|
362
|
+
if (!this.credentialsAvailable || !this.client) {
|
|
363
|
+
throw new Error('AWS credentials not configured');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Generate unique filename
|
|
367
|
+
const extension = originalFilename.split('.').pop().toLowerCase() || 'gif';
|
|
368
|
+
const timestamp = Date.now();
|
|
369
|
+
const randomId = Math.random().toString(36).substring(2, 8);
|
|
370
|
+
const key = `${timestamp}-${randomId}.${extension}`;
|
|
371
|
+
|
|
372
|
+
console.log(`[S3] Uploading What's New GIF to ${this.whatsNewBucketName}: ${key} (${imageBuffer.length} bytes)`);
|
|
373
|
+
|
|
374
|
+
const command = new PutObjectCommand({
|
|
375
|
+
Bucket: this.whatsNewBucketName,
|
|
376
|
+
Key: key,
|
|
377
|
+
Body: imageBuffer,
|
|
378
|
+
ContentType: contentType || this.getContentType(extension),
|
|
379
|
+
CacheControl: 'public, max-age=31536000', // Cache for 1 year
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await this.client.send(command);
|
|
383
|
+
|
|
384
|
+
const publicUrl = `https://${this.whatsNewBucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
|
385
|
+
|
|
386
|
+
console.log(`[S3] ✅ What's New GIF uploaded: ${publicUrl}`);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
publicUrl,
|
|
390
|
+
key,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module.exports = S3Service;
|
|
396
|
+
|