5 Commits

17 changed files with 2466 additions and 14789 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

72
bin/Upload-JMESPath.ps1 Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env pwsh
[CmdletBinding()]
param(
[Parameter(Position=0, HelpMessage='API base URL')]
[string]$ApiUrl,
[Parameter(HelpMessage='API key for authentication')]
[string]$ApiKey,
[Parameter(HelpMessage='Path to JSON file; default: read from stdin')]
[string]$JsonFile = '-',
[Parameter(HelpMessage='Show help')]
[switch]$Help
)
function Show-Usage {
$usage = @"
Usage: Upload-JMESPath.ps1 [-ApiUrl <url>] [-ApiKey <key>] [-JsonFile <file>]
Parameters:
-ApiUrl <url> The base URL of the JMESPath Playground API (default: http://localhost:3000 or $env:JMESPATH_PLAYGROUND_API_URL)
-ApiKey <key> The API key for authentication (can also be set via JMESPATH_PLAYGROUND_API_KEY)
-JsonFile <file> The JSON file to upload (default: stdin if not specified)
-Help Show this help message and exit
"@
Write-Output $usage
}
if ($Help) { Show-Usage; exit 0 }
# Apply environment defaults when parameters are not provided
if (-not $ApiUrl) {
if ($env:JMESPATH_PLAYGROUND_API_URL) { $ApiUrl = $env:JMESPATH_PLAYGROUND_API_URL } else { $ApiUrl = 'http://localhost:3000' }
}
if (-not $ApiKey) {
$ApiKey = $env:JMESPATH_PLAYGROUND_API_KEY
}
# Read JSON body from file or stdin
try {
if ($JsonFile -eq '-' -or [string]::IsNullOrEmpty($JsonFile)) {
$Body = [Console]::In.ReadToEnd()
} else {
if (-not (Test-Path -Path $JsonFile)) {
Write-Error "JSON file not found: $JsonFile"
exit 2
}
$Body = Get-Content -Raw -Path $JsonFile
}
} catch {
Write-Error "Failed to read JSON input: $($_.Exception.Message)"
exit 2
}
# Prepare headers
$Headers = @{ 'Accept' = 'application/json' }
if (-not [string]::IsNullOrEmpty($ApiKey)) { $Headers['X-API-Key'] = $ApiKey }
# POST to API
$Uri = "$ApiUrl/api/v1/upload"
try {
$response = Invoke-RestMethod -Uri $Uri -Method Post -Headers $Headers -ContentType 'application/json' -Body $Body -ErrorAction Stop
if ($null -ne $response) {
$response | ConvertTo-Json -Depth 10
} else {
Write-Output "Upload completed."
}
} catch {
Write-Error "Upload failed: $($_.Exception.Message)"
exit 3
}

94
bin/upload-jmespath.mjs Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
// Parse command-line arguments
import { parseArgs } from "util";
const args = parseArgs({
options: {
"api-url": {
type: "string",
short: "a",
default: process.env.JMESPATH_PLAYGROUND_API_URL || "http://localhost:3000",
},
"api-key": {
type: "string",
short: "k",
default: process.env.JMESPATH_PLAYGROUND_API_KEY || "",
},
"json-file": {
type: "string",
short: "j",
default: "",
},
help: { type: "boolean", short: "h" },
},
});
// Show help message
if (args.values.help) {
console.log(`
Usage: upload-jmespath.mjs [options]
Options:
-a, --api-url API base URL (default: http://localhost:3000)
-k, --api-key API key for authentication
-j, --json-file Path to the JSON file to upload
-h, --help Show this help message
`);
process.exit(0);
}
// Read the JSON from the specfied file or from stdin if no file is provided
async function readJson(filePath) {
if (filePath) {
const absolutePath = path.resolve(filePath);
const fileContent = fs.readFileSync(absolutePath, "utf-8");
return JSON.parse(fileContent);
} else {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (error) {
reject(error);
}
});
});
}
}
// Upload the JSON data to the API using built-in fetch
async function uploadJson(apiUrl, apiKey, jsonData) {
const response = await fetch(`${apiUrl}/api/v1/upload`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { "X-API-Key": `${apiKey}` } : {}),
},
body: JSON.stringify(jsonData),
});
if (!response.ok) {
throw new Error(`Failed to upload JSON: ${response.status} ${response.statusText}`);
}
}
// Main function
async function main() {
try {
const jsonData = await readJson(args.values["json-file"]);
await uploadJson(args.values["api-url"], args.values["api-key"], jsonData);
console.log("JSON uploaded successfully.");
} catch (error) {
console.error("Error:", error.message);
process.exit(1);
}
}
main();

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>

