feat: add Playwright long image downloads
This commit is contained in:
+73
-60
@@ -102,6 +102,12 @@ import {
|
||||
rasterizeThumbnailSvgToPng,
|
||||
thumbnailPngPathForSvg,
|
||||
} from './mindspace-thumbnail-png.mjs';
|
||||
import {
|
||||
isLongImageDownloadRequest,
|
||||
longImagePathForHtml,
|
||||
renderLongImage,
|
||||
renderLongImageBuffer,
|
||||
} from './mindspace-long-image.mjs';
|
||||
import { scanContent } from './mindspace-content-scan.mjs';
|
||||
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
|
||||
import { createRechargeService } from './billing-recharge.mjs';
|
||||
@@ -4390,7 +4396,7 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals
|
||||
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
|
||||
}
|
||||
|
||||
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`;
|
||||
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var shareButton=root.querySelector('[data-action="share"]');var captureButton=root.querySelector('[data-action="capture"]');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});})();`;
|
||||
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
|
||||
.createHash('sha256')
|
||||
.update(PUBLIC_FILE_SHARE_SCRIPT)
|
||||
@@ -4404,14 +4410,16 @@ function injectPublicFileShareButton(html) {
|
||||
const markup = `
|
||||
<style id="mindspace-public-share-style">
|
||||
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
[data-mindspace-public-share] div{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
|
||||
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
|
||||
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
|
||||
[data-mindspace-public-share] button:active{transform:translateY(1px)}
|
||||
[data-mindspace-public-share] small{min-height:18px;max-width:180px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
|
||||
[data-mindspace-public-share] small:empty{display:none}
|
||||
[data-mindspace-public-share] small.is-error{color:#8b2d20}
|
||||
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
|
||||
</style>
|
||||
<div data-mindspace-public-share><small aria-live="polite"></small><button type="button">公开分享</button></div>
|
||||
<div data-mindspace-public-share><small aria-live="polite"></small><div><button type="button" data-action="capture">保存长图</button><button type="button" data-action="share">公开分享</button></div></div>
|
||||
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
|
||||
if (/<\/body>/i.test(source)) {
|
||||
return {
|
||||
@@ -4446,6 +4454,16 @@ function appendQueryParam(url, key, value) {
|
||||
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
function removeQueryParam(url, key) {
|
||||
if (!url || !url.includes('?')) return url;
|
||||
const [base, queryAndHash] = url.split('?', 2);
|
||||
const [query, hash = ''] = queryAndHash.split('#', 2);
|
||||
const params = new URLSearchParams(query);
|
||||
params.delete(key);
|
||||
const nextQuery = params.toString();
|
||||
return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`;
|
||||
}
|
||||
|
||||
function resolveRequestOrigin(req) {
|
||||
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
|
||||
if (!host) return '';
|
||||
@@ -4460,18 +4478,19 @@ function detectPublishedPageTitle(html) {
|
||||
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
|
||||
}
|
||||
|
||||
function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
|
||||
const iframeSrc = escapePublicHtml(iframeUrl);
|
||||
const safeShareUrl = escapePublicHtml(shareUrl);
|
||||
const safeTitle = escapePublicHtml(title);
|
||||
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
|
||||
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
|
||||
const serializedLongImageUrl = JSON.stringify(longImageUrl).replace(/</g, '\\u003c');
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<title>${safeTitle}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -4655,13 +4674,13 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
(function () {
|
||||
var shareUrl = ${serializedShareUrl};
|
||||
var shareTitle = ${serializedTitle};
|
||||
var longImageUrl = ${serializedLongImageUrl};
|
||||
var fab = document.getElementById('publication-share-fab');
|
||||
var sheet = document.getElementById('publication-share-sheet');
|
||||
var close = document.getElementById('publication-share-close');
|
||||
var message = document.getElementById('publication-share-message');
|
||||
var frame = document.querySelector('.publication-frame');
|
||||
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
|
||||
var html2canvasLoader = null;
|
||||
function setBusy(busy) {
|
||||
actionButtons.forEach(function (button) {
|
||||
button.disabled = !!busy;
|
||||
@@ -4703,54 +4722,10 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error('复制失败,请手动复制链接');
|
||||
}
|
||||
function loadHtml2Canvas() {
|
||||
if (window.html2canvas) return Promise.resolve(window.html2canvas);
|
||||
if (html2canvasLoader) return html2canvasLoader;
|
||||
html2canvasLoader = new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
if (!window.html2canvas) {
|
||||
reject(new Error('无法加载页面截图能力'));
|
||||
return;
|
||||
}
|
||||
resolve(window.html2canvas);
|
||||
};
|
||||
script.onerror = function () { reject(new Error('无法加载页面截图能力')); };
|
||||
document.body.appendChild(script);
|
||||
}).catch(function (error) {
|
||||
html2canvasLoader = null;
|
||||
throw error;
|
||||
});
|
||||
return html2canvasLoader;
|
||||
}
|
||||
async function saveLongImage() {
|
||||
if (!frame || !frame.contentDocument || !frame.contentDocument.body) {
|
||||
throw new Error('页面内容暂时不可访问');
|
||||
}
|
||||
var renderer = await loadHtml2Canvas();
|
||||
var doc = frame.contentDocument;
|
||||
var body = doc.body;
|
||||
var width = Math.max(doc.documentElement ? doc.documentElement.scrollWidth : 0, body.scrollWidth, 320);
|
||||
var height = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, body.scrollHeight, 320);
|
||||
var canvas = await renderer(body, {
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
width: width,
|
||||
height: height,
|
||||
windowWidth: width,
|
||||
windowHeight: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
var link = document.createElement('a');
|
||||
var timestamp = new Date().toISOString().replace(/[T:]/g, '-').replace(/\..+/, '');
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = 'mindspace-public-page-' + timestamp + '.png';
|
||||
link.href = longImageUrl;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -4824,10 +4799,12 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
|
||||
async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
|
||||
let html = result.html;
|
||||
const origin = resolveRequestOrigin(req);
|
||||
const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : '';
|
||||
const originalPath = req.originalUrl || req.url || '';
|
||||
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
|
||||
const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : '';
|
||||
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
|
||||
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
|
||||
if (embed) {
|
||||
@@ -4845,14 +4822,31 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
|
||||
}
|
||||
}
|
||||
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
|
||||
if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) {
|
||||
try {
|
||||
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
|
||||
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send(image);
|
||||
} catch (error) {
|
||||
return res
|
||||
.status(500)
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(`长图生成失败:${error?.message || '未知错误'}`);
|
||||
}
|
||||
}
|
||||
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
|
||||
if (canWrapWithShell) {
|
||||
const title = detectPublishedPageTitle(html);
|
||||
const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw');
|
||||
const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw');
|
||||
const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image');
|
||||
let shellHtml = publishedPageShellHtml({
|
||||
iframeUrl: rawUrl,
|
||||
shareUrl: pageUrl,
|
||||
title,
|
||||
longImageUrl,
|
||||
});
|
||||
try {
|
||||
shellHtml = injectOgTags(shellHtml, {
|
||||
@@ -5019,7 +5013,7 @@ async function resolvePublishedRoute(req, res, password = null) {
|
||||
referrer: req.get('referer'),
|
||||
},
|
||||
);
|
||||
return sendPublishedPage(req, res, result, {
|
||||
return await sendPublishedPage(req, res, result, {
|
||||
embed: isPlazaEmbedRequest(req.query),
|
||||
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
|
||||
});
|
||||
@@ -5098,7 +5092,7 @@ app.get('/s/:token', async (req, res) => {
|
||||
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
|
||||
try {
|
||||
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
|
||||
return sendPublishedPage(
|
||||
return await sendPublishedPage(
|
||||
req,
|
||||
res,
|
||||
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
|
||||
@@ -5136,13 +5130,32 @@ async function resolvePublishDirKey(segment) {
|
||||
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
|
||||
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
|
||||
*/
|
||||
function sendPublishFile(req, res, filePath) {
|
||||
async function sendLongImageDownloadIfRequested(req, res, filePath) {
|
||||
if (!isLongImageDownloadRequest(req.query)) return false;
|
||||
const longImagePath = longImagePathForHtml(filePath);
|
||||
try {
|
||||
await renderLongImage({ htmlPath: filePath, outputPath: longImagePath });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.download(longImagePath, path.basename(longImagePath), (err) => {
|
||||
if (err && !res.headersSent) res.status(404).json({ message: '长图文件不存在' });
|
||||
});
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(`长图生成失败:${error?.message || '未知错误'}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendPublishFile(req, res, filePath) {
|
||||
if (!filePath.toLowerCase().endsWith('.html')) {
|
||||
res.sendFile(filePath, (err) => {
|
||||
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await sendLongImageDownloadIfRequested(req, res, filePath)) return;
|
||||
let html;
|
||||
try {
|
||||
html = fs.readFileSync(filePath, 'utf8');
|
||||
@@ -5320,7 +5333,7 @@ async function serveUserPublishFile(req, res, next) {
|
||||
}
|
||||
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
|
||||
if (recoveredPublicHtml) {
|
||||
sendPublishFile(req, res, recoveredPublicHtml);
|
||||
await sendPublishFile(req, res, recoveredPublicHtml);
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ message: '文件不存在' });
|
||||
@@ -5330,14 +5343,14 @@ async function serveUserPublishFile(req, res, next) {
|
||||
if (fs.statSync(resolvedPath).isDirectory()) {
|
||||
const indexPath = path.join(resolvedPath, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
sendPublishFile(req, res, indexPath);
|
||||
await sendPublishFile(req, res, indexPath);
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ message: '目录中没有 index.html' });
|
||||
return;
|
||||
}
|
||||
|
||||
sendPublishFile(req, res, resolvedPath);
|
||||
await sendPublishFile(req, res, resolvedPath);
|
||||
}
|
||||
|
||||
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
|
||||
|
||||
Reference in New Issue
Block a user