Files
memind/server/portal-publication-routes.mjs

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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('页面加载失败');
}
});
}