Files
WebFTP/backend_proxy.cjs

501 lines
22 KiB
JavaScript

/**
* WebZilla 백엔드 프록시 서버 (Node.js) v1.2
*
* 기능:
* - WebSocket Proxy (Port: 8090)
* - FTP/SFTP 지원
* - 로컬 파일 시스템 탐색 (LOCAL_LIST)
* - 설정 저장 (AppData)
* - **NEW**: 포트 충돌 자동 감지 및 프로세스 종료 기능
*/
const APP_VERSION = "0.0.1";
const WebSocket = require('ws');
const ftp = require('basic-ftp');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec } = require('child_process');
const readline = require('readline');
// --- 로컬 저장소 경로 설정 (AppData 구현) ---
function getConfigDir() {
const homedir = os.homedir();
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), 'WebZilla');
} else if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', 'WebZilla');
} else {
return path.join(homedir, '.config', 'webzilla');
}
}
const configDir = getConfigDir();
if (!fs.existsSync(configDir)) {
try { fs.mkdirSync(configDir, { recursive: true }); } catch (e) { }
}
const PORT = 8090;
// --- 서버 시작 함수 (재시도 로직 포함) ---
function startServer() {
const http = require('http'); // HTTP server module required
const server = http.createServer((req, res) => {
// Parse URL to handle query strings and decoding
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = decodeURIComponent(parsedUrl.pathname);
// Special handling for executables (Download Center)
if (pathname === '/webftp.exe' || pathname === '/webftp-backend.exe') {
const exeDir = path.dirname(process.execPath);
// Fallback for dev mode -> serve from public
const targetPath = process.pkg ? path.join(exeDir, pathname) : path.join(__dirname, 'public', pathname);
if (fs.existsSync(targetPath)) {
const stat = fs.statSync(targetPath);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size,
'Content-Disposition': `attachment; filename="${path.basename(targetPath)}"`
});
const readStream = fs.createReadStream(targetPath);
readStream.pipe(res);
return;
}
}
// Static File Serving
// Handle /ftp base path (strip it)
let normalizedPath = pathname;
if (normalizedPath.startsWith('/ftp')) {
normalizedPath = normalizedPath.replace(/^\/ftp/, '') || '/';
}
let filePath = path.join(__dirname, 'dist', normalizedPath === '/' ? 'index.html' : normalizedPath);
// Prevent traversal
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
res.writeHead(403); res.end('Forbidden'); return;
}
const extname = path.extname(filePath);
// Basic MIME types
const MIME_TYPES = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon'
};
let contentType = MIME_TYPES[extname] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
// SPA fallback
if (!extname || extname === '.html') {
fs.readFile(path.join(__dirname, 'dist', 'index.html'), (err2, content2) => {
if (err2) {
// If index.html is missing (Backend Only Mode), return 404
console.log(`[404] Backend Only Mode - Missing: ${pathname}`);
res.writeHead(404); res.end('Backend Only Mode - No Frontend Assets Found');
} else {
let html = content2.toString('utf-8');
if (process.pkg) {
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}
});
} else {
console.log(`[404] File Not Found: ${pathname}`);
res.writeHead(404); res.end('File Not Found');
}
} else {
console.error(`[500] Server Error for ${pathname}:`, err.code);
res.writeHead(500); res.end(`Server Error: ${err.code}`);
}
} else {
// Determine if we need to inject script for main index.html access
if (filePath.endsWith('index.html') || extname === '.html') {
let html = content.toString('utf-8');
if (process.pkg) {
html = html.replace('</head>', '<script>window.__IS_STANDALONE__ = true;</script></head>');
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content, 'utf-8');
}
}
});
});
const wss = new WebSocket.Server({ server });
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`\n❌ 포트 ${PORT}이(가) 이미 사용 중입니다.`);
handlePortConflict();
} else {
console.error("❌ 서버 오류:", err);
process.exit(1);
}
});
server.listen(PORT, () => {
console.log(`\n🚀 WebZilla Server [v${APP_VERSION}] running at http://localhost:${PORT}`);
console.log(`📂 Config: ${configDir}`);
// Check if Frontend Assets exist (dist/index.html)
const frontendExists = fs.existsSync(path.join(__dirname, 'dist', 'index.html'));
if (frontendExists) {
console.log("✨ Frontend detected. Launching browser...");
openBrowser(`http://localhost:${PORT}/ftp/`);
} else {
console.log("🔧 Backend Only Mode. Waiting for connections...");
}
});
// WSS Connection Handling (Existing Logic)
wss.on('connection', (ws) => {
const client = new ftp.Client();
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
switch (data.command) {
case 'CONNECT':
console.log(`FTP 연결 시도: ${data.user}@${data.host}:${data.port}`);
try {
await client.access({
host: data.host,
user: data.user,
password: data.pass,
port: parseInt(data.port),
secure: false
});
ws.send(JSON.stringify({ type: 'status', status: 'connected', message: 'FTP 서버에 연결되었습니다.' }));
console.log("FTP 연결 성공");
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `연결 실패: ${err.message}` }));
}
break;
case 'LIST':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const listPath = data.path || '/';
const list = await client.list(listPath);
const files = list.map(f => ({
id: `ftp-${Date.now()}-${Math.random()}`,
name: f.name,
type: f.isDirectory ? 'FOLDER' : 'FILE',
size: f.size,
date: f.rawModifiedAt || new Date().toISOString(),
permissions: '-'
}));
ws.send(JSON.stringify({ type: 'list', files, path: listPath }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'MKD':
if (client.closed) return;
try {
await client.ensureDir(data.path);
ws.send(JSON.stringify({ type: 'success', message: '폴더 생성 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'DELE':
if (client.closed) return;
try {
if (data.isFolder) await client.removeDir(data.path);
else await client.remove(data.path);
ws.send(JSON.stringify({ type: 'success', message: '삭제 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'RENAME':
if (client.closed) return;
try {
await client.rename(data.from, data.to);
ws.send(JSON.stringify({ type: 'success', message: '이름 변경 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
break;
case 'DISCONNECT':
client.close();
ws.send(JSON.stringify({ type: 'status', status: 'disconnected', message: '연결이 종료되었습니다.' }));
break;
case 'SAVE_SITE':
// Deprecated in favor of SAVE_SITES for full sync, but kept for compatibility
try {
const sitesFile = path.join(configDir, 'sites.json');
let sites = [];
if (fs.existsSync(sitesFile)) sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8'));
sites.push(data.siteInfo);
fs.writeFileSync(sitesFile, JSON.stringify(sites, null, 2));
ws.send(JSON.stringify({ type: 'success', message: '사이트가 추가되었습니다.' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `저장 실패: ${err.message}` }));
}
break;
case 'SAVE_SITES':
try {
const sitesFile = path.join(configDir, 'sites.json');
fs.writeFileSync(sitesFile, JSON.stringify(data.sites, null, 2));
ws.send(JSON.stringify({ type: 'success', message: '사이트 목록이 저장되었습니다.' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `저장 실패: ${err.message}` }));
}
break;
case 'GET_SITES':
try {
const sitesFile = path.join(configDir, 'sites.json');
if (fs.existsSync(sitesFile)) {
const sites = JSON.parse(fs.readFileSync(sitesFile, 'utf8'));
ws.send(JSON.stringify({ type: 'sites_list', sites }));
} else {
ws.send(JSON.stringify({ type: 'sites_list', sites: [] }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `로드 실패: ${err.message}` }));
}
break;
case 'LOCAL_LIST':
try {
const targetPath = data.path || os.homedir();
const entries = fs.readdirSync(targetPath, { withFileTypes: true });
const files = entries.map(dirent => {
let size = 0;
let date = new Date().toISOString();
try {
const stats = fs.statSync(path.join(targetPath, dirent.name));
size = stats.size;
date = stats.mtime.toISOString();
} catch (e) { }
return {
id: `local-${Math.random()}`,
name: dirent.name,
type: dirent.isDirectory() ? 'FOLDER' : 'FILE',
size: size,
date: date,
permissions: '-'
};
});
ws.send(JSON.stringify({ type: 'local_list', files, path: targetPath }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `로컬 목록 실패: ${err.message}` }));
}
break;
case 'LOCAL_MKD':
try {
if (!fs.existsSync(data.path)) {
fs.mkdirSync(data.path, { recursive: true });
ws.send(JSON.stringify({ type: 'success', message: '로컬 폴더 생성 완료' }));
} else {
ws.send(JSON.stringify({ type: 'error', message: '이미 존재하는 폴더입니다.' }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `로컬 폴더 생성 실패: ${err.message}` }));
}
break;
case 'LOCAL_RENAME':
try {
fs.renameSync(data.from, data.to);
ws.send(JSON.stringify({ type: 'success', message: '이름 변경 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `이름 변경 실패: ${err.message}` }));
}
break;
case 'LOCAL_DELE':
try {
fs.rmSync(data.path, { recursive: true, force: true });
ws.send(JSON.stringify({ type: 'success', message: '삭제 완료' }));
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: `삭제 실패: ${err.message}` }));
}
break;
case 'DOWNLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
type: 'transfer_progress',
id: transferId,
bytes: info.bytes,
bytesOverall: info.bytesOverall,
name: info.name
}));
});
await client.downloadTo(localPath, remotePath);
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '다운로드 완료',
path: localPath
}));
} catch (err) {
client.trackProgress(); // Stop tracking
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `다운로드 실패: ${err.message}`
}));
}
break;
case 'UPLOAD':
if (client.closed) {
ws.send(JSON.stringify({ type: 'error', message: 'FTP 연결이 끊어져 있습니다.' }));
return;
}
try {
const { remotePath, localPath, transferId } = data;
// Progress Handler
client.trackProgress(info => {
ws.send(JSON.stringify({
type: 'transfer_progress',
id: transferId,
bytes: info.bytes,
bytesOverall: info.bytesOverall,
name: info.name
}));
});
await client.uploadFrom(localPath, remotePath);
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_success',
id: transferId,
message: '업로드 완료',
path: remotePath
}));
} catch (err) {
client.trackProgress();
ws.send(JSON.stringify({
type: 'transfer_error',
id: data.transferId,
message: `업로드 실패: ${err.message}`
}));
}
break;
}
} catch (err) {
console.error("오류 발생:", err);
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
});
ws.on('close', () => {
client.close();
});
});
}
// --- 포트 충돌 처리 ---
function handlePortConflict() {
// Windows: netstat -ano | findstr :8090
// Mac/Linux: lsof -i :8090
if (process.platform === 'win32') {
exec(`netstat -ano | findstr :${PORT}`, (err, stdout, stderr) => {
if (err || !stdout) {
console.log("실행 중인 프로세스를 찾지 못했습니다. 수동으로 확인해주세요.");
process.exit(1);
return;
}
// Parse PID (Last token in line)
const lines = stdout.trim().split('\n');
const line = lines[0].trim();
const parts = line.split(/\s+/);
const pid = parts[parts.length - 1];
askToKill(pid);
});
} else {
// Simple fallback/notification for non-Windows (or implement lsof)
console.log(`Port ${PORT} is in use. Please kill the process manually.`);
process.exit(1);
}
}
function askToKill(pid) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log(`⚠️ PID [${pid}] 프로세스가 포트 ${PORT}를 사용 중입니다.`);
rl.question(`❓ 해당 프로세스를 종료하고 서버를 시작하시겠습니까? (Y/n): `, (answer) => {
const ans = answer.trim().toLowerCase();
if (ans === '' || ans === 'y' || ans === 'yes') {
console.log(`🔫 PID ${pid} 종료 시도 중...`);
exec(`taskkill /F /PID ${pid}`, (killErr) => {
if (killErr) {
console.error(`❌ 종료 실패: ${killErr.message}`);
process.exit(1);
} else {
console.log("✅ 프로세스가 종료되었습니다. 서버를 다시 시작합니다...");
rl.close();
setTimeout(startServer, 1000); // 1초 후 재시작
}
});
} else {
console.log("🚫 작업을 취소했습니다.");
process.exit(0);
}
});
}
// --- 브라우저 열기 ---
function openBrowser(url) {
const start = (process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open');
exec(`${start} ${url}`, (err) => {
if (err) {
console.log("브라우저를 자동으로 열 수 없습니다:", err.message);
}
});
}
// 초기 실행
startServer();