Migrate to Vite 7, improve UI (Copy/Download), and enhance API security

This commit is contained in:
2026-01-31 10:16:49 +01:00
parent 452e6e74cb
commit d398c34aa5
14 changed files with 2012 additions and 14637 deletions

View File

@@ -14,31 +14,16 @@ COPY package*.json ./
# Install dependencies (production + dev for build) # Install dependencies (production + dev for build)
RUN npm ci RUN npm ci
# Copy source code and build scripts # Copy source code and build dependencies
COPY src/ ./src/ COPY src/ ./src/
COPY public/ ./public/ COPY public/ ./public/
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
COPY server.js ./server.js COPY server.js ./server.js
COPY vite.config.js ./vite.config.js
COPY index.html ./index.html
# Generate version.js if version info provided, otherwise run normal build # Build the application
RUN if [ -n "$VERSION" ]; then \ RUN npm run build
echo "// Auto-generated version file - do not edit manually" > src/version.js && \
echo "// Generated at: $(date -Iseconds)" >> src/version.js && \
echo "" >> src/version.js && \
echo "export const VERSION = '$VERSION';" >> src/version.js && \
echo "export const IS_RELEASE = $IS_RELEASE;" >> src/version.js && \
echo "export const BUILD_TIME = '$(date -Iseconds)';" >> src/version.js && \
echo "📝 Generated version.js with VERSION=$VERSION, IS_RELEASE=$IS_RELEASE"; \
fi
# Build the application (skip prebuild if we already generated version.js)
RUN if [ -n "$VERSION" ]; then \
echo "🚀 Building with pre-generated version.js" && \
npx react-scripts build; \
else \
echo "🚀 Building with version-check.js" && \
npm run build; \
fi
# Production stage # Production stage
FROM node:24-alpine AS production FROM node:24-alpine AS production

View File

@@ -2,12 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="JMESPath Testing Tool - Validate and test JMESPath expressions against JSON data" /> <meta name="description" content="JMESPath Testing Tool - Validate and test JMESPath expressions against JSON data" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Noto+Sans+Mono:wght@300;400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Noto+Sans+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -16,5 +15,6 @@
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body> </body>
</html> </html>

16128
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,18 @@
{ {
"name": "jmespath-playground", "name": "jmespath-playground",
"version": "1.2.5", "version": "1.3.0",
"description": "A React-based web application for testing JMESPath expressions against JSON data", "description": "A React-based web application for testing JMESPath expressions against JSON data",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite",
"prebuild": "node scripts/version-check.js", "prebuild": "node scripts/version-check.js",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test --watchAll=false", "preview": "vite preview",
"test:watch": "react-scripts test", "test": "vitest",
"server": "node server.js", "server": "node server.js",
"dev": "concurrently \"npm start\" \"npm run server\"", "dev": "concurrently \"npm start\" \"npm run server\"",
"build-image": "node scripts/build-image.js" "build-image": "node scripts/build-image.js"
}, },
"proxy": "http://localhost:3000",
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"
}, },
@@ -26,21 +25,8 @@
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/index.js"
]
},
"browserslist": { "browserslist": {
"production": [ "production": [
">0.2%", ">0.2%",
@@ -63,7 +49,12 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"supertest": "^7.2.2" "jsdom": "^27.4.0",
"supertest": "^7.2.2",
"vite": "^7.3.1",
"vitest": "^4.0.18"
} }
} }

324
server.js
View File

