diff --git a/package-lock.json b/package-lock.json index 778cf7d205..06055874e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "phoenix", - "version": "4.1.1-0", + "version": "4.1.2-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "phoenix", - "version": "4.1.1-0", + "version": "4.1.2-0", "dependencies": { "@bugsnag/js": "^7.18.0", "@floating-ui/dom": "^0.5.4", @@ -58,6 +58,7 @@ "gulp-useref": "^5.0.0", "gulp-webserver": "^0.9.1", "gulp-zip": "^5.1.0", + "http-proxy": "^1.18.1", "http-server": "14.1.0", "husky": "^7.0.4", "jasmine-core": "^4.2.0", diff --git a/package.json b/package.json index 1c70018d64..431f449548 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "gulp-useref": "^5.0.0", "gulp-webserver": "^0.9.1", "gulp-zip": "^5.1.0", + "http-proxy": "^1.18.1", "http-server": "14.1.0", "husky": "^7.0.4", "jasmine-core": "^4.2.0", @@ -71,10 +72,10 @@ "_patchVersionBump": "gulp patchVersionBump", "_minorVersionBump": "gulp minorVersionBump", "_majorVersionBump": "gulp majorVersionBump", - "serve": "http-server . -p 8000 -c-1", + "serve": "node serve-proxy.js . -p 8000 -c-1", "_serveWithWebCacheHelp": "echo !!!Make sure to npm run release:dev/stageing/prod before testing the cache!!!", "serveWithWebCache": "npm run _releaseWebCache && npm run _serveWithWebCacheHelp && http-server ./dist -p 8000 -c-1", - "serveExternal": "http-server . -p 8000 -a 0.0.0.0 --log-ip true -c-1", + "serveExternal": "node serve-proxy.js . -p 8000 -a 0.0.0.0 --log-ip -c-1", "createJSDocs": "node build/api-docs-generator.js && git add docs", "_translateStrings": "gulp translateStrings", "_minify": "r.js -o require.min.config.js && echo this is untested see https://stackoverflow.com/questions/14337970/minifying-requirejs-javascript-codebase-to-a-single-file" diff --git a/serve-proxy.js b/serve-proxy.js new file mode 100644 index 0000000000..93ca243ced --- /dev/null +++ b/serve-proxy.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +const http = require('http'); +const https = require('https'); +const url = require('url'); +const path = require('path'); +const fs = require('fs'); +const httpProxy = require('http-proxy'); + +// Default configuration +let config = { + port: 8000, + host: '0.0.0.0', + root: process.cwd(), + cache: false, + cors: true, + silent: false +}; + +// Parse command line arguments +function parseArgs() { + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-p' && args[i + 1]) { + config.port = parseInt(args[i + 1]); + i++; + } else if (arg === '-a' && args[i + 1]) { + config.host = args[i + 1]; + i++; + } else if (arg === '-c-1') { + config.cache = false; + } else if (arg === '-c' && args[i + 1]) { + config.cache = parseInt(args[i + 1]) > 0; + i++; + } else if (arg === '--cors') { + config.cors = true; + } else if (arg === '-S' || arg === '--silent') { + config.silent = true; + } else if (arg === '--log-ip') { + config.logIp = true; + } else if (!arg.startsWith('-')) { + config.root = path.resolve(arg); + } + } +} + +// Create proxy server +const proxy = httpProxy.createProxyServer({ + changeOrigin: true, + secure: true, + followRedirects: true +}); + +// Handle proxy errors +proxy.on('error', (err, req, res) => { + console.error('Proxy Error:', err.message); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Proxy Error', message: err.message })); + } +}); + +// Modify proxy request headers +proxy.on('proxyReq', (proxyReq, req, res) => { + // Transform localhost:8000 to appear as phcode.dev domain + const originalHost = req.headers.host; + const originalReferer = req.headers.referer; + const originalOrigin = req.headers.origin; + + // Set target host + proxyReq.setHeader('Host', 'account.phcode.dev'); + + // Transform referer from localhost:8000 to phcode.dev + if (originalReferer && originalReferer.includes('localhost:8000')) { + const newReferer = originalReferer.replace(/localhost:8000/g, 'phcode.dev'); + proxyReq.setHeader('Referer', newReferer); + } else if (!originalReferer) { + proxyReq.setHeader('Referer', 'https://phcode.dev/'); + } + + // Transform origin from localhost:8000 to phcode.dev + if (originalOrigin && originalOrigin.includes('localhost:8000')) { + const newOrigin = originalOrigin.replace(/localhost:8000/g, 'phcode.dev'); + proxyReq.setHeader('Origin', newOrigin); + } else if (!originalOrigin) { + proxyReq.setHeader('Origin', 'https://phcode.dev'); + } + + // Ensure HTTPS scheme + proxyReq.setHeader('X-Forwarded-Proto', 'https'); + proxyReq.setHeader('X-Forwarded-For', req.connection.remoteAddress); + +}); + +// Modify proxy response headers +proxy.on('proxyRes', (proxyRes, req, res) => { + // Pass through cache control and other security headers + // But translate any domain references back to localhost for the browser + + const setCookieHeader = proxyRes.headers['set-cookie']; + if (setCookieHeader) { + // Transform any phcode.dev domain cookies back to localhost + const modifiedCookies = setCookieHeader.map(cookie => { + return cookie.replace(/domain=\.?phcode\.dev/gi, 'domain=localhost'); + }); + proxyRes.headers['set-cookie'] = modifiedCookies; + } + + // Ensure CORS headers if needed + if (config.cors) { + proxyRes.headers['Access-Control-Allow-Origin'] = '*'; + proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'; + proxyRes.headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control'; + } +}); + +// Get MIME type based on file extension +function getMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject' + }; + return mimeTypes[ext] || 'application/octet-stream'; +} + +// Serve static files +function serveStaticFile(req, res, filePath) { + fs.stat(filePath, (err, stats) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('File not found'); + return; + } + + if (stats.isDirectory()) { + // Try to serve index.html from directory + const indexPath = path.join(filePath, 'index.html'); + fs.stat(indexPath, (err, indexStats) => { + if (!err && indexStats.isFile()) { + serveStaticFile(req, res, indexPath); + } else { + // List directory contents + fs.readdir(filePath, (err, files) => { + if (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading directory'); + return; + } + + const html = ` + + + Directory listing + +

