Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
182 lines
16 KiB
JavaScript
182 lines
16 KiB
JavaScript
'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)를 덮어썼다(관리자·사용자 모두 "로그인 화면만 복사됨").
|
|
* - 범위는 첫 페이지를 받은 "최종 주소" 로 정한다: /<id> → /<id>/ 로 리다이렉트되면 /<id>/, /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(/<title[^>]*>([\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(/<base\s[^>]*>/gi, ''); // 원래 사이트의 <base> 는 로컬 경로를 깨뜨리므로 제거
|
|
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'), `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${first.file}"><a href="${first.file}">이동</a>`); }
|
|
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 };
|