@@ -1,9 +1,9 @@
const express = require('express'); const express = require("express");
const path = require('path'); const path = require("path");
const crypto = require('crypto'); const crypto = require("crypto");
const os = require('os'); const os = require("os");
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require("uuid");
const { parseArgs } = require('util'); const { parseArgs } = require("util");
// Environment configuration // Environment configuration
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100; const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
@@ -11,54 +11,29 @@ const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; //
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
// Utility functions for encryption // Utility functions for encryption
function generateSalt() {
return crypto.randomBytes(16);
}
function isLocalhostRequest(req) {
// Get client IP with fallback options
const forwarded = req.get('X-Forwarded-For');
const ip = forwarded ? forwarded.split(',')[0].trim() :
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
'127.0.0.1';
const host = req.get('host') || '';
// Check for localhost IP addresses (IPv4 and IPv6)
const localhostIPs = ['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost'];
const isLocalIP = localhostIPs.includes(ip) || ip.startsWith('127.') || ip === '::1';
// Check for localhost hostnames
const isLocalHost = host.startsWith('localhost:') || host.startsWith('127.0.0.1:') || host === 'localhost' || host === '127.0.0.1';
return isLocalIP || isLocalHost;
}
function encrypt(data, key) { function encrypt(data, key) {
try { try {
const algorithm = 'aes-256-gcm'; const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv); const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.setAAD(Buffer.from('session-data')); cipher.setAAD(Buffer.from("session-data"));
let encrypted = cipher.update(JSON.stringify(data), 'utf8'); let encrypted = cipher.update(JSON.stringify(data), "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]); encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag(); const authTag = cipher.getAuthTag();
return { return {
iv: iv.toString('hex'), iv: iv.toString("hex"),
data: encrypted.toString('hex'), data: encrypted.toString("hex"),
tag: authTag.toString('hex') tag: authTag.toString("hex"),
}; };
} catch (error) { } catch (error) {
console.error('⚠️ Encryption exception:', { console.error("⚠️ Encryption exception:", {
message: error.message, message: error.message,
algorithm: 'aes-256-gcm', algorithm: "aes-256-gcm",
keyLength: key ? key.length : 'undefined', keyLength: key ? key.length : "undefined",
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
throw new Error(`Encryption failed: ${error.message}`); throw new Error(`Encryption failed: ${error.message}`);
} }
@@ -66,39 +41,40 @@ function encrypt(data, key) {
function decrypt(encryptedObj, key) { function decrypt(encryptedObj, key) {
try { try {
const algorithm = 'aes-256-gcm'; const algorithm = "aes-256-gcm";
const iv = Buffer.from(encryptedObj.iv, 'hex'); const iv = Buffer.from(encryptedObj.iv, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv); const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAAD(Buffer.from('session-data')); decipher.setAAD(Buffer.from("session-data"));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, 'hex')); decipher.setAuthTag(Buffer.from(encryptedObj.tag, "hex"));
let decrypted = decipher.update(Buffer.from(encryptedObj.data, 'hex'), null, 'utf8'); let decrypted = decipher.update(
decrypted += decipher.final('utf8'); Buffer.from(encryptedObj.data, "hex"),
null,
"utf8",
);
decrypted += decipher.final("utf8");
return JSON.parse(decrypted); return JSON.parse(decrypted);
} catch (error) { } catch (error) {
console.error('⚠️ Decryption exception:', { console.error("⚠️ Decryption exception:", {
message: error.message, message: error.message,
algorithm: 'aes-256-gcm', algorithm: "aes-256-gcm",
keyLength: key ? key.length : 'undefined', keyLength: key ? key.length : "undefined",
hasIV: !!encryptedObj.iv, hasIV: !!encryptedObj.iv,
hasTag: !!encryptedObj.tag, hasTag: !!encryptedObj.tag,
hasData: !!encryptedObj.data, hasData: !!encryptedObj.data,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
throw new Error(`Decryption failed: ${error.message}`); throw new Error(`Decryption failed: ${error.message}`);
} }
} }
// For localhost requests, use a consistent API key so sessions persist
const LOCALHOST_API_KEY = 'localhost0123456789abcdef0123456789';
function isValidApiKey(apiKey) { function isValidApiKey(apiKey) {
return typeof apiKey === 'string' && /^[0-9a-f]{32}$/i.test(apiKey); return typeof apiKey === "string" && /^[0-9a-f]{32}$/i.test(apiKey);
} }
function getSessionId(apiKey) { function getSessionId(apiKey) {
return crypto.createHash('sha256').update(apiKey).digest('hex'); return crypto.createHash("sha256").update(apiKey).digest("hex");
} }
function generateSalt() { function generateSalt() {
@@ -106,7 +82,7 @@ function generateSalt() {
} }
function deriveKey(apiKey, salt) { function deriveKey(apiKey, salt) {
return crypto.pbkdf2Sync(apiKey, salt, 10000, 32, 'sha256'); return crypto.pbkdf2Sync(apiKey, salt, 100000, 32, "sha256");
} }
// Create Express app // Create Express app
@@ -114,19 +90,19 @@ function createApp(devMode = false) {
const app = express(); const app = express();
// Trust proxy to get real client IP (needed for localhost detection) // Trust proxy to get real client IP (needed for localhost detection)
app.set('trust proxy', true); app.set("trust proxy", true);
// Middleware // Middleware
app.use(express.json({ limit: MAX_SAMPLE_SIZE })); app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
app.use(express.static(path.join(__dirname, 'build'))); app.use(express.static(path.join(__dirname, "build")));
// Dev mode request logging middleware // Dev mode request logging middleware
if (devMode) { if (devMode) {
app.use((req, res, next) => { app.use((req, res, next) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
console.log(`📨 [${timestamp}] ${req.method} ${req.path}`); console.log(`📨 [${timestamp}] ${req.method} ${req.path}`);
if (req.method !== 'GET' && Object.keys(req.body).length > 0) { if (req.method !== "GET" && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), 'utf8'); const bodySize = Buffer.byteLength(JSON.stringify(req.body), "utf8");
console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`); console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
} }
@@ -148,7 +124,9 @@ function createApp(devMode = false) {
for (const [sessionId, session] of sessions.entries()) { for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > MAX_SESSION_TTL) { if (now - session.createdAt > MAX_SESSION_TTL) {
sessions.delete(sessionId); sessions.delete(sessionId);
console.log(`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`); console.log(
`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`,
);
} }
} }
} }
@@ -157,22 +135,15 @@ function createApp(devMode = false) {
setInterval(cleanupExpiredSessions, 5 * 60 * 1000); setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
// API endpoints // API endpoints
app.post('/api/v1/upload', (req, res) => { app.post("/api/v1/upload", (req, res) => {
try { try {
// Check if request is from localhost - if so, skip API key validation const apiKey = req.headers["x-api-key"];
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) { // Validate API key header
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) { if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); return res
} .status(403)
} else { .json({ error: "Invalid or missing X-API-Key header" });
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
} }
// Cleanup expired sessions before checking limits // Cleanup expired sessions before checking limits
@@ -181,26 +152,26 @@ function createApp(devMode = false) {
// Check session limits // Check session limits
if (sessions.size >= MAX_SESSIONS) { if (sessions.size >= MAX_SESSIONS) {
return res.status(429).json({ return res.status(429).json({
error: 'Maximum number of sessions reached. Please try again later.', error: "Maximum number of sessions reached. Please try again later.",
maxSessions: MAX_SESSIONS, maxSessions: MAX_SESSIONS,
currentSessions: sessions.size currentSessions: sessions.size,
}); });
} }
const uploadedData = req.body; const uploadedData = req.body;
// Validate that it's valid JSON // Validate that it's valid JSON
if (!uploadedData || typeof uploadedData !== 'object') { if (!uploadedData || typeof uploadedData !== "object") {
return res.status(400).json({ error: 'Invalid JSON data' }); return res.status(400).json({ error: "Invalid JSON data" });
} }
// Check data size // Check data size
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), 'utf8'); const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), "utf8");
if (dataSize > MAX_SAMPLE_SIZE) { if (dataSize > MAX_SAMPLE_SIZE) {
return res.status(413).json({ return res.status(413).json({
error: 'Sample data too large', error: "Sample data too large",
maxSize: MAX_SAMPLE_SIZE, maxSize: MAX_SAMPLE_SIZE,
receivedSize: dataSize receivedSize: dataSize,
}); });
} }
@@ -213,69 +184,66 @@ function createApp(devMode = false) {
const encryptedData = encrypt(uploadedData, key); const encryptedData = encrypt(uploadedData, key);
sessions.set(sessionId, { sessions.set(sessionId, {
salt: salt.toString('hex'), salt: salt.toString("hex"),
encryptedData, encryptedData,
state: stateGuid, state: stateGuid,
createdAt: Date.now(), createdAt: Date.now(),
accessed: false accessed: false,
}); });
console.log(`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`); console.log(
`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`,
);
res.json({ res.json({
message: 'Sample data uploaded successfully', message: "Sample data uploaded successfully",
state: stateGuid, state: stateGuid,
sessionId: sessionId.substring(0, 8) + '...' sessionId: sessionId.substring(0, 8) + "...",
}); });
} catch (error) { } catch (error) {
console.error('⚠️ Upload endpoint exception occurred:', { console.error("⚠️ Upload endpoint exception occurred:", {
message: error.message, message: error.message,
stack: error.stack, stack: error.stack,
sessionCount: sessions.size, sessionCount: sessions.size,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
// Provide more specific error messages based on error type // Provide more specific error messages based on error type
if (error.name === 'SyntaxError') { if (error.name === "SyntaxError") {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid JSON data format', error: "Invalid JSON data format",
details: 'The uploaded data could not be parsed as valid JSON' details: "The uploaded data could not be parsed as valid JSON",
}); });
} else if (error.message.includes('encrypt')) { } else if (error.message.includes("encrypt")) {
return res.status(500).json({ return res.status(500).json({
error: 'Encryption failed', error: "Encryption failed",
details: 'Failed to encrypt session data. Please try again with a new API key.' details:
"Failed to encrypt session data. Please try again with a new API key.",
}); });
} else if (error.message.includes('PBKDF2')) { } else if (error.message.includes("PBKDF2")) {
return res.status(500).json({ return res.status(500).json({
error: 'Key derivation failed', error: "Key derivation failed",
details: 'Failed to derive encryption key from API key' details: "Failed to derive encryption key from API key",
}); });
} else { } else {
return res.status(500).json({ return res.status(500).json({
error: 'Upload processing failed', error: "Upload processing failed",
details: 'An unexpected error occurred while processing your upload. Please try again.' details:
"An unexpected error occurred while processing your upload. Please try again.",
}); });
} }
} }
}); });
app.get('/api/v1/sample', (req, res) => { app.get("/api/v1/sample", (req, res) => {
try { try {
// Check if request is from localhost - if so, skip API key validation const apiKey = req.headers["x-api-key"];
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) { // Validate API key header
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) { if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); return res
} .status(403)
} else { .json({ error: "Invalid or missing X-API-Key header" });
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
} }
const sessionId = getSessionId(apiKey); const sessionId = getSessionId(apiKey);
@@ -286,64 +254,62 @@ function createApp(devMode = false) {
} }
// Decrypt data // Decrypt data
const salt = Buffer.from(session.salt, 'hex'); const salt = Buffer.from(session.salt, "hex");
const key = deriveKey(apiKey, salt); const key = deriveKey(apiKey, salt);
const decryptedData = decrypt(session.encryptedData, key); const decryptedData = decrypt(session.encryptedData, key);
// Remove session after first access (one-time use) // Remove session after first access (one-time use)
sessions.delete(sessionId); sessions.delete(sessionId);
console.log(`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`); console.log(
`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`,
);
res.json(decryptedData); res.json(decryptedData);
} catch (error) { } catch (error) {
console.error('⚠️ Sample retrieval exception occurred:', { console.error("⚠️ Sample retrieval exception occurred:", {
message: error.message, message: error.message,
stack: error.stack, stack: error.stack,
sessionCount: sessions.size, sessionCount: sessions.size,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
// Provide more specific error messages based on error type // Provide more specific error messages based on error type
if (error.message.includes('decrypt')) { if (error.message.includes("decrypt")) {
return res.status(500).json({ return res.status(500).json({
error: 'Decryption failed', error: "Decryption failed",
details: 'Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.' details:
"Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.",
}); });
} else if (error.message.includes('JSON')) { } else if (error.message.includes("JSON")) {
return res.status(500).json({ return res.status(500).json({
error: 'Data corruption detected', error: "Data corruption detected",
details: 'The stored session data appears to be corrupted and cannot be parsed.' details:
"The stored session data appears to be corrupted and cannot be parsed.",
}); });
} else if (error.name === 'TypeError') { } else if (error.name === "TypeError") {
return res.status(500).json({ return res.status(500).json({
error: 'Session data format error', error: "Session data format error",
details: 'The session data format is invalid or corrupted.' details: "The session data format is invalid or corrupted.",
}); });
} else { } else {
return res.status(500).json({ return res.status(500).json({
error: 'Sample retrieval failed', error: "Sample retrieval failed",
details: 'An unexpected error occurred while retrieving sample data. The session may have been corrupted.' details:
"An unexpected error occurred while retrieving sample data. The session may have been corrupted.",
}); });
} }
} }
}); });
app.get('/api/v1/state', (req, res) => { app.get("/api/v1/state", (req, res) => {
try { try {
// Check if request is from localhost - if so, skip API key validation const apiKey = req.headers["x-api-key"];
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
if (!isFromLocalhost) { // Validate API key header
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) { if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); return res
} .status(403)
} else { .json({ error: "Invalid or missing X-API-Key header" });
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
} }
const sessionId = getSessionId(apiKey); const sessionId = getSessionId(apiKey);
@@ -356,55 +322,56 @@ function createApp(devMode = false) {
res.json({ state: session.state }); res.json({ state: session.state });
} catch (error) { } catch (error) {
console.error('⚠️ State retrieval exception occurred:', { console.error("⚠️ State retrieval exception occurred:", {
message: error.message, message: error.message,
stack: error.stack, stack: error.stack,
sessionCount: sessions.size, sessionCount: sessions.size,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
// Provide more specific error messages // Provide more specific error messages
if (error.message.includes('API key')) { if (error.message.includes("API key")) {
return res.status(403).json({ return res.status(403).json({
error: 'API key processing failed', error: "API key processing failed",
details: 'Failed to process the provided API key' details: "Failed to process the provided API key",
}); });
} else { } else {
return res.status(500).json({ return res.status(500).json({
error: 'State retrieval failed', error: "State retrieval failed",
details: 'An unexpected error occurred while retrieving session state. Please try again.' details:
"An unexpected error occurred while retrieving session state. Please try again.",
}); });
} }
} }
}); });
// Status endpoint (no auth required) - detailed information // Status endpoint (no auth required) - detailed information
app.get('/api/v1/status', (req, res) => { app.get("/api/v1/status", (req, res) => {
cleanupExpiredSessions(); // Cleanup on status check cleanupExpiredSessions(); // Cleanup on status check
res.json({ res.json({
status: 'healthy', status: "healthy",
sessions: { sessions: {
current: sessions.size, current: sessions.size,
max: MAX_SESSIONS, max: MAX_SESSIONS,
available: MAX_SESSIONS - sessions.size available: MAX_SESSIONS - sessions.size,
}, },
limits: { limits: {
maxSessions: MAX_SESSIONS, maxSessions: MAX_SESSIONS,
maxSampleSize: MAX_SAMPLE_SIZE, maxSampleSize: MAX_SAMPLE_SIZE,
maxSessionTTL: MAX_SESSION_TTL maxSessionTTL: MAX_SESSION_TTL,
}, },
uptime: process.uptime() uptime: process.uptime(),
}); });
}); });
// Health endpoint (no auth required) - simple OK response // Health endpoint (no auth required) - simple OK response
app.get('/api/v1/health', (req, res) => { app.get("/api/v1/health", (req, res) => {
res.type('text/plain').send('OK'); res.type("text/plain").send("OK");
}); });
// Serve React app for all other routes // Serve React app for all other routes
app.get('*', (req, res) => { app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html')); res.sendFile(path.join(__dirname, "build", "index.html"));
}); });
return app; return app;
@@ -414,16 +381,27 @@ function createApp(devMode = false) {
if (require.main === module) { if (require.main === module) {
const { values } = parseArgs({ const { values } = parseArgs({
options: { options: {
'listen-addr': { type: 'string', short: 'h', default: process.env.LISTEN_ADDR || '127.0.0.1' }, "listen-addr": {
'port': { type: 'string', short: 'p', default: process.env.LISTEN_PORT || '3000' }, type: "string",
'dev': { type: 'boolean', default: process.env.DEV_MODE === 'true' || false } short: "h",
} default: process.env.LISTEN_ADDR || "127.0.0.1",
},
port: {
type: "string",
short: "p",
default: process.env.LISTEN_PORT || "3000",
},
dev: {
type: "boolean",
default: process.env.DEV_MODE === "true" || false,
},
},
}); });
const DEV_MODE = values.dev; const DEV_MODE = values.dev;
const app = createApp(DEV_MODE); const app = createApp(DEV_MODE);
const PORT = parseInt(values.port); const PORT = parseInt(values.port);
const HOST = values['listen-addr']; const HOST = values["listen-addr"];
app.listen(PORT, HOST, () => { app.listen(PORT, HOST, () => {
console.log(`JMESPath Playground Server running`); console.log(`JMESPath Playground Server running`);
@@ -432,12 +410,12 @@ if (require.main === module) {
} }
// Show actual accessible URLs // Show actual accessible URLs
if (HOST === '0.0.0.0') { if (HOST === "0.0.0.0") {
console.log(` Listening on all interfaces:`); console.log(` Listening on all interfaces:`);
const interfaces = os.networkInterfaces(); const interfaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) { for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) { for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) { if (addr.family === "IPv4" && !addr.internal) {
console.log(` http://${addr.address}:${PORT}`); console.log(` http://${addr.address}:${PORT}`);
} }
} }
@@ -450,17 +428,24 @@ if (require.main === module) {
console.log(`Configuration:`); console.log(`Configuration:`);
console.log(` Max Sessions: ${MAX_SESSIONS}`); console.log(` Max Sessions: ${MAX_SESSIONS}`);
console.log(` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`); console.log(
console.log(` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`); ` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`,
);
console.log(
` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`,
);
console.log(
" Security: AES-256-GCM encryption with PBKDF2 (100k iterations)",
);
// Show base API URL // Show base API URL
let apiBaseUrl; let apiBaseUrl;
if (HOST === '0.0.0.0') { if (HOST === "0.0.0.0") {
const interfaces = os.networkInterfaces(); const interfaces = os.networkInterfaces();
let firstIP = '127.0.0.1'; let firstIP = "127.0.0.1";
outer: for (const addrs of Object.values(interfaces)) { outer: for (const addrs of Object.values(interfaces)) {
for (const addr of addrs) { for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) { if (addr.family === "IPv4" && !addr.internal) {
firstIP = addr.address; firstIP = addr.address;
break outer; break outer;
} }
@@ -472,7 +457,6 @@ if (require.main === module) {
} }
console.log(`API Base URL: ${apiBaseUrl}`); console.log(`API Base URL: ${apiBaseUrl}`);
console.log(`Security: AES-256-GCM encryption with PBKDF2 key derivation`);
}); });
} }

View File

@@ -83,22 +83,10 @@ function App() {
// Get headers for API requests // Get headers for API requests
const getApiHeaders = () => { const getApiHeaders = () => {
const headers = { return {
Accept: "application/json", Accept: "application/json",
"X-API-Key": apiKey,
}; };
// Only send API key for non-localhost requests
// For localhost, let server use its default LOCALHOST_API_KEY
if (
window.location.hostname !== "localhost" &&
window.location.hostname !== "127.0.0.1" &&
!window.location.hostname.startsWith("127.") &&
window.location.hostname !== "::1"
) {
headers["X-API-Key"] = apiKey;
}
return headers;
}; };
// Load sample data from API on startup and setup periodic state checking // Load sample data from API on startup and setup periodic state checking

View File

@@ -1,13 +1,27 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import App from './App'; import App from './App';
import { vi } from 'vitest';
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = (function() {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value.toString(); }),
clear: vi.fn(() => { store = {}; }),
removeItem: vi.fn((key) => { delete store[key]; })
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock fetch for API calls // Mock fetch for API calls
global.fetch = jest.fn(); global.fetch = vi.fn();
describe('App Component', () => { describe('App Component', () => {
beforeEach(() => { beforeEach(() => {
fetch.mockClear(); vi.clearAllMocks();
// Mock successful API responses // Mock successful API responses
fetch.mockImplementation((url) => { fetch.mockImplementation((url) => {
if (url.includes('/api/v1/sample')) { if (url.includes('/api/v1/sample')) {

View File

@@ -55,8 +55,7 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
</button> </button>
</div> </div>
<div className="form-text"> <div className="form-text">
This API key is used to encrypt and authenticate data uploads from remote clients. This API key is used to encrypt and authenticate data uploads.
<strong>Note:</strong> Requests from localhost (127.0.0.1) do not require an API key.
</div> </div>
</div> </div>
@@ -64,7 +63,7 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
<h6>📡 Remote Data Upload API</h6> <h6>📡 Remote Data Upload API</h6>
<p className="text-muted"> <p className="text-muted">
External tools can upload sample data remotely using the REST API. External tools can upload sample data remotely using the REST API.
For remote clients, the API key is required for authentication. Define two The API key is required for authentication. Define two
environment variables in your <code>.bashrc</code>. environment variables in your <code>.bashrc</code>.
</p> </p>
<pre className="bg-light p-3 rounded border"> <pre className="bg-light p-3 rounded border">
@@ -80,10 +79,9 @@ function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
"$\{JMESPATH_PLAYGROUND_API_URL}/api/v1/upload"`}</code> "$\{JMESPATH_PLAYGROUND_API_URL}/api/v1/upload"`}</code>
</pre> </pre>
<div className="form-text"> <div className="form-text">
Replace <code>{'__JSON_FILE_NAME__'}</code> with the path to your JSON file containing the sample data. Replace <code>{"__JSON_FILE_NAME__"}</code> with the path to your
or use <code>-</code> to read from standard input. JSON file containing the sample data. or use <code>-</code> to
<br /> read from standard input.
<strong>For localhost clients:</strong> The X-API-Key should be omitted.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,46 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Add TextEncoder/TextDecoder for Node.js compatibility
if (typeof TextEncoder === 'undefined') {
global.TextEncoder = require('util').TextEncoder;
}
if (typeof TextDecoder === 'undefined') {
global.TextDecoder = require('util').TextDecoder;
}
// Mock crypto.getRandomValues for test environment
if (typeof global.crypto === 'undefined') {
global.crypto = {
getRandomValues: (array) => {
// Simple predictable mock for testing
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
};
}
// Suppress console errors during tests
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: ReactDOMTestUtils.act is deprecated') ||
args[0].includes('Warning: An update to App inside a test was not wrapped in act'))
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});

23
vite.config.js Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'build',
},
test: {
globals: true,
environment: 'jsdom',
css: true,
},
});