Files
memind/server/portal-publication-shell.mjs
T
2026-07-24 19:18:14 +08:00

375 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolvePublicRequestOrigin } from '../mindspace-public-page-context.mjs';
import { extractCoverSignals } from '../mindspace-thumbnails.mjs';
function escapePublicHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
export function extractOgImageUrl(html) {
return (
String(html ?? '').match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ||
String(html ?? '').match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] ||
''
);
}
export function extractShareMetaFromPageHtml(html) {
const signals = extractCoverSignals(String(html ?? ''));
return {
title: detectPublishedPageTitle(html),
subtitle: signals.subtitle,
};
}
export function appendQueryParam(url, key, value) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
export 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}` : ''}`;
}
export function resolveRequestOrigin(req) {
return resolvePublicRequestOrigin({
hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '',
forwardedProto: req.headers['x-forwarded-proto'] || '',
protocol: req.protocol,
});
}
export function detectPublishedPageTitle(html) {
const match = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
export 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'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: #f6f1e7; }
body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; color: #18211d; }
.publication-shell { position: relative; min-height: 100vh; }
.publication-frame { display: block; width: 100%; min-height: 100vh; border: 0; background: #fff; }
.publication-share-fab {
position: fixed;
right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
z-index: 50;
border: 0;
border-radius: 999px;
padding: 14px 18px;
background: linear-gradient(135deg, #2f6f57, #1f4c3c);
color: #fffdf7;
font: 700 14px/1 ui-sans-serif, sans-serif;
letter-spacing: .04em;
box-shadow: 0 20px 48px rgba(24, 33, 29, .24);
cursor: pointer;
}
.publication-share-sheet[hidden] { display: none; }
.publication-share-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: end center;
padding: 16px;
background: rgba(7, 12, 10, 0.56);
}
.publication-share-panel {
width: min(480px, 100%);
padding: 18px 18px 22px;
border-radius: 24px 24px 20px 20px;
background: #fffaf2;
box-shadow: 0 24px 80px rgba(24, 33, 29, 0.24);
}
.publication-share-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.publication-share-header p {
margin: 0;
color: #9b6518;
font-size: 11px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
}
.publication-share-header h2 {
margin: 6px 0 0;
font: 500 28px/1.1 Georgia, 'Times New Roman', serif;
color: #18211d;
}
.publication-share-header strong {
display: block;
margin-top: 6px;
color: #68716c;
font-size: 13px;
font-weight: 500;
}
.publication-share-close {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.publication-share-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.publication-share-option {
display: grid;
gap: 8px;
justify-items: center;
padding: 14px 10px;
border: 1px solid rgba(24, 33, 29, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
font: inherit;
cursor: pointer;
}
.publication-share-option:disabled {
opacity: .6;
cursor: wait;
}
.publication-share-option span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #fff;
font-size: 18px;
font-weight: 700;
}
.publication-share-option[data-action="wechat"] span { background: linear-gradient(145deg, #07c160, #06ad56); }
.publication-share-option[data-action="copy"] span { background: linear-gradient(145deg, #2f6f57, #245845); }
.publication-share-option[data-action="capture"] span { background: linear-gradient(145deg, #8f6b2f, #6f5121); }
.publication-share-link, .publication-share-message {
margin-top: 16px;
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
}
.publication-share-link {
background: rgba(24, 33, 29, 0.05);
}
.publication-share-link code {
display: block;
margin-top: 6px;
word-break: break-all;
color: #445049;
font-size: 12px;
}
.publication-share-message {
display: none;
color: #245845;
background: rgba(47, 111, 87, 0.12);
}
.publication-share-message.is-visible {
display: block;
}
@media (max-width: 480px) {
.publication-share-sheet {
padding: 0;
place-items: end stretch;
}
.publication-share-panel {
width: 100%;
border-radius: 24px 24px 0 0;
}
.publication-share-fab {
right: 14px;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
}
}
</style>
</head>
<body>
<div class="publication-shell">
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts allow-downloads"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
<section class="publication-share-panel" role="dialog" aria-modal="true" aria-labelledby="publication-share-title">
<header class="publication-share-header">
<div>
<p>Share</p>
<h2 id="publication-share-title">分享页面</h2>
<strong>${safeTitle}</strong>
</div>
<button type="button" class="publication-share-close" id="publication-share-close" aria-label="关闭">×</button>
</header>
<div class="publication-share-grid">
<button type="button" class="publication-share-option" data-action="wechat"><span>微</span>微信</button>
<button type="button" class="publication-share-option" data-action="copy"><span>链</span>复制链接</button>
<button type="button" class="publication-share-option" data-action="capture"><span>图</span>保存长图</button>
</div>
<div class="publication-share-link">
分享链接
<code>${safeShareUrl}</code>
</div>
<div class="publication-share-message" id="publication-share-message" role="status"></div>
</section>
</div>
<script>
(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')) : [];
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
});
}
function setMessage(text, isError) {
if (!message) return;
message.textContent = text || '';
message.classList.toggle('is-visible', Boolean(text));
message.style.color = isError ? '#8b2d20' : '#245845';
message.style.background = isError ? 'rgba(139, 45, 32, 0.12)' : 'rgba(47, 111, 87, 0.12)';
}
function openSheet() {
if (sheet) sheet.hidden = false;
setMessage('');
}
function closeSheet() {
if (sheet) sheet.hidden = true;
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back to execCommand for browsers where clipboard API exists but is blocked.
}
}
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
async function saveLongImage() {
var link = document.createElement('a');
link.href = longImageUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
if (fab) fab.addEventListener('click', openSheet);
if (close) close.addEventListener('click', closeSheet);
if (sheet) {
sheet.addEventListener('click', function (event) {
if (event.target === sheet) closeSheet();
});
actionButtons.forEach(function (button) {
button.addEventListener('click', async function () {
var action = button.getAttribute('data-action');
try {
setBusy(true);
setMessage('');
if (action === 'wechat') {
await copyText([shareTitle, shareUrl].filter(Boolean).join('\\n'));
setMessage('文案已复制,请打开微信粘贴分享');
return;
}
if (action === 'copy') {
await copyText(shareUrl);
setMessage('链接已复制');
return;
}
if (action === 'capture') {
setMessage('正在生成长图,请稍候');
await saveLongImage();
setMessage('长图已保存');
}
} catch (error) {
setMessage(error && error.message ? error.message : '操作失败,请稍后再试', true);
} finally {
setBusy(false);
}
});
});
}
function syncFrameHeight() {
if (!frame) return;
var doc = frame.contentDocument;
var body = doc && doc.body;
var root = doc && doc.documentElement;
var height = Math.max(
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.scrollHeight : 0,
root ? root.offsetHeight : 0,
window.innerHeight,
320
);
frame.style.height = height + 'px';
}
if (frame) {
frame.addEventListener('load', function () {
syncFrameHeight();
window.setTimeout(syncFrameHeight, 300);
});
}
window.addEventListener('message', function (event) {
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
if (event.data.height) {
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
}
});
window.addEventListener('resize', syncFrameHeight);
})();
</script>
</body>
</html>`;
}