-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
481 lines (407 loc) · 21.7 KB
/
server.js
File metadata and controls
481 lines (407 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const axios = require('axios');
const cors = require('cors');
const path = require('path');
const http = require('http'); // Or https if needed
const fs = require('fs').promises; // Use promises API
const cheerio = require('cheerio'); // For parsing HTML on backend
const app = express();
const port = 3000;
// --- Twitch/IGDB Credentials ---
const TWITCH_CLIENT_ID = process.env.TWITCH_CLIENT_ID;
const TWITCH_CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET;
// GameGet-specific config
const SEARCH_LIMIT = 50; // Max search results to return
if (!TWITCH_CLIENT_ID || !TWITCH_CLIENT_SECRET) {
console.error("FATAL ERROR: TWITCH_CLIENT_ID or TWITCH_CLIENT_SECRET not found in .env file.");
process.exit(1);
}
// ... (Credentials check) ...
let twitchAccessToken = null;
let tokenExpiryTime = 0;
async function getTwitchAccessToken() {
const now = Date.now();
if (twitchAccessToken && now < tokenExpiryTime - 60000) {
return twitchAccessToken;
}
console.log("Fetching new Twitch Access Token...");
const tokenUrl = 'https://id.twitch.tv/oauth2/token';
const params = new URLSearchParams();
params.append('client_id', TWITCH_CLIENT_ID);
params.append('client_secret', TWITCH_CLIENT_SECRET);
params.append('grant_type', 'client_credentials');
try {
const response = await axios.post(tokenUrl, params);
twitchAccessToken = response.data.access_token;
tokenExpiryTime = now + (response.data.expires_in * 1000);
console.log("New Twitch token obtained.");
return twitchAccessToken;
} catch (error) {
console.error("Error fetching Twitch Access Token:", error.response ? error.response.data : error.message);
twitchAccessToken = null;
tokenExpiryTime = 0;
throw new Error('Could not authenticate with Twitch/IGDB.');
}
}
// --- Middleware ---
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json({ limit: '5mb' })); // Middleware to parse JSON bodies (increased limit for HTML)
// --- API Endpoint for Searching (returns list with year) ---
app.get('/search', async (req, res) => {
const gameQuery = req.query.gameName;
if (!gameQuery) {
return res.status(400).json({ error: 'Game name query parameter is required' });
}
try {
const accessToken = await getTwitchAccessToken();
const igdbUrl = 'https://api.igdb.com/v4/games';
// Query: Search, get ID, Name, and First Release Date, limit to results specified in SEARCH_LIMIT
// category for game-type 0:main_game, 1:dlc_addon, 2:expansion, 3:bundle, 4:standalone_expansion, 5:mod, 6:episode, 7:season, 8:remake, 9:remaster, 10:expanded_game, 11:port, 12:fork
const requestBody = `
search "${gameQuery.replace(/"/g, '\\"')}";
fields
id,
name,
first_release_date,
game_type,
version_parent,
platforms.name;
limit ${SEARCH_LIMIT};
`;
// Update console log message
console.log(`Querying IGDB for top ${SEARCH_LIMIT} list matching "${gameQuery}"...`);
const igdbResponse = await axios.post(igdbUrl, requestBody, {
headers: {
'Client-ID': TWITCH_CLIENT_ID,
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
},
timeout: 15000
});
if (igdbResponse.data && igdbResponse.data.length > 0) {
// Map results to include the release year (logic remains the same)
const resultsWithYear = igdbResponse.data.map(game => {
let releaseYear = 'N/A';
if (game.first_release_date) {
releaseYear = new Date(game.first_release_date * 1000).getFullYear().toString();
}
// Determine Game Type String
let gameType = 'Game'; // Default
switch (game.game_type) {
case 0: gameType = 'Main Game'; break;
case 1: gameType = 'DLC/Addon'; break; // Excluded by 'where' clause, but keep for reference
case 2: gameType = 'Expansion'; break;
case 3: gameType = 'Bundle'; break;
case 4: gameType = 'Standalone Expansion'; break;
case 5: gameType = 'Mod'; break;
case 6: gameType = 'Episode'; break;
case 7: gameType = 'Season'; break;
case 8: gameType = 'Remake'; break;
case 9: gameType = 'Remaster'; break;
case 10: gameType = 'Expanded Game'; break;
case 11: gameType = 'Port'; break;
case 12: gameType = 'Fork'; break;
default: // If unknown category or null
if (game.version_parent) { // Check if it's a version of something else
gameType = 'Version/Port'; // Generic label if parent exists but category unknown/main
} else {
gameType = 'Game'; // Default fallback
}
break;
}
// Extract Platform Names
let platformNames = [];
if (game.platforms && Array.isArray(game.platforms)) {
platformNames = game.platforms.map(p => p.name).filter(Boolean); // Get names
}
return {
id: game.id,
name: game.name || 'Unnamed Game',
year: releaseYear,
type: gameType, // Add the type string
platforms: platformNames // <--- Send array of names
};
});
// --- End of mapping ---
console.log(`Found ${resultsWithYear.length} potential matches (sending top ${SEARCH_LIMIT}).`);
res.json(resultsWithYear);
} else {
console.log(`No results found on IGDB for "${gameQuery}".`);
res.status(404).json({ error: `No games found matching "${gameQuery}" on IGDB.` });
}
} catch (error) {
// ... (error handling remains the same)
console.error(`Error searching IGDB list for "${gameQuery}":`, error.response ? JSON.stringify(error.response.data, null, 2) : error.message);
if (error.message === 'Could not authenticate with Twitch/IGDB.' || (error.response && (error.response.status === 401 || error.response.status === 403))) {
twitchAccessToken = null;
tokenExpiryTime = 0;
console.warn("IGDB Auth failed during search, clearing token.");
return res.status(error.response?.status || 503).json({ error: `IGDB Authentication error (${error.response?.status || 'Auth func failed'}). Please try again.` });
} else if (error.code === 'ECONNABORTED') {
console.error('IGDB search request timed out.');
return res.status(504).json({ error: 'IGDB API took too long to respond.' });
}
res.status(500).json({ error: 'Failed to search IGDB API.' });
}
});
// --- API Endpoint for getting details of a specific game ID ---
app.get('/details/:gameId', async (req, res) => {
const { gameId } = req.params;
if (!gameId || isNaN(parseInt(gameId))) { return res.status(400).json({ error: 'Valid game ID parameter is required.' }); }
const parsedGameId = parseInt(gameId);
try {
const accessToken = await getTwitchAccessToken();
const igdbUrl = 'https://api.igdb.com/v4/games';
// --- SIMPLIFIED Query: Fields needed for display + metadata ---
const requestBody = `
fields
name,
first_release_date,
cover.url,
platforms.name,
involved_companies.company.name, involved_companies.developer,
websites.url, websites.type;
where id = ${parsedGameId};
limit 1;
`;
// --- End Query ---
console.log(`Querying IGDB for details (ID: ${parsedGameId})...`);
const igdbResponse = await axios.post(igdbUrl, requestBody, {
headers: { 'Client-ID': TWITCH_CLIENT_ID, 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json' },
timeout: 10000
});
if (igdbResponse.data && igdbResponse.data.length > 0) {
const game = igdbResponse.data[0];
console.log("IGDB Raw Details Data:", game);
// Map IGDB data needed for Frontend Display & Metadata
const gameData = {
id: game.id, // Keep ID for metadata
name: game.name || 'N/A',
thumbnailUrl: null,
releaseTimestamp: game.first_release_date || null, // For metadata sorting
releaseDate: 'N/A', // Formatted string for display & metadata
developer: 'N/A',
platforms: [], // Array of names for display & metadata
igdbStoreLinks: [] // Array of {name, url} for display & metadata
};
// Map Cover URL
if (game.cover && game.cover.url) {
gameData.thumbnailUrl = game.cover.url.replace('t_thumb', 't_cover_big');
if (gameData.thumbnailUrl.startsWith('//')) { gameData.thumbnailUrl = 'https:' + gameData.thumbnailUrl; }
}
// Map Release Date (Formatted String)
if (game.first_release_date) {
gameData.releaseDate = new Date(game.first_release_date * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
}
// Map Developer
if (game.involved_companies && Array.isArray(game.involved_companies)) {
const devCompany = game.involved_companies.find(ic => ic.developer === true && ic.company?.name);
if (devCompany) gameData.developer = devCompany.company.name;
}
// Map Platforms (Names)
if (game.platforms && Array.isArray(game.platforms)) {
gameData.platforms = game.platforms.map(p => p.name).filter(Boolean);
}
// Map Store Links (Steam, Epic, GOG from IGDB)
if (game.websites && Array.isArray(game.websites)) {
game.websites.forEach(site => {
let storeName = null;
switch (site.type) { case 13: storeName = 'Steam'; break; case 15: storeName = 'Itch'; break; case 16: storeName = 'Epic Games'; break; case 17: storeName = 'GOG'; break; }
if (storeName && site.url) {
const urlLower = site.url.toLowerCase(); let isValid = false;
if (storeName === 'Steam' && urlLower.includes('store.steampowered.com/app/')) isValid = true;
else if (storeName === 'Itch' && (urlLower.includes('itch.io/') || urlLower.includes('itch'))) isValid = true;
else if (storeName === 'Epic Games' && (urlLower.includes('store.epicgames.com/') || urlLower.includes('www.epicgames.com/store/'))) isValid = true;
else if (storeName === 'GOG' && urlLower.includes('gog.com/')) isValid = true;
if (isValid) gameData.igdbStoreLinks.push({ name: storeName, url: site.url });
}
});
}
console.log("Mapped Game Details for Frontend:", gameData);
res.json(gameData);
} else {
console.log(`Game ID ${parsedGameId} not found on IGDB.`);
res.status(404).json({ error: `Game with ID ${parsedGameId} not found.` });
}
} catch (error) {
console.error(`Error getting IGDB details for ID ${parsedGameId}:`, error.response ? JSON.stringify(error.response.data, null, 2) : error.message);
// ... specific error handling (auth, timeout) ...
res.status(500).json({ error: 'Failed to get game details from IGDB API.' });
}
});
// --- API Endpoint to Save HTML to Collection ---
app.post('/api/saveToCollection', async (req, res) => {
const { filename, htmlContent } = req.body;
console.log(`API Save: Received filename: "${filename}"`); // Log received name
if (!filename || typeof filename !== 'string' || !htmlContent || typeof htmlContent !== 'string') {
return res.status(400).json({ error: 'Invalid filename or htmlContent provided.' });
}
// --- Refined Sanitization & Logging ---
let sanitizedFilename = filename;
// 1. Remove path traversal attempts FIRST
sanitizedFilename = sanitizedFilename.replace(/\.\.\//g, '').replace(/\.\.\\/g, ''); // Remove ../ and ..\ explicitly
console.log(`API Save: After traversal removal: "${sanitizedFilename}"`);
// 2. Extract basename AFTER initial cleanup
sanitizedFilename = path.basename(sanitizedFilename);
console.log(`API Save: After basename: "${sanitizedFilename}"`);
// 3. Replace remaining invalid characters (keep the dot for extension)
// Allow letters, numbers, underscore, hyphen, dot. Replace others with underscore.
sanitizedFilename = sanitizedFilename.replace(/[^a-z0-9_\-\.]/gi, '_');
console.log(`API Save: After char replace: "${sanitizedFilename}"`);
// 4. Replace multiple consecutive underscores/hyphens with single ones (optional cleanup)
sanitizedFilename = sanitizedFilename.replace(/[_]+/g, '_').replace(/[-]+/g, '-');
console.log(`API Save: After consecutive char cleanup: "${sanitizedFilename}"`);
// 5. Check extension AFTER all sanitization
if (!sanitizedFilename.toLowerCase().endsWith('.html')) {
console.error(`API Save: Sanitized filename "${sanitizedFilename}" does not end with .html`);
return res.status(400).json({ error: 'Invalid filename extension after sanitization.' });
}
// --- End Sanitization ---
const saveDir = path.join(__dirname, 'public', 'results');
const fullPath = path.join(saveDir, sanitizedFilename);
console.log(`API Save: Final path: ${fullPath}`);
try {
await fs.mkdir(saveDir, { recursive: true });
await fs.writeFile(fullPath, htmlContent, 'utf-8');
console.log(`API Save: Successfully saved ${sanitizedFilename}`);
res.status(201).json({ message: 'File saved successfully.' });
} catch (error) {
console.error(`API Save: Error saving file "${sanitizedFilename}":`, error);
res.status(500).json({ error: 'Failed to save file.' });
}
});
// --- End Save to Collection Endpoint ---
// --- API Endpoint to Scan Collection Folder ---
app.get('/api/getCollection', async (req, res) => {
const collectionPath = path.join(__dirname, 'public', 'results'); // Path to check
console.log(`API: Scanning collection folder: ${collectionPath}`);
try {
// Check if directory exists first
try {
await fs.access(collectionPath); // Check access/existence
} catch (dirError) {
if (dirError.code === 'ENOENT') {
console.warn(`Collection directory '${collectionPath}' not found. Creating it.`);
await fs.mkdir(collectionPath, { recursive: true }); // Create if missing
return res.json([]); // Return empty array if just created
} else {
throw dirError; // Re-throw other access errors
}
}
const files = await fs.readdir(collectionPath);
const htmlFiles = files.filter(file => file.toLowerCase().endsWith('.html'));
console.log(`API: Found ${htmlFiles.length} HTML files.`);
const collectionData = [];
// Process each HTML file
for (const filename of htmlFiles) { // Use for...of for simpler async loop
try {
const filePath = path.join(collectionPath, filename);
const fileContent = await fs.readFile(filePath, 'utf-8');
const $ = cheerio.load(fileContent); // Load HTML into cheerio
const metadata = {};
// Extract metadata using cheerio selectors
$('head meta[name^="gameget:"]').each((i, elem) => {
const nameAttr = $(elem).attr('name');
const content = $(elem).attr('content');
if (nameAttr && content !== undefined) {
const name = nameAttr.substring(8); // Remove "gameget:" prefix
// Type conversion based on expected name
if (content === '') { // Treat empty content as null for consistency
metadata[name] = null;
} else if (name === 'id' || name === 'releaseTimestamp' || name === 'igdbRating' || name === 'userRatingValue') {
metadata[name] = parseFloat(content);
if (isNaN(metadata[name])) metadata[name] = null; // Set to null if parsing failed
} else if (name === 'platforms' || name === 'genres') {
metadata[name] = content ? content.split(',').map(s => s.trim()).filter(Boolean) : []; // Split, trim, filter empty
} else if (name === 'storeLinks') {
try { metadata[name] = JSON.parse(content || '[]'); if (!Array.isArray(metadata[name])) metadata[name] = []; }
catch { metadata[name] = [];}
} else {
metadata[name] = content; // Keep as string (name, dev, date string, coverUrl, userRatingStyle, userPlatform)
}
}
});
// Add if valid
if (metadata.name && metadata.id) {
metadata.sourceFile = filename; // Add filename for reference
collectionData.push(metadata);
} else {
console.warn(`API: Skipping file ${filename}: Missing essential metadata (name or id). Content:`, content.substring(0, 200));
}
} catch (fileError) {
console.error(`API: Error processing file ${filename}:`, fileError.message);
}
} // End for...of loop
console.log(`API: Successfully parsed metadata for ${collectionData.length} files.`);
res.json(collectionData); // Send array of metadata objects
} catch (error) {
console.error("API: Error reading collection directory:", error);
res.status(500).json({ error: 'Failed to read collection data.' });
}
});
// --- API Endpoint to Copy a Card to Current ---
app.post('/api/setCurrentCard', async (req, res) => {
const { sourceFilename } = req.body;
// Basic validation
if (!sourceFilename || typeof sourceFilename !== 'string') {
return res.status(400).json({ error: 'Invalid sourceFilename provided.' });
}
// --- CRITICAL: Sanitize sourceFilename ---
// 1. Prevent directory traversal (remove ../ / \)
let safeBaseName = sourceFilename.replace(/\.\.[\/\\]/g, ''); // Remove ../ and ..\
// 2. Ensure it's just the filename, no extra paths
safeBaseName = path.basename(safeBaseName);
// 3. Check it ends with .html and contains expected pattern maybe?
if (!safeBaseName.toLowerCase().endsWith('.html') || !safeBaseName.includes('_Card')) {
console.error(`API SetCurrent: Invalid source filename format: "${safeBaseName}"`);
return res.status(400).json({ error: 'Invalid source filename format.' });
}
const sanitizedSourceFilename = safeBaseName; // Use the cleaned basename
// --- End Sanitization ---
const resultsDir = path.join(__dirname, 'public', 'results');
const twitchDir = path.join(__dirname, 'public', 'twitch');
const sourcePath = path.join(resultsDir, sanitizedSourceFilename);
const destPath = path.join(twitchDir, 'Current_GameGet.html'); // Fixed destination name
console.log(`API SetCurrent: Attempting to copy "${sourcePath}" to "${destPath}"`);
try {
// Check if source file exists first
await fs.access(sourcePath, fs.constants.F_OK); // Check if source exists
// Ensure destination directory exists
await fs.mkdir(twitchDir, { recursive: true });
// Copy the file (overwrites destination)
await fs.copyFile(sourcePath, destPath);
console.log(`API SetCurrent: Successfully copied ${sanitizedSourceFilename} to Current_GameGet.html`);
res.status(200).json({ message: 'Current card updated successfully.' });
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`API SetCurrent: Source file not found: "${sourcePath}"`);
res.status(404).json({ error: `Source file not found: ${sanitizedSourceFilename}` });
} else {
console.error(`API SetCurrent: Error copying file:`, error);
res.status(500).json({ error: 'Failed to update current card file on server.' });
}
}
});
// Other endpoints (/search, catch-all, etc.) remain unchanged.
/*
// --- Catch-all route ---
app.get('*', (req, res) => {
console.log(`Catch-all route hit for: ${req.originalUrl}. Serving index.html.`);
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
*/
// --- Start Server ---
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
console.info("INFO: This version uses the IGDB API and allows selection from search results.");
});
// Optional error handlers
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});