'use strict'; // ============================================================================ // 사이트 복사하기 (v1.2.0) // // 웹 주소만 받아 브라우저처럼 HTTP 로 접속해 같은 사이트(같은 호스트)의 HTML·CSS·JS·이미지·글꼴을 // 최대한 내려받고, 서로를 가리키는 주소를 로컬 상대 경로로 바꿔 빌더의 사이트 폴더에 저장한다. // - 서버 접속 정보가 필요 없는 대신, 서버에서 실행되는 코드(PHP 등)나 로그인 뒤 화면, 스크립트로만 그려지는 내용은 가져올 수 없다. // - 다른 도메인(CDN 등)의 자원은 원래 주소 그대로 둔다. // - 크기 한도는 가져오기와 같은 규칙(서버 디스크 용량의 10%, remote.capacityError). // - 보안: 사설·내부 주소(127.0.0.1, 10.x, 192.168.x 등)는 관리자만 복사할 수 있다(내부 서비스 노출 방지). // ============================================================================ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const dns = require('dns').promises; const net = require('net'); const remote = require('./remote'); const UA = 'Mozilla/5.0 (compatible; AI-Webmaker-Copy/1.2; +site-copy)'; const MAX_PAGES = parseInt(process.env.COPY_MAX_PAGES || '300', 10); // HTML 페이지 상한 (무한 링크 방지) const MAX_ASSETS = parseInt(process.env.COPY_MAX_ASSETS || '5000', 10); // 자원 파일 상한 const TIMEOUT = 25000; const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s }); /** 사설·예약 IP 인지 (IPv4/IPv6) */ const netguard = require('./netguard'); const isPrivateIp = netguard.isPrivateIp; // v1.3.17: 공용 판정(IPv4 대응 IPv6·예약 대역 포함)으로 통일 /** 복사 시작 전 주소 점검: http(s) 만, 사설 주소는 관리자만 */ async function checkUrl(url, allowPrivate) { let u; try { u = new URL(url); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); } if (!/^https?:$/.test(u.protocol)) throw httpError('http:// 또는 https:// 주소만 복사할 수 있습니다'); let addrs = []; try { addrs = (await dns.lookup(u.hostname, { all: true })).map((a) => a.address); } catch (_) { throw httpError(`주소를 찾을 수 없습니다: ${u.hostname}`); } if (!allowPrivate && addrs.some(isPrivateIp)) throw httpError('내부(사설) 네트워크 주소는 복사할 수 없습니다. 관리자에게 문의하세요.', 403); return u; } /** * URL → 사이트 폴더 안의 상대 파일 경로. * - / 또는 /dir/ → index.html, /dir/index.html * - 확장자 없는 페이지(/about) 가 HTML 이면 about/index.html (폴더형 주소를 그대로 유지) * - 쿼리가 있으면 파일명에 짧은 해시를 붙여 서로 다른 응답을 구분 */ function localPath(u, isHtml) { let p = decodeURIComponent(u.pathname || '/'); p = p.replace(/\/{2,}/g, '/').replace(/(^|\/)\.\.?(?=\/|$)/g, ''); if (p.endsWith('/')) p += 'index.html'; else if (isHtml && !/\.[a-z0-9]{1,6}$/i.test(path.posix.basename(p))) p += '/index.html'; if (u.search) { const h = crypto.createHash('sha1').update(u.search).digest('hex').slice(0, 8); const ext = path.posix.extname(p); p = ext ? p.slice(0, -ext.length) + '.' + h + ext : p + '.' + h; } p = p.replace(/^\/+/, '').split('/').map((seg) => seg.replace(/[<>:"\\|?*\x00-\x1f]/g, '_').slice(0, 120)).join('/'); if (!p || p.startsWith('.')) p = 'index.html'; // HTML 로 받은 페이지가 .php/.asp/.jsp 처럼 서버 실행 확장자이거나 쿼리 해시가 붙어 .html 로 끝나지 않으면 ".html" 을 덧붙인다. // (예: contact.php → contact.php.html) 받은 내용은 실행 결과 HTML 이므로, 정적 서버가 PHP 로 실행하거나 내려받기로 주지 않게 한다. if (isHtml && !/\.html?$/i.test(p)) p += '.html'; return p; } /** * CSS url() 안에 넣을 경로. 원래 따옴표(q)를 그대로 써야 HTML style="..." 속성 안에서 따옴표가 충돌하지 않는다. * 따옴표가 없던 경우에는 공백·괄호·따옴표를 퍼센트 인코딩해 url( ) 문법이 깨지지 않게 한다. */ function cssUrl(q, l) { return q ? `url(${q}${l}${q})` : `url(${l.replace(/[\s()'"]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))})`; } /** a 파일에서 b 파일로 가는 상대 경로 (같은 사이트 안) */ function relLink(fromFile, toFile, hash) { let r = path.posix.relative(path.posix.dirname(fromFile), toFile) || path.posix.basename(toFile); if (!r.startsWith('.')) r = './' + r; return r.replace(/^\.\//, '') + (hash || ''); } /** HTML 에서 참조 URL 수집 (href/src/srcset/poster/data-src, style 의 url()) */ function htmlRefs(html) { const out = []; const attr = /\s(href|src|poster|data-src|data-original|content)\s*=\s*("([^"]*)"|'([^']*)')/gi; let m; while ((m = attr.exec(html))) { const v = m[3] !== undefined ? m[3] : m[4]; if (m[1].toLowerCase() === 'content' && !/^(https?:)?\/\/|^\//.test(v)) continue; out.push(v); } const ss = /\ssrcset\s*=\s*("([^"]*)"|'([^']*)')/gi; while ((m = ss.exec(html))) for (const part of (m[2] || m[3] || '').split(',')) { const u = part.trim().split(/\s+/)[0]; if (u) out.push(u); } const css = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi; while ((m = css.exec(html))) out.push(m[2]); return out; } function cssRefs(css) { const out = []; let m; const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/gi; while ((m = re.exec(css))) out.push(m[2] || m[4]); return out; } const skipRef = (v) => !v || /^(#|data:|mailto:|tel:|javascript:|about:|blob:)/i.test(v.trim()); /** * 사이트 복사 실행. * @param {string} startUrl * @param {string} siteDir 저장할 사이트 폴더 * @param {{allowPrivate?: boolean, onProgress?: (text)=>void, isCancelled?: ()=>boolean}} opts * @returns {{pages, assets, bytes, skipped, title, origin, errors: string[]}} */ async function copySite(startUrl, siteDir, opts = {}) { const onProgress = opts.onProgress || (() => {}); const start = await checkUrl(startUrl, opts.allowPrivate); const hostOk = (h) => h.replace(/^www\./, '') === start.hostname.replace(/^www\./, ''); /* * v1.4.1 복사 범위(scope): 시작 주소가 폴더 아래에 있으면(예: https://webmaker.ibit.kr/<사이트ID>/) 그 폴더 안의 페이지만 복사한다. * - 1.4.0 까지는 같은 호스트면 모두 따라가, 사이트 안의 "https://webmaker.ibit.kr/" 링크 → 서비스 첫 화면 → /login 리다이렉트로 * 받은 "AI Webmaker 로그인 화면" 이 복사본의 첫 페이지(index.html)를 덮어썼다(관리자·사용자 모두 "로그인 화면만 복사됨"). * - 범위는 첫 페이지를 받은 "최종 주소" 로 정한다: / → // 로 리다이렉트되면 //, /about.html 처럼 파일이면 그 폴더(/). * 맨 위(/)에서 시작하면 지금까지처럼 사이트 전체. * - 범위 밖 페이지는 받지 않고 원래 주소(절대 주소)로 남긴다. 범위 밖의 자원(CSS·JS·이미지)은 받아서 _root/ 아래에 둔다. * - 범위 안 파일은 범위 폴더를 기준으로 저장한다 → 시작 페이지가 복사본의 index.html 이 된다. */ let scope = null; // 첫 페이지를 받기 전에는 null (첫 페이지만 범위 확인 없이 받는다) /** 이 서비스(PUBLIC_BASE_URL)의 런타임 파일(/wm-modules.js, /wm-stats.js, /api/…)은 복사하지 않고 절대 주소로 둔다 — 모듈·통계가 계속 서비스 최신 코드로 동작하게 */ const svcHost = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').hostname; } catch (_) { return ''; } })(); const svcRuntime = (u) => !!svcHost && u.hostname === svcHost && /^\/(wm-[\w-]+\.js$|api\/|theme\.css$|i18n\.js$)/.test(u.pathname); const inScope = (u) => !scope || scope === '/' || u.pathname === scope.slice(0, -1) || u.pathname.startsWith(scope); /** 범위 기준 저장 경로: 범위 안 = 범위 폴더 기준, 범위 밖 자원 = _root/<원래 경로> */ const scopedPath = (u, isHtml) => { if (!scope || scope === '/') return localPath(u, isHtml); if (inScope(u)) { const x = new URL(u.href); x.pathname = '/' + u.pathname.slice(Math.min(u.pathname.length, scope.length)); return localPath(x, isHtml); } return '_root/' + localPath(u, isHtml); }; const queue = [{ url: start.href, html: true }]; const seen = new Map(); // url(해시 제외) → { file, html } const pages = []; let assets = 0; let bytes = 0; let title = ''; const errors = []; let skipped = 0; const norm = (u) => { const x = new URL(u); x.hash = ''; return x.href; }; const enqueue = (raw, base, html) => { if (skipRef(raw)) return null; let u; try { u = new URL(raw.trim(), base); } catch (_) { return null; } if (!/^https?:$/.test(u.protocol) || !hostOk(u.hostname)) return null; if ((html && !inScope(u)) || svcRuntime(u)) return null; /* v1.4.1 범위 밖 페이지·서비스 런타임 파일은 복사하지 않음 */ const k = norm(u.href); if (!seen.has(k) && !queue.some((q) => q.url === k)) queue.push({ url: k, html }); return k; }; /** * 일반 사용자 복사에서 호스트가 여전히 공개 주소인지 확인 (DNS 가 도중에 내부 주소로 바뀌는 경우 대비, 30초 캐시). * 관리자(allowPrivate)는 확인하지 않는다. */ // v1.3.17 (S09): 30초 동안 한 번만 확인하던 방식(www 별칭 등 다른 호스트는 확인 없이 접속)을 없애고, // netguard.safeFetch 가 요청·리다이렉트 단계마다 그 호스트의 주소를 확인하고 확인한 주소로만 접속한다. /** * 한 주소 내려받기. 리다이렉트는 자동으로 따라가지 않고(redirect: 'manual') 최대 5번까지 직접 따라가며, * 같은 사이트(hostOk) 안으로 가는 경우만 허용한다 — 공개 사이트가 내부 주소로 돌려보내 서버가 내부망에 * 요청을 보내게 되는 것(SSRF)을 막기 위해서다. */ const fetchOne = async (url, isPage) => { // isPage: 페이지 요청이면 리다이렉트도 범위 안으로만 따라간다 (v1.4.1) // v1.3.17 (B16): 본문을 흘려받으며 남은 저장 한도(서버 용량 기준)와 한 파일 상한(200MB) 중 작은 값을 넘으면 바로 끊는다 const cap = remote.capacity().limitBytes; const left = Math.max(0, (Number.isFinite(cap) ? cap : Infinity) - bytes); const maxBytes = Math.min(left + 1, 200 * 1024 * 1024); try { const r = await netguard.safeFetch(url, { allowPrivate: !!opts.allowPrivate, sameSite: (u) => hostOk(u.hostname) && (!isPage || inScope(u)), maxBytes, timeout: TIMEOUT, userAgent: UA, tooBigMessage: '파일이 너무 큽니다' }); if (r.error && r.status === 310) return { ok: false, status: 310, type: '', buf: Buffer.alloc(0), finalUrl: r.finalUrl }; return r; } catch (e) { if (e.tooBig) { const ce = remote.capacityError(bytes + maxBytes); if (ce) throw ce; } throw e; } }; // 1) 내려받기: 페이지(HTML)를 먼저 넓게, 자원은 발견되는 대로 while (queue.length) { if (opts.isCancelled && opts.isCancelled()) throw httpError('사용자가 작업을 중단했습니다', 499); const item = queue.shift(); if (seen.has(item.url)) continue; const htmlCount = pages.length; if (item.html && htmlCount >= MAX_PAGES) { skipped += 1; seen.set(item.url, null); continue; } if (!item.html && assets >= MAX_ASSETS) { skipped += 1; seen.set(item.url, null); continue; } let r; try { r = await fetchOne(item.url, item.html); } catch (e) { errors.push(`${item.url}: ${e.name === 'AbortError' ? '시간 초과' : e.message}`); seen.set(item.url, null); continue; } if (r.offsite) { seen.set(item.url, null); continue; } // 다른 도메인으로 넘어가는 주소는 복사하지 않음 if (!r.ok) { errors.push(`${item.url}: ${r.status === 310 ? '리다이렉트가 너무 많음' : 'HTTP ' + r.status}`); seen.set(item.url, null); continue; } const isHtml = /text\/html|application\/xhtml/i.test(r.type) || (item.html && !r.type); let fin; try { fin = new URL(r.finalUrl || item.url); } catch (_) { fin = new URL(item.url); } if (!hostOk(fin.hostname)) { seen.set(item.url, null); continue; } // 다른 도메인으로 넘어간 페이지는 복사하지 않음 if (scope === null) { const fp = fin.pathname; scope = fp.endsWith('/') ? fp : fp.slice(0, fp.lastIndexOf('/') + 1) || '/'; } // v1.4.1 첫 페이지의 최종 주소로 범위 결정 else if (isHtml && !inScope(fin)) { seen.set(item.url, null); continue; } // 범위 밖으로 넘어간 페이지 const file = scopedPath(new URL(item.url), isHtml); bytes += r.buf.length; { const ce = remote.capacityError(bytes); if (ce) throw ce; } seen.set(item.url, { file, html: isHtml, isCss: /text\/css/i.test(r.type) || /\.css$/i.test(file), buf: r.buf, base: fin.href }); if (isHtml) { pages.push(item.url); const text = r.buf.toString('utf8'); if (!title) title = ((text.match(/]*>([\s\S]*?)<\/title>/i) || [])[1] || '').replace(/\s+/g, ' ').trim(); for (const ref of htmlRefs(text)) { const isPageLink = /^\s*(?!.*\.(css|js|mjs|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|pdf|zip|json|xml|txt)(\?|#|$))/i.test(ref); enqueue(ref, fin.href, isPageLink); } } else if (/text\/css/i.test(r.type) || /\.css$/i.test(file)) { for (const ref of cssRefs(r.buf.toString('utf8'))) enqueue(ref, fin.href, false); assets += 1; } else assets += 1; if ((pages.length + assets) % 10 === 0) onProgress(`내려받는 중 — 페이지 ${pages.length}개, 파일 ${assets}개 (${(bytes / 1048576).toFixed(1)}MB)`); } if (!pages.length) throw httpError(`첫 페이지를 가져오지 못했습니다${errors.length ? ': ' + errors[0] : ''}`, 502); // 2) 저장: 같은 사이트 주소를 로컬 상대 경로로 바꿔 쓴다 onProgress('주소를 로컬 경로로 바꿔 저장하고 있습니다'); const toLocal = (raw, base, fromFile) => { if (skipRef(raw)) return null; let u; try { u = new URL(raw.trim(), base); } catch (_) { return null; } if (!/^https?:$/.test(u.protocol) || !hostOk(u.hostname)) return null; const k = norm(u.href); const rec = seen.get(k); if (!rec) return (scope && scope !== '/' && !inScope(u)) || svcRuntime(u) ? u.href : null; return relLink(fromFile, rec.file, u.hash); }; /* v1.4.1 복사하지 않은 범위 밖 주소는 절대 주소로 (복사본이 다른 주소에서 열려도 원래 곳을 가리키게) */ for (const [, rec] of seen) { if (!rec) continue; const abs = path.join(siteDir, rec.file); fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 }); let out = rec.buf; if (rec.html) { let html = rec.buf.toString('utf8'); html = html.replace(/(\s(?:href|src|poster|data-src|data-original)\s*=\s*)("([^"]*)"|'([^']*)')/gi, (all, pre, q, a, b) => { const v = a !== undefined ? a : b; const l = toLocal(v, rec.base, rec.file); return l ? `${pre}"${l}"` : all; }); html = html.replace(/(\ssrcset\s*=\s*)("([^"]*)"|'([^']*)')/gi, (all, pre, q, a, b) => `${pre}"${(a !== undefined ? a : b).split(',').map((part) => { const [u, ...rest] = part.trim().split(/\s+/); const l = toLocal(u, rec.base, rec.file); return [l || u, ...rest].join(' '); }).join(', ')}"`); html = html.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? cssUrl(q, l) : all; }); html = html.replace(/]*>/gi, ''); // 원래 사이트의 는 로컬 경로를 깨뜨리므로 제거 out = Buffer.from(html, 'utf8'); } else if (rec.isCss) { let css = rec.buf.toString('utf8'); css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? cssUrl(q, l) : all; }).replace(/@import\s+(['"])([^'"]+)\1/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? `@import "${l}"` : all; }); out = Buffer.from(css, 'utf8'); } fs.writeFileSync(abs, out, { mode: 0o644 }); } if (!fs.existsSync(path.join(siteDir, 'index.html'))) { const first = seen.get(norm(start.href)); if (first && first.file !== 'index.html') fs.writeFileSync(path.join(siteDir, 'index.html'), `이동`); } const dynamic = pages.some((p) => /\.(php|asp|aspx|jsp)(\?|$)/i.test(p)); return { pages: pages.length, assets, bytes, skipped, title, origin: start.origin, errors: errors.slice(0, 50), dynamic }; } module.exports = { cssUrl, copySite, checkUrl, isPrivateIp, localPath, relLink };