Directory listing for ${req.url}

+ + + + `; + + const headers = { + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(html) + }; + + if (!config.cache) { + headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; + headers['Pragma'] = 'no-cache'; + headers['Expires'] = '0'; + } + + if (config.cors) { + headers['Access-Control-Allow-Origin'] = '*'; + headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'; + headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control'; + } + + res.writeHead(200, headers); + res.end(html); + }); + } + }); + return; + } + + // Serve file + const mimeType = getMimeType(filePath); + const headers = { + 'Content-Type': mimeType, + 'Content-Length': stats.size + }; + + if (!config.cache) { + headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; + headers['Pragma'] = 'no-cache'; + headers['Expires'] = '0'; + } + + if (config.cors) { + headers['Access-Control-Allow-Origin'] = '*'; + headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'; + headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control'; + } + + res.writeHead(200, headers); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + + stream.on('error', (err) => { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading file'); + }); + }); +} + +// Create HTTP server +const server = http.createServer((req, res) => { + const parsedUrl = url.parse(req.url, true); + + // Handle CORS preflight + if (req.method === 'OPTIONS' && config.cors) { + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control' + }); + res.end(); + return; + } + + // Check if this is a proxy request + if (parsedUrl.pathname.startsWith('/proxy/accounts')) { + // Extract the path after /proxy/accounts + const targetPath = parsedUrl.pathname.replace('/proxy/accounts', ''); + const originalUrl = req.url; + + // Modify the request URL for the proxy + req.url = targetPath + (parsedUrl.search || ''); + + if (!config.silent) { + console.log(`[PROXY] ${req.method} ${originalUrl} -> https://account.phcode.dev${req.url}`); + } + + // Proxy the request + proxy.web(req, res, { + target: 'https://account.phcode.dev', + changeOrigin: true, + secure: true + }); + return; + } + + // Serve static files + let filePath = path.join(config.root, parsedUrl.pathname); + + // Security: prevent directory traversal + const normalizedPath = path.normalize(filePath); + if (!normalizedPath.startsWith(config.root)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden'); + return; + } + + if (!config.silent) { + const clientIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}${config.logIp ? ` (${clientIp})` : ''}`); + } + + serveStaticFile(req, res, filePath); +}); + +// Parse arguments and start server +parseArgs(); + +server.listen(config.port, config.host, () => { + if (!config.silent) { + console.log(`Starting up http-server, serving ${config.root}`); + console.log(`Available on:`); + console.log(` http://${config.host === '0.0.0.0' ? 'localhost' : config.host}:${config.port}`); + console.log(`Proxy routes:`); + console.log(` /proxy/accounts/* -> https://account.phcode.dev/*`); + console.log('Hit CTRL-C to stop the server'); + } +}); + +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('\nShutting down the server...'); + server.close(() => { + process.exit(0); + }); +}); + +process.on('SIGTERM', () => { + console.log('\nShutting down the server...'); + server.close(() => { + process.exit(0); + }); +}); \ No newline at end of file