539 lines
16 KiB
JavaScript
539 lines
16 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
buildMindSpacePublicRoutePath,
|
|
isPublicWorkspaceHtmlRelativePath,
|
|
resolveMindSpaceUserPublishDir,
|
|
} from '../mindspace-runtime-config.mjs';
|
|
import { isPlazaEmbedRequest } from '../plaza-embed.mjs';
|
|
import {
|
|
rasterizeThumbnailSvgToPng,
|
|
} from '../mindspace-thumbnail-png.mjs';
|
|
import { PUBLIC_ZONE_DIR } from '../user-publish.mjs';
|
|
|
|
function escapePublicHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
export function renderPublicationPasswordGate(
|
|
action,
|
|
) {
|
|
return `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'">
|
|
<title>受保护页面</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #17221d; background: radial-gradient(circle at top right, #d9e9df, transparent 42rem), #f5f0e5; font-family: ui-sans-serif, sans-serif; }
|
|
form { width: min(420px, 100%); padding: 36px; border: 1px solid #d6d0c3; border-radius: 28px; background: #fffdf7; box-shadow: 0 24px 70px rgba(35, 48, 40, .12); }
|
|
p { color: #68716c; line-height: 1.7; }
|
|
input, button { width: 100%; margin-top: 14px; padding: 14px 16px; border-radius: 12px; font: inherit; }
|
|
input { border: 1px solid #c9c6bc; }
|
|
button { border: 0; color: white; background: #2f6f57; cursor: pointer; font-weight: 800; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<form method="post" action="${action}">
|
|
<h1>此页面受密码保护</h1>
|
|
<p>请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。</p>
|
|
<input type="password" name="password" minlength="8" maxlength="128" required autocomplete="current-password">
|
|
<button type="submit">访问页面</button>
|
|
</form>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function renderPublicHomepage(data) {
|
|
const cards = data.pages
|
|
.map(
|
|
(page) => `\
|
|
<a class="card" href="${escapePublicHtml(page.publicUrl)}">
|
|
<div class="card-top">
|
|
<span>${escapePublicHtml(page.templateId)}</span>
|
|
<strong>${Number(page.viewCount)} 次浏览</strong>
|
|
</div>
|
|
<h2>${escapePublicHtml(page.title)}</h2>
|
|
<p>${escapePublicHtml(page.summary || '暂无摘要')}</p>
|
|
<div class="card-foot">
|
|
<span>${new Date(page.publishedAt).toLocaleDateString('zh-CN')}</span>
|
|
<span>打开页面</span>
|
|
</div>
|
|
</a>`,
|
|
)
|
|
.join('');
|
|
return `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'">
|
|
<title>${escapePublicHtml(data.owner.displayName)} · MindSpace</title>
|
|
<style>
|
|
:root { color: #17221d; background: #f5f0e5; font-family: 'Iowan Old Style', 'Palatino Linotype', serif; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; min-height: 100vh; background:
|
|
radial-gradient(circle at top right, rgba(79, 137, 112, .18), transparent 32rem),
|
|
linear-gradient(180deg, #f9f6ef 0%, #f1eadc 100%);
|
|
color: #17221d;
|
|
}
|
|
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
|
|
.hero { padding: 28px 0 32px; border-bottom: 1px solid rgba(23, 34, 29, .12); }
|
|
.eyebrow { margin: 0 0 12px; color: #8f6b2f; font: 700 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; text-transform: uppercase; }
|
|
h1 { margin: 0; font-size: clamp(40px, 8vw, 82px); line-height: .96; letter-spacing: -.05em; }
|
|
.lead { max-width: 760px; margin: 18px 0 0; color: #56615b; font: 18px/1.8 ui-sans-serif, sans-serif; }
|
|
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
|
|
.stats span { border: 1px solid rgba(23, 34, 29, .12); border-radius: 999px; padding: 10px 14px; background: rgba(255,255,255,.6); font: 600 13px/1 ui-sans-serif, sans-serif; color: #405048; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin-top: 34px; }
|
|
.card { display: flex; flex-direction: column; gap: 16px; min-height: 230px; padding: 22px; border-radius: 24px; text-decoration: none; color: inherit; background: rgba(255,253,247,.92); border: 1px solid rgba(23, 34, 29, .08); box-shadow: 0 20px 60px rgba(45, 53, 47, .08); transition: transform .18s ease, box-shadow .18s ease; }
|
|
.card:hover { transform: translateY(-2px); box-shadow: 0 24px 70px rgba(45, 53, 47, .12); }
|
|
.card-top, .card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; font: 600 12px/1.4 ui-sans-serif, sans-serif; color: #6a746f; text-transform: uppercase; letter-spacing: .08em; }
|
|
.card h2 { margin: 0; font-size: 28px; line-height: 1.08; letter-spacing: -.03em; }
|
|
.card p { margin: 0; color: #56615b; font: 15px/1.75 ui-sans-serif, sans-serif; }
|
|
.empty { margin-top: 32px; padding: 28px; border-radius: 24px; background: rgba(255,253,247,.76); border: 1px dashed rgba(23, 34, 29, .18); color: #56615b; font: 16px/1.7 ui-sans-serif, sans-serif; }
|
|
@media (max-width: 640px) {
|
|
main { width: min(100% - 24px, 1120px); padding: 24px 0 48px; }
|
|
.hero { padding-top: 12px; }
|
|
.grid { gap: 14px; }
|
|
.card { min-height: 0; border-radius: 20px; }
|
|
.card h2 { font-size: 24px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="hero">
|
|
<p class="eyebrow">MindSpace Public</p>
|
|
<h1>${escapePublicHtml(data.owner.displayName)}</h1>
|
|
<p class="lead">这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。</p>
|
|
<div class="stats">
|
|
<span>${Number(data.pageCount)} 个公开页面</span>
|
|
<span>${Number(data.totalViews)} 次累计浏览</span>
|
|
<span>/u/${escapePublicHtml(data.owner.slug)}</span>
|
|
</div>
|
|
</section>
|
|
${
|
|
data.pages.length
|
|
? `<section class="grid">${cards}</section>`
|
|
: '<section class="empty">这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。</section>'
|
|
}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function attachPortalPublicationRoutes({
|
|
app,
|
|
urlencodedBody,
|
|
userAuthReady = Promise.resolve(),
|
|
h5Root,
|
|
getAuthPool = () => null,
|
|
getMindSpacePages = () => null,
|
|
getMindSpacePublications = () => null,
|
|
getUserAuth = () => null,
|
|
sendPublishedPage,
|
|
fsApi = fs,
|
|
pathApi = path,
|
|
publicZoneDir = PUBLIC_ZONE_DIR,
|
|
resolveUserPublishDir =
|
|
resolveMindSpaceUserPublishDir,
|
|
isEmbedRequest = isPlazaEmbedRequest,
|
|
isPublicWorkspaceHtml =
|
|
isPublicWorkspaceHtmlRelativePath,
|
|
buildPublicRoutePath =
|
|
buildMindSpacePublicRoutePath,
|
|
rasterizeThumbnail =
|
|
rasterizeThumbnailSvgToPng,
|
|
} = {}) {
|
|
if (
|
|
!app ||
|
|
!h5Root ||
|
|
typeof urlencodedBody !== 'function' ||
|
|
typeof sendPublishedPage !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'attachPortalPublicationRoutes requires route dependencies',
|
|
);
|
|
}
|
|
|
|
async function resolvePublishedRoute(
|
|
req,
|
|
res,
|
|
password = null,
|
|
) {
|
|
await userAuthReady;
|
|
const mindSpacePublications =
|
|
getMindSpacePublications();
|
|
if (!mindSpacePublications) {
|
|
return res
|
|
.status(503)
|
|
.send('MindSpace 未启用');
|
|
}
|
|
try {
|
|
const userAuth = getUserAuth();
|
|
const viewer =
|
|
req.userSession && userAuth
|
|
? await userAuth.getMe(req.userToken)
|
|
: null;
|
|
const result =
|
|
await mindSpacePublications.resolvePublic(
|
|
req.params.ownerSlug,
|
|
req.params.urlSlug,
|
|
viewer?.id,
|
|
password,
|
|
{
|
|
userAgent: req.get('user-agent'),
|
|
referrer: req.get('referer'),
|
|
},
|
|
);
|
|
const embed = isEmbedRequest(req.query);
|
|
const raw =
|
|
String(req.query.view ?? '')
|
|
.toLowerCase() === 'raw';
|
|
if (
|
|
!password &&
|
|
!embed &&
|
|
!raw &&
|
|
result.publication?.accessMode ===
|
|
'public' &&
|
|
isPublicWorkspaceHtml(
|
|
result.workspaceRelativePath,
|
|
)
|
|
) {
|
|
const redirectPath =
|
|
buildPublicRoutePath(
|
|
result.ownerId,
|
|
String(
|
|
result.workspaceRelativePath,
|
|
).split('/'),
|
|
);
|
|
return res.redirect(301, redirectPath);
|
|
}
|
|
return await sendPublishedPage(
|
|
req,
|
|
res,
|
|
result,
|
|
{
|
|
embed,
|
|
raw,
|
|
ownerSlug: req.params.ownerSlug,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error?.code ===
|
|
'publication_password_required'
|
|
) {
|
|
return res
|
|
.status(password ? 403 : 200)
|
|
.send(
|
|
renderPublicationPasswordGate(
|
|
req.originalUrl,
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
error?.code ===
|
|
'publication_login_required'
|
|
) {
|
|
return res
|
|
.status(401)
|
|
.send(
|
|
'请先登录 TKMind 后再访问此页面',
|
|
);
|
|
}
|
|
if (
|
|
error?.code ===
|
|
'publication_not_found'
|
|
) {
|
|
return res
|
|
.status(404)
|
|
.send('页面不存在或已下线');
|
|
}
|
|
return res
|
|
.status(500)
|
|
.send('页面加载失败');
|
|
}
|
|
}
|
|
|
|
app.use(async (req, res, next) => {
|
|
const thumbnailMatch =
|
|
/^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(
|
|
req.path,
|
|
);
|
|
if (!thumbnailMatch) return next();
|
|
const ownerSlug = thumbnailMatch[1];
|
|
const urlSlug = thumbnailMatch[2];
|
|
await userAuthReady;
|
|
const authPool = getAuthPool();
|
|
const mindSpacePages =
|
|
getMindSpacePages();
|
|
if (!authPool || !mindSpacePages) {
|
|
return res
|
|
.status(503)
|
|
.send('MindSpace 未启用');
|
|
}
|
|
try {
|
|
const [rows] = await authPool.query(
|
|
`SELECT pr.user_id, pr.page_id
|
|
FROM h5_publish_records pr
|
|
JOIN h5_users u ON u.id = pr.user_id
|
|
WHERE COALESCE(u.slug, u.username) = ?
|
|
AND pr.url_slug = ?
|
|
AND pr.status = 'online'
|
|
ORDER BY pr.published_at DESC
|
|
LIMIT 1`,
|
|
[ownerSlug, urlSlug],
|
|
);
|
|
const row = rows[0];
|
|
if (!row) {
|
|
return res
|
|
.status(404)
|
|
.send('缩略图不存在');
|
|
}
|
|
const svg =
|
|
await mindSpacePages.renderThumbnail(
|
|
row.user_id,
|
|
row.page_id,
|
|
);
|
|
res.set('Content-Type', 'image/png');
|
|
res.set(
|
|
'Cache-Control',
|
|
'public, max-age=300',
|
|
);
|
|
return res.send(
|
|
rasterizeThumbnail(svg),
|
|
);
|
|
} catch {
|
|
return res
|
|
.status(500)
|
|
.send('缩略图加载失败');
|
|
}
|
|
});
|
|
|
|
app.get(
|
|
'/u/:ownerSlug/pages/:urlSlug',
|
|
async (req, res) =>
|
|
resolvePublishedRoute(req, res),
|
|
);
|
|
|
|
app.use(
|
|
'/u/:ownerSlug/public',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const ownerSlug = String(
|
|
req.params.ownerSlug ?? '',
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!ownerSlug) {
|
|
return res
|
|
.status(404)
|
|
.json({ message: '用户不存在' });
|
|
}
|
|
const authPool = getAuthPool();
|
|
if (!authPool) {
|
|
return res
|
|
.status(503)
|
|
.send('MindSpace 未启用');
|
|
}
|
|
|
|
try {
|
|
const [rows] = await authPool.query(
|
|
`SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`,
|
|
[ownerSlug],
|
|
);
|
|
const userId = rows[0]?.id
|
|
? String(rows[0].id)
|
|
: null;
|
|
if (!userId) {
|
|
return res
|
|
.status(404)
|
|
.json({ message: '用户不存在' });
|
|
}
|
|
|
|
const relativePath = String(
|
|
req.path ?? '',
|
|
)
|
|
.replace(/^\/+/, '')
|
|
.split('/')
|
|
.map((segment) =>
|
|
decodeURIComponent(segment),
|
|
)
|
|
.filter(Boolean);
|
|
if (relativePath.length === 0) {
|
|
return res
|
|
.status(404)
|
|
.json({ message: '文件不存在' });
|
|
}
|
|
|
|
const publishDir =
|
|
resolveUserPublishDir(h5Root, {
|
|
id: userId,
|
|
});
|
|
const resolvedRoot =
|
|
pathApi.resolve(publishDir);
|
|
const filePath = pathApi.resolve(
|
|
publishDir,
|
|
publicZoneDir,
|
|
...relativePath,
|
|
);
|
|
if (
|
|
filePath !== resolvedRoot &&
|
|
!filePath.startsWith(
|
|
`${resolvedRoot}${pathApi.sep}`,
|
|
)
|
|
) {
|
|
return res
|
|
.status(403)
|
|
.json({ message: '禁止访问' });
|
|
}
|
|
if (
|
|
!fsApi.existsSync(filePath) ||
|
|
!fsApi.statSync(filePath).isFile()
|
|
) {
|
|
return res
|
|
.status(404)
|
|
.json({ message: '文件不存在' });
|
|
}
|
|
|
|
return res.sendFile(
|
|
filePath,
|
|
(error) => {
|
|
if (
|
|
error &&
|
|
!res.headersSent
|
|
) {
|
|
res
|
|
.status(404)
|
|
.json({
|
|
message: '文件不存在',
|
|
});
|
|
}
|
|
},
|
|
);
|
|
} catch {
|
|
return res
|
|
.status(500)
|
|
.send('文件加载失败');
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
'/u/:ownerSlug',
|
|
async (req, res) => {
|
|
await userAuthReady;
|
|
const mindSpacePublications =
|
|
getMindSpacePublications();
|
|
if (!mindSpacePublications) {
|
|
return res
|
|
.status(503)
|
|
.send('MindSpace 未启用');
|
|
}
|
|
try {
|
|
const data =
|
|
await mindSpacePublications
|
|
.getPublicHomepage(
|
|
req.params.ownerSlug,
|
|
);
|
|
res.set(
|
|
'Content-Type',
|
|
'text/html; charset=utf-8',
|
|
);
|
|
res.set(
|
|
'Content-Security-Policy',
|
|
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
|
);
|
|
res.set(
|
|
'Cache-Control',
|
|
'public, max-age=60',
|
|
);
|
|
return res.send(
|
|
renderPublicHomepage(data),
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error?.code ===
|
|
'publication_not_found'
|
|
) {
|
|
return res
|
|
.status(404)
|
|
.send('主页不存在');
|
|
}
|
|
return res
|
|
.status(500)
|
|
.send('主页加载失败');
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
'/u/:ownerSlug/pages/:urlSlug',
|
|
urlencodedBody,
|
|
async (req, res) =>
|
|
resolvePublishedRoute(
|
|
req,
|
|
res,
|
|
req.body?.password,
|
|
),
|
|
);
|
|
|
|
app.get('/s/:token', async (req, res) => {
|
|
await userAuthReady;
|
|
const mindSpacePublications =
|
|
getMindSpacePublications();
|
|
if (!mindSpacePublications) {
|
|
return res
|
|
.status(503)
|
|
.send('MindSpace 未启用');
|
|
}
|
|
try {
|
|
const userAuth = getUserAuth();
|
|
const viewer =
|
|
req.userSession && userAuth
|
|
? await userAuth.getMe(req.userToken)
|
|
: null;
|
|
return await sendPublishedPage(
|
|
req,
|
|
res,
|
|
await mindSpacePublications
|
|
.resolvePrivateLink(
|
|
req.params.token,
|
|
viewer?.id,
|
|
{
|
|
userAgent:
|
|
req.get('user-agent'),
|
|
referrer: req.get('referer'),
|
|
},
|
|
),
|
|
{
|
|
embed: isEmbedRequest(req.query),
|
|
raw:
|
|
String(req.query.view ?? '')
|
|
.toLowerCase() === 'raw',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error?.code ===
|
|
'publication_not_found'
|
|
) {
|
|
return res
|
|
.status(404)
|
|
.send('页面不存在或已下线');
|
|
}
|
|
return res
|
|
.status(500)
|
|
.send('页面加载失败');
|
|
}
|
|
});
|
|
}
|