460 lines
14 KiB
JavaScript
460 lines
14 KiB
JavaScript
const express = require("express");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const os = require("os");
|
|
const { v4: uuidv4 } = require("uuid");
|
|
const { parseArgs } = require("util");
|
|
|
|
// Environment configuration
|
|
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
|
|
const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; // 1MB
|
|
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
|
|
|
|
// Utility functions for encryption
|
|
function encrypt(data, key) {
|
|
try {
|
|
const algorithm = "aes-256-gcm";
|
|
const iv = crypto.randomBytes(16);
|
|
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
|
cipher.setAAD(Buffer.from("session-data"));
|
|
|
|
let encrypted = cipher.update(JSON.stringify(data), "utf8");
|
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
|
|
const authTag = cipher.getAuthTag();
|
|
|
|
return {
|
|
iv: iv.toString("hex"),
|
|
data: encrypted.toString("hex"),
|
|
tag: authTag.toString("hex"),
|
|
};
|
|
} catch (error) {
|
|
console.error("⚠️ Encryption exception:", {
|
|
message: error.message,
|
|
algorithm: "aes-256-gcm",
|
|
keyLength: key ? key.length : "undefined",
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
throw new Error(`Encryption failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function decrypt(encryptedObj, key) {
|
|
try {
|
|
const algorithm = "aes-256-gcm";
|
|
const iv = Buffer.from(encryptedObj.iv, "hex");
|
|
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
|
decipher.setAAD(Buffer.from("session-data"));
|
|
decipher.setAuthTag(Buffer.from(encryptedObj.tag, "hex"));
|
|
|
|
let decrypted = decipher.update(
|
|
Buffer.from(encryptedObj.data, "hex"),
|
|
null,
|
|
"utf8",
|
|
);
|
|
decrypted += decipher.final("utf8");
|
|
|
|
return JSON.parse(decrypted);
|
|
} catch (error) {
|
|
console.error("⚠️ Decryption exception:", {
|
|
message: error.message,
|
|
algorithm: "aes-256-gcm",
|
|
keyLength: key ? key.length : "undefined",
|
|
hasIV: !!encryptedObj.iv,
|
|
hasTag: !!encryptedObj.tag,
|
|
hasData: !!encryptedObj.data,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
throw new Error(`Decryption failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function isValidApiKey(apiKey) {
|
|
return typeof apiKey === "string" && /^[0-9a-f]{32}$/i.test(apiKey);
|
|
}
|
|
|
|
function getSessionId(apiKey) {
|
|
return crypto.createHash("sha256").update(apiKey).digest("hex");
|
|
}
|
|
|
|
function generateSalt() {
|
|
return crypto.randomBytes(32);
|
|
}
|
|
|
|
function deriveKey(apiKey, salt) {
|
|
return crypto.pbkdf2Sync(apiKey, salt, 100000, 32, "sha256");
|
|
}
|
|
|
|
// Create Express app
|
|
function createApp(devMode = false) {
|
|
const app = express();
|
|
|
|
// Trust proxy to get real client IP (needed for localhost detection)
|
|
app.set("trust proxy", true);
|
|
|
|
// Middleware
|
|
app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
|
|
app.use(express.static(path.join(__dirname, "build")));
|
|
|
|
// Dev mode request logging middleware
|
|
if (devMode) {
|
|
app.use((req, res, next) => {
|
|
const timestamp = new Date().toISOString();
|
|
console.log(`📨 [${timestamp}] ${req.method} ${req.path}`);
|
|
if (req.method !== "GET" && Object.keys(req.body).length > 0) {
|
|
const bodySize = Buffer.byteLength(JSON.stringify(req.body), "utf8");
|
|
console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
|
|
}
|
|
|
|
const originalJson = res.json;
|
|
res.json = function (data) {
|
|
console.log(` ✓ Response: ${res.statusCode}`);
|
|
return originalJson.call(this, data);
|
|
};
|
|
next();
|
|
});
|
|
}
|
|
|
|
// Session storage
|
|
const sessions = new Map();
|
|
|
|
// Cleanup expired sessions
|
|
function cleanupExpiredSessions() {
|
|
const now = Date.now();
|
|
for (const [sessionId, session] of sessions.entries()) {
|
|
if (now - session.createdAt > MAX_SESSION_TTL) {
|
|
sessions.delete(sessionId);
|
|
console.log(
|
|
`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run cleanup every 5 minutes
|
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
|
|
|
|
// API endpoints
|
|
app.post("/api/v1/upload", (req, res) => {
|
|
try {
|
|
const apiKey = req.headers["x-api-key"];
|
|
|
|
// Validate API key header
|
|
if (!apiKey || !isValidApiKey(apiKey)) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Invalid or missing X-API-Key header" });
|
|
}
|
|
|
|
// Cleanup expired sessions before checking limits
|
|
cleanupExpiredSessions();
|
|
|
|
// Check session limits
|
|
if (sessions.size >= MAX_SESSIONS) {
|
|
return res.status(429).json({
|
|
error: "Maximum number of sessions reached. Please try again later.",
|
|
maxSessions: MAX_SESSIONS,
|
|
currentSessions: sessions.size,
|
|
});
|
|
}
|
|
|
|
const uploadedData = req.body;
|
|
|
|
// Validate that it's valid JSON
|
|
if (!uploadedData || typeof uploadedData !== "object") {
|
|
return res.status(400).json({ error: "Invalid JSON data" });
|
|
}
|
|
|
|
// Check data size
|
|
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), "utf8");
|
|
if (dataSize > MAX_SAMPLE_SIZE) {
|
|
return res.status(413).json({
|
|
error: "Sample data too large",
|
|
maxSize: MAX_SAMPLE_SIZE,
|
|
receivedSize: dataSize,
|
|
});
|
|
}
|
|
|
|
const sessionId = getSessionId(apiKey);
|
|
const salt = generateSalt();
|
|
const key = deriveKey(apiKey, salt);
|
|
const stateGuid = uuidv4();
|
|
|
|
// Encrypt and store session data
|
|
const encryptedData = encrypt(uploadedData, key);
|
|
|
|
sessions.set(sessionId, {
|
|
salt: salt.toString("hex"),
|
|
encryptedData,
|
|
state: stateGuid,
|
|
createdAt: Date.now(),
|
|
accessed: false,
|
|
});
|
|
|
|
console.log(
|
|
`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`,
|
|
);
|
|
|
|
res.json({ message: "OK" });
|
|
} catch (error) {
|
|
console.error("⚠️ Upload endpoint exception occurred:", {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
sessionCount: sessions.size,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
// Provide more specific error messages based on error type
|
|
if (error.name === "SyntaxError") {
|
|
return res.status(400).json({
|
|
error: "Invalid JSON data format",
|
|
details: "The uploaded data could not be parsed as valid JSON",
|
|
});
|
|
} else if (error.message.includes("encrypt")) {
|
|
return res.status(500).json({
|
|
error: "Encryption failed",
|
|
details:
|
|
"Failed to encrypt session data. Please try again with a new API key.",
|
|
});
|
|
} else if (error.message.includes("PBKDF2")) {
|
|
return res.status(500).json({
|
|
error: "Key derivation failed",
|
|
details: "Failed to derive encryption key from API key",
|
|
});
|
|
} else {
|
|
return res.status(500).json({
|
|
error: "Upload processing failed",
|
|
details:
|
|
"An unexpected error occurred while processing your upload. Please try again.",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get("/api/v1/sample", (req, res) => {
|
|
try {
|
|
const apiKey = req.headers["x-api-key"];
|
|
|
|
// Validate API key header
|
|
if (!apiKey || !isValidApiKey(apiKey)) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Invalid or missing X-API-Key header" });
|
|
}
|
|
|
|
const sessionId = getSessionId(apiKey);
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return res.json(null);
|
|
}
|
|
|
|
// Decrypt data
|
|
const salt = Buffer.from(session.salt, "hex");
|
|
const key = deriveKey(apiKey, salt);
|
|
const decryptedData = decrypt(session.encryptedData, key);
|
|
|
|
// Remove session after first access (one-time use)
|
|
sessions.delete(sessionId);
|
|
console.log(
|
|
`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`,
|
|
);
|
|
|
|
res.json(decryptedData);
|
|
} catch (error) {
|
|
console.error("⚠️ Sample retrieval exception occurred:", {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
sessionCount: sessions.size,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
// Provide more specific error messages based on error type
|
|
if (error.message.includes("decrypt")) {
|
|
return res.status(500).json({
|
|
error: "Decryption failed",
|
|
details:
|
|
"Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.",
|
|
});
|
|
} else if (error.message.includes("JSON")) {
|
|
return res.status(500).json({
|
|
error: "Data corruption detected",
|
|
details:
|
|
"The stored session data appears to be corrupted and cannot be parsed.",
|
|
});
|
|
} else if (error.name === "TypeError") {
|
|
return res.status(500).json({
|
|
error: "Session data format error",
|
|
details: "The session data format is invalid or corrupted.",
|
|
});
|
|
} else {
|
|
return res.status(500).json({
|
|
error: "Sample retrieval failed",
|
|
details:
|
|
"An unexpected error occurred while retrieving sample data. The session may have been corrupted.",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get("/api/v1/state", (req, res) => {
|
|
try {
|
|
const apiKey = req.headers["x-api-key"];
|
|
|
|
// Validate API key header
|
|
if (!apiKey || !isValidApiKey(apiKey)) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Invalid or missing X-API-Key header" });
|
|
}
|
|
|
|
const sessionId = getSessionId(apiKey);
|
|
const session = sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
// Return null state when no session exists
|
|
return res.json({ state: null });
|
|
}
|
|
|
|
res.json({ state: session.state });
|
|
} catch (error) {
|
|
console.error("⚠️ State retrieval exception occurred:", {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
sessionCount: sessions.size,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
// Provide more specific error messages
|
|
if (error.message.includes("API key")) {
|
|
return res.status(403).json({
|
|
error: "API key processing failed",
|
|
details: "Failed to process the provided API key",
|
|
});
|
|
} else {
|
|
return res.status(500).json({
|
|
error: "State retrieval failed",
|
|
details:
|
|
"An unexpected error occurred while retrieving session state. Please try again.",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Status endpoint (no auth required) - detailed information
|
|
app.get("/api/v1/status", (req, res) => {
|
|
cleanupExpiredSessions(); // Cleanup on status check
|
|
res.json({
|
|
status: "healthy",
|
|
sessions: {
|
|
current: sessions.size,
|
|
max: MAX_SESSIONS,
|
|
available: MAX_SESSIONS - sessions.size,
|
|
},
|
|
limits: {
|
|
maxSessions: MAX_SESSIONS,
|
|
maxSampleSize: MAX_SAMPLE_SIZE,
|
|
maxSessionTTL: MAX_SESSION_TTL,
|
|
},
|
|
uptime: process.uptime(),
|
|
});
|
|
});
|
|
|
|
// Health endpoint (no auth required) - simple OK response
|
|
app.get("/api/v1/health", (req, res) => {
|
|
res.type("text/plain").send("OK");
|
|
});
|
|
|
|
// Serve React app for all other routes
|
|
app.get("*", (req, res) => {
|
|
res.sendFile(path.join(__dirname, "build", "index.html"));
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
// Start server if this file is run directly
|
|
if (require.main === module) {
|
|
const { values } = parseArgs({
|
|
options: {
|
|
"listen-addr": {
|
|
type: "string",
|
|
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 app = createApp(DEV_MODE);
|
|
const PORT = parseInt(values.port);
|
|
const HOST = values["listen-addr"];
|
|
|
|
app.listen(PORT, HOST, () => {
|
|
console.log(`JMESPath Playground Server running`);
|
|
if (DEV_MODE) {
|
|
console.log(` 🔧 Development Mode Enabled`);
|
|
}
|
|
|
|
// Show actual accessible URLs
|
|
if (HOST === "0.0.0.0") {
|
|
console.log(` Listening on all interfaces:`);
|
|
const interfaces = os.networkInterfaces();
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
for (const addr of addrs) {
|
|
if (addr.family === "IPv4" && !addr.internal) {
|
|
console.log(` http://${addr.address}:${PORT}`);
|
|
}
|
|
}
|
|
}
|
|
// Also show localhost for local access
|
|
console.log(` http://127.0.0.1:${PORT}`);
|
|
} else {
|
|
console.log(` http://${HOST}:${PORT}`);
|
|
}
|
|
|
|
console.log(`Configuration:`);
|
|
console.log(` Max Sessions: ${MAX_SESSIONS}`);
|
|
console.log(
|
|
` 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
|
|
let apiBaseUrl;
|
|
if (HOST === "0.0.0.0") {
|
|
const interfaces = os.networkInterfaces();
|
|
let firstIP = "127.0.0.1";
|
|
outer: for (const addrs of Object.values(interfaces)) {
|
|
for (const addr of addrs) {
|
|
if (addr.family === "IPv4" && !addr.internal) {
|
|
firstIP = addr.address;
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
apiBaseUrl = `http://${firstIP}:${PORT}/api/v1`;
|
|
} else {
|
|
apiBaseUrl = `http://${HOST}:${PORT}/api/v1`;
|
|
}
|
|
|
|
console.log(`API Base URL: ${apiBaseUrl}`);
|
|
});
|
|
}
|
|
|
|
module.exports = { createApp };
|