Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,737 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
|
||||
import { scanContent } from './mindspace-content-scan.mjs';
|
||||
import { pageInternals } from './mindspace-pages.mjs';
|
||||
|
||||
const SCANNER_VERSION = 'mindspace-content-v1';
|
||||
const ACCESS_MODES = new Set([
|
||||
'public',
|
||||
'password',
|
||||
'private_link',
|
||||
'time_limited',
|
||||
'login_required',
|
||||
'owner_only',
|
||||
]);
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function publicationError(message, code, details) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
const slug = String(value ?? '').normalize('NFKC').trim().toLowerCase();
|
||||
if (!SLUG_PATTERN.test(slug)) {
|
||||
throw publicationError('页面地址只能包含小写字母、数字和连字符', 'invalid_publish_input');
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) {
|
||||
const normalized = normalizeSlug(preferredSlug);
|
||||
const candidates = [normalized];
|
||||
for (let index = 2; index <= 20; index += 1) {
|
||||
candidates.push(`${normalized}-${index}`);
|
||||
}
|
||||
candidates.push(`${normalized}-${excludePageId.replace(/-/g, '').slice(0, 8)}`);
|
||||
for (const candidate of candidates) {
|
||||
if (!SLUG_PATTERN.test(candidate)) continue;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id FROM h5_publish_records
|
||||
WHERE user_id = ? AND url_slug = ? AND status = 'online' AND page_id <> ?
|
||||
LIMIT 1`,
|
||||
[userId, candidate, excludePageId],
|
||||
);
|
||||
if (!rows[0]) return candidate;
|
||||
}
|
||||
return `${normalized}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
function normalizeAccessMode(value) {
|
||||
const mode = String(value ?? 'public');
|
||||
if (!ACCESS_MODES.has(mode)) {
|
||||
throw publicationError('不支持的访问方式', 'invalid_publish_input');
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function normalizePassword(value, required) {
|
||||
const password = String(value ?? '');
|
||||
if (!required && !password) return null;
|
||||
if (password.length < 8 || password.length > 128) {
|
||||
throw publicationError('访问密码长度必须为 8 到 128 个字符', 'invalid_publish_input');
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
function normalizeExpiresAt(value, required) {
|
||||
if (!required && (value == null || value === '')) return null;
|
||||
const expiresAt = Number(value);
|
||||
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) {
|
||||
throw publicationError('过期时间必须晚于当前时间', 'invalid_publish_input');
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, 32);
|
||||
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password, encoded) {
|
||||
const [algorithm, saltHex, hashHex] = String(encoded ?? '').split('$');
|
||||
if (algorithm !== 'scrypt' || !saltHex || !hashHex) return false;
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
const actual = crypto.scryptSync(String(password ?? ''), Buffer.from(saltHex, 'hex'), expected.length);
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
function deviceType(userAgent) {
|
||||
const value = String(userAgent ?? '');
|
||||
if (/bot|crawler|spider|slurp/i.test(value)) return 'bot';
|
||||
if (/ipad|tablet|kindle|silk/i.test(value)) return 'tablet';
|
||||
if (/mobile|iphone|android/i.test(value)) return 'mobile';
|
||||
if (value) return 'desktop';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function referrerHost(referrer) {
|
||||
if (!referrer) return null;
|
||||
try {
|
||||
return new URL(referrer).hostname.slice(0, 255) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function publicationResponse(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
pageId: row.page_id,
|
||||
pageVersionId: row.page_version_id,
|
||||
urlSlug: row.url_slug,
|
||||
publicUrl: row.public_url,
|
||||
accessMode: row.access_mode,
|
||||
expiresAt: row.expires_at == null ? null : Number(row.expires_at),
|
||||
status: row.status,
|
||||
viewCount: Number(row.view_count ?? 0),
|
||||
publishedAt: Number(row.published_at),
|
||||
offlineAt: row.offline_at == null ? null : Number(row.offline_at),
|
||||
};
|
||||
}
|
||||
|
||||
function publicHomepageResponse(owner, pages) {
|
||||
const totalViews = pages.reduce(
|
||||
(sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
owner: {
|
||||
id: owner.id,
|
||||
slug: owner.slug,
|
||||
username: owner.username,
|
||||
displayName: owner.display_name || owner.username,
|
||||
},
|
||||
totalViews,
|
||||
pageCount: pages.length,
|
||||
pages: pages.map((page) => ({
|
||||
id: page.id,
|
||||
pageId: page.page_id,
|
||||
title: page.title,
|
||||
summary: page.summary ?? '',
|
||||
templateId: page.template_id,
|
||||
publicUrl: page.public_url,
|
||||
urlSlug: page.url_slug,
|
||||
viewCount: Number(page.view_count ?? 0),
|
||||
publishedAt: Number(page.published_at),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPublicationService(pool, options = {}) {
|
||||
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
|
||||
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
|
||||
const publicPageLimit = Number(options.publicPageLimit ?? 5);
|
||||
|
||||
const absoluteStoragePath = (storageKey) => {
|
||||
const resolved = path.resolve(storageRoot, storageKey);
|
||||
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new Error('存储路径越界');
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const loadVersion = async (userId, pageId, pageVersionId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id,
|
||||
p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no,
|
||||
pv.bundle_asset_id, av.storage_key
|
||||
FROM h5_page_records p
|
||||
JOIN h5_page_versions pv ON pv.page_id = p.id
|
||||
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
|
||||
WHERE p.id = ? AND p.user_id = ? AND pv.id = ? AND p.status <> 'deleted'
|
||||
LIMIT 1`,
|
||||
[pageId, userId, pageVersionId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw publicationError('页面版本不存在', 'page_not_found');
|
||||
return {
|
||||
...row,
|
||||
version_no: Number(row.version_no),
|
||||
content: await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'),
|
||||
};
|
||||
};
|
||||
|
||||
const persistScan = async (conn, userId, pageVersionId, scan, now) => {
|
||||
const scanId = idFactory();
|
||||
await conn.query(
|
||||
`INSERT INTO h5_security_scans
|
||||
(id, user_id, target_type, target_id, scanner_version, status, risk_level,
|
||||
findings_count, summary_json, started_at, completed_at)
|
||||
VALUES (?, ?, 'page_version', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
scanId,
|
||||
userId,
|
||||
pageVersionId,
|
||||
SCANNER_VERSION,
|
||||
scan.status,
|
||||
scan.riskLevel,
|
||||
scan.findings.length,
|
||||
JSON.stringify({ types: scan.findings.map((finding) => finding.type) }),
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
for (const finding of scan.findings) {
|
||||
await conn.query(
|
||||
`INSERT INTO h5_security_findings
|
||||
(id, scan_id, finding_type, risk_level, occurrence_count, sample_masked, blocking, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
idFactory(),
|
||||
scanId,
|
||||
finding.type,
|
||||
finding.riskLevel,
|
||||
finding.occurrenceCount,
|
||||
finding.sampleMasked,
|
||||
finding.blocking ? 1 : 0,
|
||||
now,
|
||||
],
|
||||
);
|
||||
}
|
||||
return scanId;
|
||||
};
|
||||
|
||||
const check = async (userId, pageId, input) => {
|
||||
const slug = normalizeSlug(input.urlSlug);
|
||||
const accessMode = normalizeAccessMode(input.accessMode);
|
||||
normalizePassword(input.password, accessMode === 'password');
|
||||
const expiresAt = normalizeExpiresAt(
|
||||
input.expiresAt,
|
||||
accessMode === 'time_limited',
|
||||
);
|
||||
const page = await loadVersion(userId, pageId, input.pageVersionId);
|
||||
if (page.page_version_id !== page.current_version_id) {
|
||||
throw publicationError('只能发布页面的当前版本', 'invalid_state_transition');
|
||||
}
|
||||
const [conflicts] = await pool.query(
|
||||
`SELECT pr.id, pr.page_id, p.title
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
WHERE pr.user_id = ? AND pr.url_slug = ? AND pr.status = 'online'
|
||||
AND pr.page_id <> ? LIMIT 1`,
|
||||
[userId, slug, pageId],
|
||||
);
|
||||
const conflict = conflicts[0] ?? null;
|
||||
let publishContent = page.content;
|
||||
if (page.page_type === 'html') {
|
||||
publishContent = (await localizeGoogleFontsCss(page.content)).html;
|
||||
}
|
||||
const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, {
|
||||
format: page.page_type === 'html' ? 'html' : 'text',
|
||||
});
|
||||
const suggestedUrlSlug =
|
||||
conflict && accessMode !== 'private_link'
|
||||
? await findAvailableSlug(pool, userId, slug, pageId)
|
||||
: null;
|
||||
return {
|
||||
pageVersionId: page.page_version_id,
|
||||
urlSlug: slug,
|
||||
accessMode,
|
||||
expiresAt,
|
||||
slugAvailable: accessMode === 'private_link' || !conflict,
|
||||
slugConflict: conflict
|
||||
? { pageId: conflict.page_id, title: conflict.title }
|
||||
: null,
|
||||
suggestedUrlSlug,
|
||||
...scan,
|
||||
allowed: (accessMode === 'private_link' || !conflict) && scan.allowed,
|
||||
};
|
||||
};
|
||||
|
||||
const publish = async (userId, pageId, input) => {
|
||||
const result = await check(userId, pageId, input);
|
||||
if (!result.slugAvailable) throw publicationError('页面地址已被使用', 'slug_conflict');
|
||||
if (!result.allowed) {
|
||||
throw publicationError('页面包含不可公开的高风险信息', 'security_risk_blocked', {
|
||||
findings: result.findings,
|
||||
});
|
||||
}
|
||||
const acknowledged = new Set(input.acknowledgedFindingIds ?? []);
|
||||
const missingAcknowledgements = result.findings.filter(
|
||||
(finding) => !finding.blocking && !acknowledged.has(finding.id),
|
||||
);
|
||||
if (missingAcknowledgements.length) {
|
||||
throw publicationError('请先确认发布检查中的风险提示', 'security_ack_required', {
|
||||
findings: missingAcknowledgements,
|
||||
});
|
||||
}
|
||||
const page = await loadVersion(userId, pageId, result.pageVersionId);
|
||||
let publishContent = page.content;
|
||||
if (page.page_type === 'html') {
|
||||
publishContent = (await localizeGoogleFontsCss(page.content)).html;
|
||||
}
|
||||
const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent });
|
||||
const htmlBytes = Buffer.byteLength(html);
|
||||
const conn = await pool.getConnection();
|
||||
let writtenPath;
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [spaces] = await conn.query(
|
||||
`SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces
|
||||
WHERE id = ? AND user_id = ? FOR UPDATE`,
|
||||
[page.space_id, userId],
|
||||
);
|
||||
const available =
|
||||
Number(spaces[0]?.quota_bytes ?? 0) -
|
||||
Number(spaces[0]?.used_bytes ?? 0) -
|
||||
Number(spaces[0]?.reserved_bytes ?? 0);
|
||||
if (available < htmlBytes) {
|
||||
throw publicationError('剩余空间不足', 'quota_exceeded', {
|
||||
requiredBytes: htmlBytes,
|
||||
availableBytes: Math.max(0, available),
|
||||
});
|
||||
}
|
||||
const [publicationUsage] = await conn.query(
|
||||
`SELECT COUNT(DISTINCT page_id) AS public_page_used,
|
||||
MAX(CASE WHEN page_id = ? THEN 1 ELSE 0 END) AS page_already_online
|
||||
FROM h5_publish_records
|
||||
WHERE user_id = ? AND status = 'online'`,
|
||||
[pageId, userId],
|
||||
);
|
||||
if (
|
||||
!Number(publicationUsage[0]?.page_already_online) &&
|
||||
Number(publicationUsage[0]?.public_page_used) >= publicPageLimit
|
||||
) {
|
||||
throw publicationError('公开页面数量已达到套餐上限', 'public_page_limit_exceeded', {
|
||||
limit: publicPageLimit,
|
||||
});
|
||||
}
|
||||
const [categories] = await conn.query(
|
||||
`SELECT id FROM h5_space_categories
|
||||
WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`,
|
||||
[userId, page.space_id],
|
||||
);
|
||||
if (!categories[0]) throw publicationError('公开分类不存在', 'category_not_found');
|
||||
|
||||
const now = Date.now();
|
||||
const scanId = await persistScan(conn, userId, page.page_version_id, result, now);
|
||||
const bundleAssetId = idFactory();
|
||||
const assetVersionId = idFactory();
|
||||
const publishId = idFactory();
|
||||
const privateToken =
|
||||
result.accessMode === 'private_link'
|
||||
? crypto.randomBytes(24).toString('base64url')
|
||||
: null;
|
||||
const tokenHash = privateToken
|
||||
? crypto.createHash('sha256').update(privateToken).digest('hex')
|
||||
: null;
|
||||
const passwordHash =
|
||||
result.accessMode === 'password' ? hashPassword(input.password) : null;
|
||||
const storageKey = path.posix.join(
|
||||
'users',
|
||||
userId,
|
||||
'publications',
|
||||
publishId,
|
||||
'index.html',
|
||||
);
|
||||
writtenPath = absoluteStoragePath(storageKey);
|
||||
await fs.mkdir(path.dirname(writtenPath), { recursive: true });
|
||||
await fs.writeFile(writtenPath, html, { flag: 'wx' });
|
||||
const checksum = crypto.createHash('sha256').update(html).digest('hex');
|
||||
const [users] = await conn.query(
|
||||
`SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
const ownerSlug = users[0]?.public_slug;
|
||||
const publicUrl = privateToken
|
||||
? `/s/${privateToken}`
|
||||
: `/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO h5_assets
|
||||
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
|
||||
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
|
||||
status, source_type, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'html', 'text/html', 'index.html', ?, ?, ?, ?, ?,
|
||||
'public_candidate', 'ready', 'generated', ?, ?)`,
|
||||
[
|
||||
bundleAssetId,
|
||||
userId,
|
||||
page.space_id,
|
||||
categories[0].id,
|
||||
`${page.title} · 发布快照`,
|
||||
assetVersionId,
|
||||
htmlBytes,
|
||||
checksum,
|
||||
result.riskLevel,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_asset_versions
|
||||
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
|
||||
created_by, change_note, scan_status, created_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?, 'text/html', ?, '发布页面快照', ?, ?)`,
|
||||
[
|
||||
assetVersionId,
|
||||
bundleAssetId,
|
||||
storageKey,
|
||||
htmlBytes,
|
||||
checksum,
|
||||
userId,
|
||||
result.status === 'passed' ? 'passed' : 'warned',
|
||||
now,
|
||||
],
|
||||
);
|
||||
const [previousRows] = await conn.query(
|
||||
`SELECT id, page_version_id FROM h5_publish_records
|
||||
WHERE user_id = ? AND page_id = ?
|
||||
ORDER BY published_at DESC LIMIT 1 FOR UPDATE`,
|
||||
[userId, pageId],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
|
||||
WHERE user_id = ? AND page_id = ? AND status = 'online'`,
|
||||
[now, now, userId, pageId],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_page_versions
|
||||
SET immutable = 1, bundle_asset_id = ?, security_scan_id = ?
|
||||
WHERE id = ? AND page_id = ?`,
|
||||
[bundleAssetId, scanId, page.page_version_id, pageId],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_publish_records
|
||||
(id, user_id, page_id, page_version_id, publish_type, url_slug, public_url,
|
||||
access_mode, password_hash, token_hash, token_prefix, expires_at, published_at,
|
||||
status, view_count, security_scan_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`,
|
||||
[
|
||||
publishId,
|
||||
userId,
|
||||
pageId,
|
||||
page.page_version_id,
|
||||
result.urlSlug,
|
||||
publicUrl,
|
||||
result.accessMode,
|
||||
passwordHash,
|
||||
tokenHash,
|
||||
privateToken?.slice(0, 8) ?? null,
|
||||
result.expiresAt,
|
||||
now,
|
||||
scanId,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_publication_events
|
||||
(id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id,
|
||||
access_mode, detail_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
idFactory(),
|
||||
publishId,
|
||||
previousRows.length ? 'republished' : 'published',
|
||||
userId,
|
||||
previousRows[0]?.page_version_id ?? null,
|
||||
page.page_version_id,
|
||||
result.accessMode,
|
||||
JSON.stringify({ findings: result.findings.map((finding) => finding.id) }),
|
||||
now,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_page_records
|
||||
SET current_publish_id = ?, status = ?, visibility = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
publishId,
|
||||
result.accessMode === 'public' ? 'published' : 'protected',
|
||||
result.accessMode === 'public' ? 'public' : 'private',
|
||||
now,
|
||||
pageId,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[htmlBytes, now, page.space_id, userId],
|
||||
);
|
||||
await conn.commit();
|
||||
return {
|
||||
...publicationResponse({
|
||||
id: publishId,
|
||||
page_id: pageId,
|
||||
page_version_id: page.page_version_id,
|
||||
url_slug: result.urlSlug,
|
||||
public_url: publicUrl,
|
||||
access_mode: result.accessMode,
|
||||
status: 'online',
|
||||
view_count: 0,
|
||||
published_at: now,
|
||||
offline_at: null,
|
||||
expires_at: result.expiresAt,
|
||||
}),
|
||||
findings: result.findings,
|
||||
};
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrent = async (userId, pageId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.* FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
|
||||
WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online'
|
||||
ORDER BY pr.published_at DESC LIMIT 1`,
|
||||
[pageId, userId],
|
||||
);
|
||||
return publicationResponse(rows[0]);
|
||||
};
|
||||
|
||||
const offline = async (userId, publicationId) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [rows] = await conn.query(
|
||||
`SELECT * FROM h5_publish_records
|
||||
WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`,
|
||||
[publicationId, userId],
|
||||
);
|
||||
const publication = rows[0];
|
||||
if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
|
||||
const now = Date.now();
|
||||
await conn.query(
|
||||
`UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[now, now, publicationId],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_page_records
|
||||
SET current_publish_id = NULL, status = 'offline', visibility = 'private', updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND current_publish_id = ?`,
|
||||
[now, publication.page_id, userId, publicationId],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_publication_events
|
||||
(id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id,
|
||||
access_mode, detail_json, created_at)
|
||||
VALUES (?, ?, 'offlined', ?, ?, NULL, ?, '{}', ?)`,
|
||||
[
|
||||
idFactory(),
|
||||
publicationId,
|
||||
userId,
|
||||
publication.page_version_id,
|
||||
publication.access_mode,
|
||||
now,
|
||||
],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE plaza_posts SET status = 'hidden', updated_at = ?
|
||||
WHERE publication_id = ? AND status != 'hidden'`,
|
||||
[now, publicationId],
|
||||
);
|
||||
await conn.commit();
|
||||
return { id: publicationId, status: 'offline', offlineAt: now };
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveRow = async (row, viewerId, password, requestMeta = {}) => {
|
||||
if (!row) throw publicationError('公开页面不存在', 'publication_not_found');
|
||||
if (row.expires_at && Number(row.expires_at) <= Date.now()) {
|
||||
await pool.query(
|
||||
`UPDATE h5_publish_records SET status = 'expired', updated_at = ? WHERE id = ?`,
|
||||
[Date.now(), row.id],
|
||||
);
|
||||
throw publicationError('公开页面已过期', 'publication_not_found');
|
||||
}
|
||||
if (row.access_mode === 'owner_only' && row.owner_id !== viewerId) {
|
||||
throw publicationError('公开页面不存在', 'publication_not_found');
|
||||
}
|
||||
if (row.access_mode === 'login_required' && !viewerId) {
|
||||
throw publicationError('登录后才能访问此页面', 'publication_login_required');
|
||||
}
|
||||
if (row.access_mode === 'password' && !verifyPassword(password, row.password_hash)) {
|
||||
throw publicationError('需要访问密码', 'publication_password_required');
|
||||
}
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`UPDATE h5_publish_records SET view_count = view_count + 1, updated_at = ? WHERE id = ?`,
|
||||
[now, row.id],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO h5_publication_views
|
||||
(id, publish_id, viewer_user_id, referrer_host, device_type, viewed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
idFactory(),
|
||||
row.id,
|
||||
viewerId ?? null,
|
||||
referrerHost(requestMeta.referrer),
|
||||
deviceType(requestMeta.userAgent),
|
||||
now,
|
||||
],
|
||||
);
|
||||
return {
|
||||
html: await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'),
|
||||
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
|
||||
};
|
||||
};
|
||||
|
||||
const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.*, u.id AS owner_id, av.storage_key
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
|
||||
JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
|
||||
WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online'
|
||||
AND pr.access_mode <> 'private_link'
|
||||
ORDER BY pr.published_at DESC LIMIT 1`,
|
||||
[ownerSlug, urlSlug],
|
||||
);
|
||||
return resolveRow(rows[0], viewerId, password, requestMeta);
|
||||
};
|
||||
|
||||
const resolvePrivateLink = async (token, viewerId, requestMeta) => {
|
||||
const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.*, u.id AS owner_id, av.storage_key
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
|
||||
JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
|
||||
WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link'
|
||||
LIMIT 1`,
|
||||
[tokenHash],
|
||||
);
|
||||
return resolveRow(rows[0], viewerId, null, requestMeta);
|
||||
};
|
||||
|
||||
const getStats = async (userId, publicationId) => {
|
||||
const [publications] = await pool.query(
|
||||
`SELECT id, view_count, published_at, status
|
||||
FROM h5_publish_records WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
[publicationId, userId],
|
||||
);
|
||||
const publication = publications[0];
|
||||
if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
|
||||
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const [daily] = await pool.query(
|
||||
`SELECT DATE_FORMAT(FROM_UNIXTIME(viewed_at / 1000), '%Y-%m-%d') AS day,
|
||||
COUNT(*) AS views
|
||||
FROM h5_publication_views
|
||||
WHERE publish_id = ? AND viewed_at >= ?
|
||||
GROUP BY day ORDER BY day`,
|
||||
[publicationId, since],
|
||||
);
|
||||
const [devices] = await pool.query(
|
||||
`SELECT device_type, COUNT(*) AS views
|
||||
FROM h5_publication_views WHERE publish_id = ?
|
||||
GROUP BY device_type ORDER BY views DESC`,
|
||||
[publicationId],
|
||||
);
|
||||
const [sources] = await pool.query(
|
||||
`SELECT COALESCE(referrer_host, 'direct') AS source, COUNT(*) AS views
|
||||
FROM h5_publication_views WHERE publish_id = ?
|
||||
GROUP BY source ORDER BY views DESC LIMIT 10`,
|
||||
[publicationId],
|
||||
);
|
||||
return {
|
||||
publicationId,
|
||||
status: publication.status,
|
||||
totalViews: Number(publication.view_count),
|
||||
publishedAt: Number(publication.published_at),
|
||||
daily: daily.map((row) => ({ day: row.day, views: Number(row.views) })),
|
||||
devices: devices.map((row) => ({
|
||||
deviceType: row.device_type,
|
||||
views: Number(row.views),
|
||||
})),
|
||||
sources: sources.map((row) => ({ source: row.source, views: Number(row.views) })),
|
||||
};
|
||||
};
|
||||
|
||||
const getPublicHomepage = async (ownerSlug) => {
|
||||
const [owners] = await pool.query(
|
||||
`SELECT id, username, COALESCE(slug, username) AS slug, display_name
|
||||
FROM h5_users
|
||||
WHERE COALESCE(slug, username) = ?
|
||||
LIMIT 1`,
|
||||
[ownerSlug],
|
||||
);
|
||||
const owner = owners[0];
|
||||
if (!owner) throw publicationError('公开主页不存在', 'publication_not_found');
|
||||
const [rows] = await pool.query(
|
||||
`SELECT pr.id, pr.page_id, pr.url_slug, pr.public_url, pr.view_count, pr.published_at,
|
||||
p.title, p.summary, p.template_id
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
|
||||
WHERE pr.user_id = ? AND pr.status = 'online' AND pr.access_mode = 'public'
|
||||
ORDER BY pr.published_at DESC
|
||||
LIMIT 48`,
|
||||
[owner.id],
|
||||
);
|
||||
return publicHomepageResponse(owner, rows);
|
||||
};
|
||||
|
||||
return {
|
||||
check,
|
||||
publish,
|
||||
getCurrent,
|
||||
getPublicHomepage,
|
||||
getStats,
|
||||
offline,
|
||||
resolvePublic,
|
||||
resolvePrivateLink,
|
||||
};
|
||||
}
|
||||
|
||||
export const publicationInternals = {
|
||||
normalizeSlug,
|
||||
normalizeAccessMode,
|
||||
normalizePassword,
|
||||
normalizeExpiresAt,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
deviceType,
|
||||
referrerHost,
|
||||
publicHomepageResponse,
|
||||
scanContent, // re-exported from mindspace-content-scan.mjs
|
||||
};
|
||||
Reference in New Issue
Block a user