feat: redirect router (302 to VM) instead of S3 stream + bump 0.0.3

This commit is contained in:
“Naeel”
2026-09-02 18:36:47 +03:00
parent 658e6e9c7f
commit 5360893b2a
5 changed files with 81 additions and 1504 deletions
+59 -105
View File
@@ -1,125 +1,79 @@
'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.2';
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 PORT = Number(process.env.PORT) || 3000;
const VM_DOCS_BASE_URL = (process.env.VM_DOCS_BASE_URL || '').replace(/\/+$/, '');
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
});
const STAND_RE = /^[A-Za-z0-9._-]+$/;
function getDocsCandidates(urlPath) {
const relativePath = decodeURIComponent(urlPath.replace(/^\/+/, ''));
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 send(res, status, body, headers = {}) {
const data = Buffer.from(String(body));
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': data.length,
...headers,
});
res.end(data);
}
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));
}
function redirect(res, location) {
send(res, 302, '', { Location: location });
}
const server = http.createServer((req, res) => {
if (req.url === '/health') {
health(req, res);
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, 'Method Not Allowed');
return;
}
docsHandler(req, res);
let pathname;
try {
pathname = decodeURIComponent(req.url.split('?')[0]);
} catch (err) {
send(res, 400, 'Bad Request');
return;
}
if (pathname === '/health') {
send(res, 200, 'ok');
return;
}
if (pathname === '/') {
if (!VM_DOCS_BASE_URL) {
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
return;
}
redirect(res, VM_DOCS_BASE_URL + '/');
return;
}
if (!VM_DOCS_BASE_URL) {
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
return;
}
const segments = pathname.split('/').filter((s) => s !== '');
if (segments.some((s) => s === '..')) {
send(res, 400, 'Bad Request');
return;
}
const stand = segments[0];
if (!stand || !STAND_RE.test(stand)) {
send(res, 404, 'Not Found');
return;
}
redirect(res, VM_DOCS_BASE_URL + pathname);
});
if (require.main === module) {
server.listen(PORT, () => {
console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`);
console.log(`redirect router listening on :${PORT}`);
console.log(`VM_DOCS_BASE_URL=${VM_DOCS_BASE_URL || '(not set)'}`);
});
}
module.exports = {
getDocsCandidates,
contentTypeFor,
health,
docsHandler,
server,
s3,
};
module.exports = { server, send, redirect };