'use strict'; const http = require('http'); const path = require('path'); const { S3Client, GetObjectCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); const PORT = process.env.PORT || 3000; const VERSION = process.env.APP_VERSION || '0.0.1'; const SERVICE = 'tf-docs'; const S3_BUCKET = process.env.S3_BUCKET || ''; const DOCS_S3_PREFIX = (process.env.DOCS_S3_PREFIX || 'docs').replace(/^\/+|\/+$/g, ''); const S3_ENDPOINT = process.env.S3_ENDPOINT || ''; const CACHE_CONTROL = process.env.CACHE_CONTROL || 'public, max-age=60'; const s3 = new S3Client({ region: process.env.S3_REGION || 'us-east-1', endpoint: S3_ENDPOINT ? `https://${S3_ENDPOINT.replace(/^https?:\/\//, '')}` : undefined, forcePathStyle: true, credentials: process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY ? { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY } : undefined }); function getDocsCandidates(urlPath) { const relativePath = decodeURIComponent(urlPath.replace(/^\/docs\/?/, '')); const normalized = path.posix.normalize(`/${relativePath}`).replace(/^\/+|\/+$/g, ''); if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) { return null; } const base = `${DOCS_S3_PREFIX}/${normalized}`; const candidates = []; if (urlPath.endsWith('/')) { candidates.push(`${base}/index.html`); } else { candidates.push(base); if (!path.posix.extname(normalized)) candidates.push(`${base}/index.html`); candidates.push(`${DOCS_S3_PREFIX}/${normalized.split('/').slice(0, 3).join('/')}/index.html`); } return [...new Set(candidates)]; } function contentTypeFor(key) { const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }; return types[path.posix.extname(key).toLowerCase()] || 'application/octet-stream'; } async function docsHandler(req, res) { const candidates = getDocsCandidates(req.url.split('?')[0]); if (!candidates || !S3_BUCKET) { res.statusCode = 400; res.end('Invalid documentation path or S3_BUCKET is not configured'); return; } for (const key of candidates) { try { const result = await s3.send(new GetObjectCommand({ Bucket: S3_BUCKET, Key: key })); res.statusCode = 200; res.setHeader('Content-Type', result.ContentType || contentTypeFor(key)); res.setHeader('Cache-Control', CACHE_CONTROL); if (result.ContentLength !== undefined) res.setHeader('Content-Length', result.ContentLength); result.Body.pipe(res); return; } catch (error) { if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) { res.statusCode = 502; res.end('S3 request failed'); return; } } } res.statusCode = 404; res.end('Documentation file not found'); } async function health(_req, res) { if (!S3_BUCKET) { res.statusCode = 503; res.end('s3 unreachable: S3_BUCKET not configured'); return; } try { await s3.send(new HeadBucketCommand({ Bucket: S3_BUCKET })); res.statusCode = 200; res.end('ok'); } catch (error) { res.statusCode = 503; res.end('s3 unreachable: ' + (error && error.message ? error.message : error)); } } const server = http.createServer((req, res) => { if (req.url === '/health') { health(req, res); return; } if (req.url.startsWith('/docs/')) { docsHandler(req, res); return; } res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(`
version: ${VERSION}
`); }); if (require.main === module) { server.listen(PORT, () => { console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`); }); } module.exports = { getDocsCandidates, contentTypeFor, health, docsHandler, server, s3, };