import express from "express"; import fetch from "node-fetch"; import { CloudWatchLogsClient, DescribeLogGroupsCommand, DescribeLogStreamsCommand, FilterLogEventsCommand } from "@aws-sdk/client-cloudwatch-logs"; import { GetSecretValueCommand, ListSecretsCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { simpleParser } from "mailparser"; const app = express(); const PORT = Number(process.env.PORT || 3334); const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566"; const CLOUDWATCH_REGION = process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1"; const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development"; const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000); const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200); const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION; const LOCALSTACK_CREDENTIALS = { accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test", secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test" }; const cloudWatchLogsClient = new CloudWatchLogsClient({ region: CLOUDWATCH_REGION, endpoint: CLOUDWATCH_ENDPOINT, credentials: LOCALSTACK_CREDENTIALS }); const secretsManagerClient = new SecretsManagerClient({ region: SECRETS_REGION, endpoint: SECRETS_ENDPOINT, credentials: LOCALSTACK_CREDENTIALS }); app.use((req, res, next) => { res.set("Cache-Control", "no-store"); next(); }); app.get("/", (req, res) => { res.type("html").send(renderHtml()); }); app.get("/app.js", (req, res) => { res.type("application/javascript").send(`(${clientApp.toString()})(${JSON.stringify(getClientConfig())});`); }); app.get("/health", (req, res) => { res.json({ ok: true, endpoint: SES_ENDPOINT, endpoints: { ses: SES_ENDPOINT, cloudWatchLogs: CLOUDWATCH_ENDPOINT, secretsManager: SECRETS_ENDPOINT }, port: PORT, defaultRefreshMs: DEFAULT_REFRESH_MS }); }); app.get("/api/messages", async (req, res) => { try { res.json(await loadMessages()); } catch (error) { console.error("Error fetching messages:", error); res.status(502).json({ error: "Unable to fetch messages from LocalStack SES", details: error.message, endpoint: SES_ENDPOINT }); } }); app.get("/api/messages/:id/raw", async (req, res) => { try { const message = await findSesMessageById(req.params.id); if (!message) { res.status(404).type("text/plain").send("Message not found"); return; } res.type("text/plain").send(message.RawData || ""); } catch (error) { console.error("Error fetching raw message:", error); res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`); } }); app.get("/api/messages/:id/attachments/:index", async (req, res) => { try { const attachmentIndex = Number.parseInt(req.params.index, 10); if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) { res.status(400).type("text/plain").send("Attachment index must be a non-negative integer"); return; } const parsed = await parseSesMessageById(req.params.id); if (!parsed) { res.status(404).type("text/plain").send("Message not found"); return; } const attachment = parsed.attachments?.[attachmentIndex]; if (!attachment) { res.status(404).type("text/plain").send("Attachment not found"); return; } const filename = resolveAttachmentFilename(attachment, attachmentIndex); const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || ""); res.setHeader("Content-Type", attachment.contentType || "application/octet-stream"); res.setHeader("Content-Disposition", buildAttachmentDisposition(filename)); res.setHeader("Content-Length", String(content.length)); res.send(content); } catch (error) { console.error("Error downloading attachment:", error); res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`); } }); app.get("/api/logs/groups", async (req, res) => { try { const groups = await loadLogGroups(); res.json({ endpoint: CLOUDWATCH_ENDPOINT, region: CLOUDWATCH_REGION, defaultGroup: CLOUDWATCH_DEFAULT_GROUP, groups }); } catch (error) { console.error("Error fetching log groups:", error); res.status(502).json({ error: "Unable to fetch CloudWatch log groups from LocalStack", details: error.message, endpoint: CLOUDWATCH_ENDPOINT }); } }); app.get("/api/logs/streams", async (req, res) => { try { const logGroupName = String(req.query.group || ""); if (!logGroupName) { res.status(400).json({ error: "Query parameter 'group' is required" }); return; } res.json({ logGroupName, streams: await loadLogStreams(logGroupName) }); } catch (error) { console.error("Error fetching log streams:", error); res.status(502).json({ error: "Unable to fetch CloudWatch log streams from LocalStack", details: error.message, endpoint: CLOUDWATCH_ENDPOINT }); } }); app.get("/api/logs/events", async (req, res) => { try { const logGroupName = String(req.query.group || ""); const logStreamName = String(req.query.stream || ""); const windowMs = clampNumber(req.query.windowMs, CLOUDWATCH_DEFAULT_WINDOW_MS, 60 * 1000, 24 * 60 * 60 * 1000); const limit = clampNumber(req.query.limit, CLOUDWATCH_DEFAULT_LIMIT, 25, 500); if (!logGroupName) { res.status(400).json({ error: "Query parameter 'group' is required" }); return; } res.json(await loadLogEvents({ logGroupName, logStreamName, windowMs, limit })); } catch (error) { console.error("Error fetching log events:", error); res.status(502).json({ error: "Unable to fetch CloudWatch log events from LocalStack", details: error.message, endpoint: CLOUDWATCH_ENDPOINT }); } }); app.get("/api/secrets", async (req, res) => { try { res.json(await loadSecrets()); } catch (error) { console.error("Error fetching secrets:", error); res.status(502).json({ error: "Unable to fetch Secrets Manager secrets from LocalStack", details: error.message, endpoint: SECRETS_ENDPOINT }); } }); app.get("/api/secrets/value", async (req, res) => { try { const secretId = String(req.query.id || ""); if (!secretId) { res.status(400).json({ error: "Query parameter 'id' is required" }); return; } res.json(await loadSecretValue(secretId)); } catch (error) { if (error?.name === "ResourceNotFoundException") { res.status(404).json({ error: "Secret not found", details: error.message, endpoint: SECRETS_ENDPOINT }); return; } console.error("Error fetching secret value:", error); res.status(502).json({ error: "Unable to fetch Secrets Manager value from LocalStack", details: error.message, endpoint: SECRETS_ENDPOINT }); } }); async function loadMessages() { const startedAt = Date.now(); const sesMessages = await fetchSesMessages(); const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index))); messages.sort((left, right) => { if ((right.timestampMs || 0) !== (left.timestampMs || 0)) { return (right.timestampMs || 0) - (left.timestampMs || 0); } return right.index - left.index; }); return { endpoint: SES_ENDPOINT, fetchedAt: new Date().toISOString(), fetchDurationMs: Date.now() - startedAt, totalMessages: messages.length, parseErrors: messages.filter((message) => Boolean(message.parseError)).length, latestMessageTimestamp: messages[0]?.timestamp || "", messages }; } async function fetchSesMessages() { const response = await fetch(SES_ENDPOINT, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!response.ok) { throw new Error(`SES endpoint responded with ${response.status}`); } const data = await response.json(); return Array.isArray(data.messages) ? data.messages : []; } async function loadLogGroups() { const groups = []; let nextToken; let pageCount = 0; do { const response = await cloudWatchLogsClient.send( new DescribeLogGroupsCommand({ nextToken, limit: 50 }) ); groups.push( ...(response.logGroups || []).map((group) => ({ name: group.logGroupName || "", arn: group.arn || "", storedBytes: group.storedBytes || 0, retentionInDays: group.retentionInDays || 0, creationTime: group.creationTime || 0 })) ); nextToken = response.nextToken; pageCount += 1; } while (nextToken && pageCount < 10); return groups.sort((left, right) => left.name.localeCompare(right.name)); } async function loadLogStreams(logGroupName) { const streams = []; let nextToken; let pageCount = 0; do { const response = await cloudWatchLogsClient.send( new DescribeLogStreamsCommand({ logGroupName, descending: true, orderBy: "LastEventTime", nextToken, limit: 50 }) ); streams.push( ...(response.logStreams || []).map((stream) => ({ name: stream.logStreamName || "", arn: stream.arn || "", lastEventTimestamp: stream.lastEventTimestamp || 0, lastIngestionTime: stream.lastIngestionTime || 0, storedBytes: stream.storedBytes || 0 })) ); nextToken = response.nextToken; pageCount += 1; } while (nextToken && pageCount < 6 && streams.length < 250); return streams; } async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) { const startedAt = Date.now(); const eventMap = new Map(); const startTime = Date.now() - windowMs; let nextToken; let previousToken = ""; let pageCount = 0; let searchedLogStreams = 0; do { const response = await cloudWatchLogsClient.send( new FilterLogEventsCommand({ logGroupName, logStreamNames: logStreamName ? [logStreamName] : undefined, startTime, endTime: Date.now(), limit, nextToken }) ); for (const event of response.events || []) { const id = event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`; if (!eventMap.has(id)) { const message = String(event.message || "").trim(); eventMap.set(id, { id, timestamp: event.timestamp || 0, ingestionTime: event.ingestionTime || 0, logStreamName: event.logStreamName || "", message, preview: buildLogPreview(message) }); } } searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length); previousToken = nextToken || ""; nextToken = response.nextToken; pageCount += 1; } while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit); const events = [...eventMap.values()] .sort((left, right) => { if ((right.timestamp || 0) !== (left.timestamp || 0)) { return (right.timestamp || 0) - (left.timestamp || 0); } return left.logStreamName.localeCompare(right.logStreamName); }) .slice(0, limit); return { endpoint: CLOUDWATCH_ENDPOINT, region: CLOUDWATCH_REGION, logGroupName, logStreamName, fetchDurationMs: Date.now() - startedAt, latestTimestamp: events[0]?.timestamp || 0, searchedLogStreams, totalEvents: events.length, events }; } async function loadSecrets() { const startedAt = Date.now(); const secrets = []; let nextToken; let pageCount = 0; do { const response = await secretsManagerClient.send( new ListSecretsCommand({ NextToken: nextToken, MaxResults: 50 }) ); secrets.push( ...(response.SecretList || []).map((secret, index) => ({ id: secret.ARN || secret.Name || `secret-${index}`, name: secret.Name || "Unnamed secret", arn: secret.ARN || "", description: secret.Description || "", createdDate: normalizeTimestamp(secret.CreatedDate), lastChangedDate: normalizeTimestamp(secret.LastChangedDate), lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate), deletedDate: normalizeTimestamp(secret.DeletedDate), primaryRegion: secret.PrimaryRegion || "", owningService: secret.OwningService || "", rotationEnabled: Boolean(secret.RotationEnabled), versionCount: Object.keys(secret.SecretVersionsToStages || {}).length, tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0, tags: (secret.Tags || []) .map((tag) => ({ key: tag.Key || "", value: tag.Value || "" })) .filter((tag) => tag.key || tag.value) })) ); nextToken = response.NextToken; pageCount += 1; } while (nextToken && pageCount < 10 && secrets.length < 500); secrets.sort((left, right) => { const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0; const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0; if (rightTime !== leftTime) { return rightTime - leftTime; } return left.name.localeCompare(right.name); }); return { endpoint: SECRETS_ENDPOINT, region: SECRETS_REGION, fetchedAt: new Date().toISOString(), fetchDurationMs: Date.now() - startedAt, totalSecrets: secrets.length, latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "", secrets }; } async function loadSecretValue(secretId) { const startedAt = Date.now(); const response = await secretsManagerClient.send( new GetSecretValueCommand({ SecretId: secretId }) ); const secretBinary = response.SecretBinary ? typeof response.SecretBinary === "string" ? response.SecretBinary : Buffer.from(response.SecretBinary).toString("base64") : ""; return { endpoint: SECRETS_ENDPOINT, region: SECRETS_REGION, fetchDurationMs: Date.now() - startedAt, id: secretId, name: response.Name || "", arn: response.ARN || "", versionId: response.VersionId || "", versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [], createdDate: normalizeTimestamp(response.CreatedDate), secretString: typeof response.SecretString === "string" ? response.SecretString : "", secretBinary }; } async function findSesMessageById(id) { const messages = await fetchSesMessages(); return messages.find((message, index) => resolveMessageId(message, index) === id) || null; } async function parseSesMessageById(id) { const message = await findSesMessageById(id); if (!message) { return null; } return simpleParser(message.RawData || ""); } async function toMessageViewModel(message, index) { const id = resolveMessageId(message, index); try { const parsed = await simpleParser(message.RawData || ""); const textContent = normalizeText(parsed.text || ""); const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || ""); const timestamp = normalizeTimestamp(message.Timestamp || parsed.date); return { id, index, from: formatAddressList(parsed.from) || message.Source || "Unknown sender", to: formatAddressList(parsed.to) || "No To Address", replyTo: formatAddressList(parsed.replyTo), subject: parsed.subject || "No Subject", region: message.Region || "", timestamp, timestampMs: timestamp ? Date.parse(timestamp) : 0, messageId: parsed.messageId || "", rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), attachmentCount: parsed.attachments.length, attachments: parsed.attachments.map((attachment, attachmentIndex) => ({ index: attachmentIndex, filename: resolveAttachmentFilename(attachment, attachmentIndex), contentType: attachment.contentType || "application/octet-stream", size: attachment.size || 0 })), preview: buildPreview(textContent, renderedHtml), textContent, renderedHtml, hasHtml: Boolean(renderedHtml), parseError: "" }; } catch (error) { return { id, index, from: message.Source || "Unknown sender", to: "Unknown recipient", replyTo: "", subject: "Unable to parse message", region: message.Region || "", timestamp: normalizeTimestamp(message.Timestamp), timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0, messageId: "", rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), attachmentCount: 0, attachments: [], preview: "This message could not be parsed. Open the raw view to inspect the MIME source.", textContent: "", renderedHtml: "", hasHtml: false, parseError: error.message }; } } function resolveMessageId(message, index = 0) { return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; } function resolveAttachmentFilename(attachment, index = 0) { if (attachment?.filename) { return attachment.filename; } return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`; } function attachmentExtension(contentType) { const normalized = String(contentType || "") .split(";")[0] .trim() .toLowerCase(); return ( { "application/json": ".json", "application/pdf": ".pdf", "application/zip": ".zip", "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "text/calendar": ".ics", "text/csv": ".csv", "text/html": ".html", "text/plain": ".txt" }[normalized] || "" ); } function buildAttachmentDisposition(filename) { const fallback = String(filename || "attachment") .replace(/[^\x20-\x7e]/g, "_") .replace(/["\\]/g, "_"); return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`; } function normalizeTimestamp(value) { if (!value) { return ""; } const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? "" : date.toISOString(); } function normalizeText(value) { return String(value || "") .replace(/\r\n/g, "\n") .trim(); } function buildPreview(textContent, renderedHtml) { const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim(); if (!source) { return "No message preview available."; } return source.length > 220 ? `${source.slice(0, 217)}...` : source; } function buildLogPreview(message) { const source = String(message || "") .replace(/\s+/g, " ") .trim(); if (!source) { return "No log preview available."; } return source.length > 220 ? `${source.slice(0, 217)}...` : source; } function clampNumber(value, fallback, min, max) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return fallback; } return Math.min(Math.max(parsed, min), max); } function buildRenderedHtml(html) { if (!html) { return ""; } const value = String(html); const hasDocument = /]/i.test(value) || /
LocalStack Toolbox