16134
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"
} }
} }

81
scripts/build-image.js Normal file → Executable file
View File

@@ -2,6 +2,7 @@
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const { parseArgs } = require('util');
function execCommand(command, description) { function execCommand(command, description) {
try { try {
@@ -46,11 +47,77 @@ function getVersion() {
return { version: `${packageJson.version}-dev`, isRelease: false }; return { version: `${packageJson.version}-dev`, isRelease: false };
} }
function getHostArchitecture() {
// Map Node.js architecture names to container architecture names
const archMap = {
'arm64': 'arm64',
'arm': 'arm64',
'x64': 'amd64'
};
return archMap[process.arch] || 'arm64';
}
function showHelp() {
const hostArch = getHostArchitecture();
console.log(`Build multi-architecture container images for JMESPath Playground
Usage:
build-image.js [OPTIONS]
Options:
--all-arch Build for both arm64 and amd64 (default: build for host architecture only)
--arch <arch> Target architecture (arm64 or amd64). Can be specified multiple times.
--help, -h Show this help message and exit
Examples:
build-image.js # Builds for ${hostArch} only (host architecture)
build-image.js --all-arch # Builds for both arm64 and amd64
build-image.js --arch arm64 # Builds for arm64 only
build-image.js --arch arm64 --arch amd64 # Explicitly specify both
build-image.js -h # Show help`);
}
function main() { function main() {
const { values } = parseArgs({
options: {
help: {
type: 'boolean',
short: 'h',
description: 'Show help'
},
'all-arch': {
type: 'boolean',
description: 'Build for both arm64 and amd64'
},
arch: {
type: 'string',
multiple: true,
description: 'Target architecture (arm64 or amd64)'
}
},
strict: true,
allowPositionals: false
});
if (values.help) {
showHelp();
process.exit(0);
}
const containerTool = getContainerTool(); const containerTool = getContainerTool();
const { version, isRelease } = getVersion(); const { version, isRelease } = getVersion();
let architectures;
if (values['all-arch']) {
architectures = ['arm64', 'amd64'];
} else if (values.arch && values.arch.length > 0) {
architectures = values.arch;
} else {
architectures = [getHostArchitecture()];
}
console.log(`Building ${isRelease ? 'release' : 'development'} version: ${version}`); console.log(`Building ${isRelease ? 'release' : 'development'} version: ${version}`);
console.log(`Target architectures: ${architectures.join(', ')}`);
// Build container image // Build container image
const tags = isRelease const tags = isRelease
@@ -63,16 +130,24 @@ function main() {
`-t skoszewski/jmespath-playground:latest` `-t skoszewski/jmespath-playground:latest`
].join(' '); ].join(' ');
const buildCommand = `${containerTool} build --build-arg VERSION="${version}" --build-arg IS_RELEASE="${isRelease}" ${tags} .`; const archFlags = architectures.map(arch => `--arch ${arch}`).join(' ');
const buildCommand = `${containerTool} build ${archFlags} --build-arg VERSION="${version}" --build-arg IS_RELEASE="${isRelease}" ${tags} .`;
execCommand(buildCommand, 'Building container image'); execCommand(buildCommand, 'Building container image');
console.log('Container image build completed successfully!'); console.log('Container image build completed successfully!');
// Show usage instructions // Show usage instructions
console.log(`\nUsage examples:`);
console.log(` build-image.js # Builds for host architecture only`);
console.log(` build-image.js --all-arch # Builds for both arm64 and amd64`);
console.log(` build-image.js --arch arm64 # Builds for arm64 only`);
console.log(` build-image.js --arch arm64 --arch amd64 # Explicitly specify both`);
if (isRelease) { if (isRelease) {
console.log(`\nTo run the container:`); console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:${version}`); console.log(` ${containerTool} run --arch arm64 --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:${version}`);
if (containerTool === 'docker') { if (containerTool === 'docker') {
console.log(`\nTo push to Docker Hub:`); console.log(`\nTo push to Docker Hub:`);
console.log(` docker push skoszewski/jmespath-playground:${version}`); console.log(` docker push skoszewski/jmespath-playground:${version}`);
@@ -80,7 +155,7 @@ function main() {
} }
} else { } else {
console.log(`\nTo run the container:`); console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:dev`); console.log(` ${containerTool} run --arch arm64 --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:dev`);
} }
} }

336
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,24 +90,24 @@ 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`);
} }
const originalJson = res.json; const originalJson = res.json;
res.json = function(data) { res.json = function (data) {
console.log(` ✓ Response: ${res.statusCode}`); console.log(` ✓ Response: ${res.statusCode}`);
return originalJson.call(this, data); return originalJson.call(this, data);
}; };
@@ -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
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); .status(403)
} .json({ error: "Invalid or missing X-API-Key header" });
} else {
// 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
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); .status(403)
} .json({ error: "Invalid or missing X-API-Key header" });
} else {
// 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
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' }); .status(403)
} .json({ error: "Invalid or missing X-API-Key header" });
} else {
// 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,8 +457,7 @@ 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`);
}); });
} }
module.exports = { createApp }; module.exports = { createApp };

View File

@@ -1,16 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import Header from './components/Header'; import Header from "./components/Header";
import Footer from './components/Footer'; import Footer from "./components/Footer";
import MainPage from './components/MainPage'; import MainPage from "./components/MainPage";
import ApiKeyPage from './components/ApiKeyPage'; import ApiKeyPage from "./components/ApiKeyPage";
import './App.css'; import "./App.css";
// Utility function to generate a cryptographically secure API key // Utility function to generate a cryptographically secure API key
function generateApiKey() { function generateApiKey() {
const array = new Uint8Array(16); const array = new Uint8Array(16);
// Use crypto.getRandomValues if available (browser), fallback for tests // Use crypto.getRandomValues if available (browser), fallback for tests
if (typeof crypto !== 'undefined' && crypto.getRandomValues) { if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(array); crypto.getRandomValues(array);
} else { } else {
// Fallback for test environments - not cryptographically secure // Fallback for test environments - not cryptographically secure
@@ -18,63 +18,75 @@ function generateApiKey() {
array[i] = Math.floor(Math.random() * 256); array[i] = Math.floor(Math.random() * 256);
} }
} }
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
} }
// JMESPath Testing Tool - Main Application Component // JMESPath Testing Tool - Main Application Component
function App() { function App() {
const [currentPage, setCurrentPage] = useState('main'); // 'main' or 'apikey' const [currentPage, setCurrentPage] = useState("main"); // 'main' or 'apikey'
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
// Load theme from localStorage or default to 'auto' // Load theme from localStorage or default to 'auto'
return localStorage.getItem('theme') || 'auto'; return localStorage.getItem("theme") || "auto";
}); });
const [showReloadButton, setShowReloadButton] = useState(false); const [showReloadButton, setShowReloadButton] = useState(false);
const [currentStateGuid, setCurrentStateGuid] = useState(null); const [currentStateGuid, setCurrentStateGuid] = useState(null);
const [sampleData, setSampleData] = useState(null); const [jmespathExpression, setJmespathExpression] =
useState("people[0].name");
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [apiKey, setApiKey] = useState(() => { const [apiKey, setApiKey] = useState(() => {
// Load API key from localStorage or generate new one // Load API key from localStorage or generate new one
const stored = localStorage.getItem('jmespath-api-key'); const stored = localStorage.getItem("jmespath-api-key");
if (stored && /^[0-9a-f]{32}$/i.test(stored)) { if (stored && /^[0-9a-f]{32}$/i.test(stored)) {
return stored; return stored;
} }
const newKey = generateApiKey(); const newKey = generateApiKey();
localStorage.setItem('jmespath-api-key', newKey); localStorage.setItem("jmespath-api-key", newKey);
return newKey; return newKey;
}); });
// Theme management // Theme management
useEffect(() => { useEffect(() => {
const applyTheme = (selectedTheme) => { const applyTheme = (selectedTheme) => {
const effectiveTheme = selectedTheme === 'auto' const effectiveTheme =
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') selectedTheme === "auto"
: selectedTheme; ? window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
document.documentElement.setAttribute('data-bs-theme', effectiveTheme); ? "dark"
: "light"
: selectedTheme;
document.documentElement.setAttribute("data-bs-theme", effectiveTheme);
}; };
applyTheme(theme); applyTheme(theme);
// Save theme preference // Save theme preference
localStorage.setItem('theme', theme); localStorage.setItem("theme", theme);
}, [theme]); }, [theme]);
// 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
@@ -90,10 +102,10 @@ function App() {
// Check if state has changed (new data uploaded) // Check if state has changed (new data uploaded)
const checkStateChange = async () => { const checkStateChange = async () => {
try { try {
const response = await fetch('/api/v1/state', { const response = await fetch("/api/v1/state", {
headers: getApiHeaders() headers: getApiHeaders(),
}); });
if (response.ok) { if (response.ok) {
const stateData = await response.json(); const stateData = await response.json();
if (stateData.state && stateData.state !== currentStateGuid) { if (stateData.state && stateData.state !== currentStateGuid) {
@@ -109,19 +121,19 @@ function App() {
const loadSampleData = async () => { const loadSampleData = async () => {
try { try {
setShowReloadButton(false); setShowReloadButton(false);
const response = await fetch('/api/v1/sample', { const response = await fetch("/api/v1/sample", {
headers: getApiHeaders() headers: getApiHeaders(),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data) { if (data) {
setSampleData(data); setJsonData(JSON.stringify(data, null, 2));
} }
// Update current state GUID // Update current state GUID
const stateResponse = await fetch('/api/v1/state', { const stateResponse = await fetch("/api/v1/state", {
headers: getApiHeaders() headers: getApiHeaders(),
}); });
if (stateResponse.ok) { if (stateResponse.ok) {
const stateData = await stateResponse.json(); const stateData = await stateResponse.json();
@@ -129,7 +141,7 @@ function App() {
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to load sample data:', error); console.error("Failed to load sample data:", error);
} }
}; };
@@ -137,7 +149,7 @@ function App() {
const regenerateApiKey = () => { const regenerateApiKey = () => {
const newKey = generateApiKey(); const newKey = generateApiKey();
setApiKey(newKey); setApiKey(newKey);
localStorage.setItem('jmespath-api-key', newKey); localStorage.setItem("jmespath-api-key", newKey);
setShowReloadButton(false); setShowReloadButton(false);
setCurrentStateGuid(null); setCurrentStateGuid(null);
}; };
@@ -152,7 +164,7 @@ function App() {
return ( return (
<div className="container-fluid vh-100 d-flex flex-column"> <div className="container-fluid vh-100 d-flex flex-column">
<Header <Header
theme={theme} theme={theme}
onThemeChange={handleThemeChange} onThemeChange={handleThemeChange}
currentPage={currentPage} currentPage={currentPage}
@@ -160,19 +172,22 @@ function App() {
/> />
{/* Main Content Section - flex-grow to fill space */} {/* Main Content Section - flex-grow to fill space */}
<div className="container-fluid flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}> <div
{currentPage === 'main' ? ( className="container-fluid flex-grow-1 d-flex flex-column"
<MainPage style={{ minHeight: 0 }}
>
{currentPage === "main" ? (
<MainPage
apiKey={apiKey} apiKey={apiKey}
showReloadButton={showReloadButton} showReloadButton={showReloadButton}
onReloadSampleData={loadSampleData} onReloadSampleData={loadSampleData}
initialSampleData={sampleData} jmespathExpression={jmespathExpression}
setJmespathExpression={setJmespathExpression}
jsonData={jsonData}
setJsonData={setJsonData}
/> />
) : ( ) : (
<ApiKeyPage <ApiKeyPage apiKey={apiKey} onRegenerateApiKey={regenerateApiKey} />
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
/>
)} )}
</div> </div>
@@ -181,4 +196,4 @@ function App() {
); );
} }
export default App; export default App;

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,41 +1,24 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import jmespath from 'jmespath'; import jmespath from "jmespath";
function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleData }) {
const [jmespathExpression, setJmespathExpression] = useState('people[0].name');
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [jsonError, setJsonError] = useState('');
// Use initial sample data when provided
useEffect(() => {
if (initialSampleData) {
setJsonData(JSON.stringify(initialSampleData, null, 2));
}
}, [initialSampleData]);
function MainPage({
showReloadButton,
onReloadSampleData,
jmespathExpression,
setJmespathExpression,
jsonData,
setJsonData,
}) {
const [result, setResult] = useState("");
const [error, setError] = useState("");
const [jsonError, setJsonError] = useState("");
const [copySuccess, setCopySuccess] = useState(false);
const evaluateExpression = () => { const evaluateExpression = () => {
try { try {
// Clear previous errors // Clear previous errors
setError(''); setError("");
setJsonError(''); setJsonError("");
// Validate and parse JSON // Validate and parse JSON
let parsedData; let parsedData;
@@ -43,7 +26,7 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
parsedData = JSON.parse(jsonData); parsedData = JSON.parse(jsonData);
} catch (jsonErr) { } catch (jsonErr) {
setJsonError(`Invalid JSON: ${jsonErr.message}`); setJsonError(`Invalid JSON: ${jsonErr.message}`);
setResult(''); setResult("");
return; return;
} }
@@ -52,13 +35,13 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
// Format the result // Format the result
if (queryResult === null || queryResult === undefined) { if (queryResult === null || queryResult === undefined) {
setResult('null'); setResult("null");
} else { } else {
setResult(JSON.stringify(queryResult, null, 2)); setResult(JSON.stringify(queryResult, null, 2));
} }
} catch (jmesErr) { } catch (jmesErr) {
setError(`JMESPath Error: ${jmesErr.message}`); setError(`JMESPath Error: ${jmesErr.message}`);
setResult(''); setResult("");
} }
}; };
@@ -88,30 +71,50 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
}; };
const clearAll = () => { const clearAll = () => {
setJmespathExpression(''); setJmespathExpression("");
setJsonData(''); setJsonData("");
setResult(''); setResult("");
setError(''); setError("");
setJsonError(''); setJsonError("");
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(result);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy!", err);
}
};
const downloadResult = () => {
const blob = new Blob([result], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "result.json";
a.click();
URL.revokeObjectURL(url);
}; };
const loadSample = () => { const loadSample = () => {
const sampleData = { const sampleData = {
"users": [ users: [
{"name": "Alice", "age": 30, "city": "New York"}, { name: "Alice", age: 30, city: "New York" },
{"name": "Bob", "age": 25, "city": "San Francisco"}, { name: "Bob", age: 25, city: "San Francisco" },
{"name": "Charlie", "age": 35, "city": "Chicago"} { name: "Charlie", age: 35, city: "Chicago" },
], ],
"total": 3 total: 3,
}; };
setJsonData(JSON.stringify(sampleData, null, 2)); setJsonData(JSON.stringify(sampleData, null, 2));
setJmespathExpression('users[?age > `30`].name'); setJmespathExpression("users[?age > `30`].name");
}; };
const loadFromDisk = () => { const loadFromDisk = () => {
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'file'; input.type = "file";
input.accept = '.json'; input.accept = ".json";
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
@@ -122,7 +125,7 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
setJsonData(JSON.stringify(parsed, null, 2)); setJsonData(JSON.stringify(parsed, null, 2));
} catch (error) { } catch (error) {
alert('Invalid JSON file'); alert("Invalid JSON file");
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -132,9 +135,9 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
}; };
const loadLogFile = () => { const loadLogFile = () => {
const input = document.createElement('input'); const input = document.createElement("input");
input.type = 'file'; input.type = "file";
input.accept = '.log,.jsonl,.ndjson'; input.accept = ".log,.jsonl,.ndjson";
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {
@@ -142,12 +145,12 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const content = e.target.result; const content = e.target.result;
const lines = content.trim().split('\n'); const lines = content.trim().split("\n");
const logs = lines.map(line => JSON.parse(line)); const logs = lines.map((line) => JSON.parse(line));
setJsonData(JSON.stringify(logs, null, 2)); setJsonData(JSON.stringify(logs, null, 2));
setJmespathExpression('[*].message'); setJmespathExpression("[*].message");
} catch (error) { } catch (error) {
alert('Invalid JSON Lines file'); alert("Invalid JSON Lines file");
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -162,8 +165,9 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
<div className="row mb-2"> <div className="row mb-2">
<div className="col-12"> <div className="col-12">
<p className="text-muted text-center mb-2 small"> <p className="text-muted text-center mb-2 small">
Validate and test JMESPath expressions against JSON data in real-time. Validate and test JMESPath expressions against JSON data in
Enter your JMESPath query and JSON data below to see the results instantly. real-time. Enter your JMESPath query and JSON data below to see the
results instantly.
</p> </p>
</div> </div>
</div> </div>
@@ -172,11 +176,54 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
<div className="row mb-2"> <div className="row mb-2">
<div className="col-12"> <div className="col-12">
<div className="card"> <div className="card">
<div className="card-header d-flex justify-content-between align-items-center py-2"> <div className="card-header py-2">
<h6 className="mb-0"> <h6 className="mb-0">
<i className="bi bi-search me-2"></i> <i className="bi bi-search me-2"></i>
JMESPath Expression JMESPath Expression
</h6> </h6>
</div>
<div className="card-body">
<input
type="text"
className={`form-control jmespath-input ${error ? "error" : "success"}`}
value={jmespathExpression}
onChange={handleJmespathChange}
placeholder="Enter JMESPath expression (e.g., people[*].name)"
/>
<div
className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? "alert-danger" : "alert-success"}`}
>
<small className="mb-0">
{error || "Expression is correct"}
</small>
{showReloadButton && (
<button
className="btn btn-light btn-sm ms-2 border"
onClick={() => {
onReloadSampleData();
}}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Section: Input and Output Areas */}
<div className="row flex-grow-1" style={{ minHeight: 0 }}>
{/* Left Panel: JSON Data Input */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header d-flex justify-content-between align-items-center py-2">
<h6 className="mb-0">
<i className="bi bi-file-earmark-code me-2"></i>
JSON Data
</h6>
<div> <div>
<button <button
className="btn btn-outline-success btn-sm me-2" className="btn btn-outline-success btn-sm me-2"
@@ -215,52 +262,16 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
</button> </button>
</div> </div>
</div> </div>
<div className="card-body"> <div
<input className="card-body flex-grow-1 d-flex flex-column"
type="text" style={{ minHeight: 0 }}
className={`form-control jmespath-input ${error ? 'error' : 'success'}`} >
value={jmespathExpression}
onChange={handleJmespathChange}
placeholder="Enter JMESPath expression (e.g., people[*].name)"
/>
<div className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? 'alert-danger' : 'alert-success'}`}>
<small className="mb-0">{error || 'Expression is correct'}</small>
{showReloadButton && (
<button
className="btn btn-light btn-sm ms-2 border"
onClick={() => {
onReloadSampleData();
}}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Section: Input and Output Areas */}
<div className="row flex-grow-1" style={{ minHeight: 0 }}>
{/* Left Panel: JSON Data Input */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-file-earmark-code me-2"></i>
JSON Data
</h6>
</div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
<textarea <textarea
className={`form-control json-input flex-grow-1 ${jsonError ? 'error' : 'success'}`} className={`form-control json-input flex-grow-1 ${jsonError ? "error" : "success"}`}
value={jsonData} value={jsonData}
onChange={handleJsonChange} onChange={handleJsonChange}
placeholder="Enter JSON data here..." placeholder="Enter JSON data here..."
style={{ minHeight: 0, resize: 'none' }} style={{ minHeight: 0, resize: "none" }}
/> />
{jsonError && ( {jsonError && (
<div className="alert alert-danger mt-2 mb-0"> <div className="alert alert-danger mt-2 mb-0">
@@ -274,19 +285,42 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
{/* Right Panel: Results */} {/* Right Panel: Results */}
<div className="col-md-6"> <div className="col-md-6">
<div className="card h-100 d-flex flex-column"> <div className="card h-100 d-flex flex-column">
<div className="card-header py-2"> <div className="card-header py-2 d-flex justify-content-between align-items-center">
<h6 className="mb-0"> <h6 className="mb-0">
<i className="bi bi-output me-2"></i> <i className="bi bi-output me-2"></i>
Results Results
</h6> </h6>
<div>
<button
className={`btn btn-sm me-2 ${copySuccess ? "btn-success" : "btn-outline-secondary"}`}
onClick={copyToClipboard}
disabled={!result || result === "null"}
title="Copy result to clipboard"
>
<i className={`bi ${copySuccess ? "bi-check-lg" : "bi-clipboard"} me-1`}></i>
{copySuccess ? "Copied!" : "Copy"}
</button>
<button
className="btn btn-outline-secondary btn-sm"
onClick={downloadResult}
disabled={!result || result === "null"}
title="Download result as JSON file"
>
<i className="bi bi-download me-1"></i>
Download
</button>
</div>
</div> </div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}> <div
className="card-body flex-grow-1 d-flex flex-column"
style={{ minHeight: 0 }}
>
<textarea <textarea
className="form-control result-output flex-grow-1" className="form-control result-output flex-grow-1"
value={result} value={result}
readOnly readOnly
placeholder="Results will appear here..." placeholder="Results will appear here..."
style={{ minHeight: 0, resize: 'none' }} style={{ minHeight: 0, resize: "none" }}
/> />
</div> </div>
</div> </div>
@@ -296,4 +330,4 @@ function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleD
); );
} }
export default MainPage; export default MainPage;

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,
},
});