Extract memind_adm admin server, add local dev tooling, and remove image-generation.
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+31
-184
@@ -1,11 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
migrateUserPublishDir,
|
||||
PUBLISH_ROOT_DIR,
|
||||
PUBLISH_KEY_UUID,
|
||||
resolveLegacyPublishDir,
|
||||
} from './user-publish.mjs';
|
||||
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs';
|
||||
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||||
|
||||
const URL_PATTERN =
|
||||
@@ -28,10 +23,11 @@ export function extractStaticPageLinks(content, { userId, username } = {}) {
|
||||
for (const match of text.matchAll(URL_PATTERN)) {
|
||||
const owner = decodePathSegment(match[1]).toLowerCase();
|
||||
const relativePath = decodePathSegment(match[2]);
|
||||
const ownedByUser =
|
||||
(normalizedUserId && owner === normalizedUserId) ||
|
||||
(normalizedUsername && owner === normalizedUsername);
|
||||
if ((normalizedUserId || normalizedUsername) && !ownedByUser) continue;
|
||||
if (normalizedUserId) {
|
||||
if (owner !== normalizedUserId) continue;
|
||||
} else if (normalizedUsername && owner !== normalizedUsername) {
|
||||
continue;
|
||||
}
|
||||
const key = `${owner}/${relativePath}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -118,7 +114,7 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.readFile } = {}) {
|
||||
export async function findPublishHtml(h5Root, userId, relativePath) {
|
||||
const normalized = String(relativePath ?? '').replace(/^\/+/, '');
|
||||
const basename = path.basename(normalized);
|
||||
const candidates = [
|
||||
@@ -128,59 +124,22 @@ async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.
|
||||
].filter((value, index, list) => value && list.indexOf(value) === index);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const absolute = path.resolve(publishRoot, candidate);
|
||||
if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) continue;
|
||||
if (!absolute.toLowerCase().endsWith('.html')) continue;
|
||||
try {
|
||||
const content = await readFile(absolute, 'utf8');
|
||||
if (!content.trim()) continue;
|
||||
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
|
||||
return {
|
||||
absolute,
|
||||
content,
|
||||
relativePath: resolvedRelativePath,
|
||||
filename: path.basename(resolvedRelativePath),
|
||||
};
|
||||
return await readPublishHtml(h5Root, userId, candidate);
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
const publishRoot = path.resolve(
|
||||
h5Root,
|
||||
PUBLISH_ROOT_DIR,
|
||||
String(userId ?? '').trim().toLowerCase(),
|
||||
);
|
||||
const absolute = await walkPublishHtmlByBasename(publishRoot, basename);
|
||||
if (absolute) {
|
||||
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
|
||||
const content = await readFile(absolute, 'utf8');
|
||||
if (!content.trim()) {
|
||||
throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' });
|
||||
}
|
||||
return {
|
||||
absolute,
|
||||
content,
|
||||
relativePath: resolvedRelativePath,
|
||||
filename: path.basename(resolvedRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findPublishHtml(h5Root, userId, relativePath, { username } = {}) {
|
||||
const key = String(userId ?? '').trim().toLowerCase();
|
||||
if (username && key) {
|
||||
migrateUserPublishDir(h5Root, { id: key, username });
|
||||
}
|
||||
|
||||
const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key);
|
||||
const primary = await findPublishHtmlInRoot(publishRoot, relativePath);
|
||||
if (primary) return primary;
|
||||
|
||||
const legacyRoot = username ? resolveLegacyPublishDir(h5Root, { username }) : null;
|
||||
if (legacyRoot && path.resolve(legacyRoot) !== publishRoot) {
|
||||
const legacy = await findPublishHtmlInRoot(legacyRoot, relativePath);
|
||||
if (legacy) {
|
||||
migrateUserPublishDir(h5Root, { id: key, username });
|
||||
return readPublishHtml(h5Root, userId, legacy.relativePath);
|
||||
}
|
||||
return readPublishHtml(h5Root, userId, resolvedRelativePath);
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||
@@ -256,9 +215,8 @@ function dedupeDuplicatedFilename(filename) {
|
||||
if (half >= 2 && base.slice(0, half) === base.slice(half)) {
|
||||
base = base.slice(0, half);
|
||||
}
|
||||
base = base.replace(/(.{4,}?)\1+/g, '$1');
|
||||
base = base.replace(/(.+?)\1+/g, '$1');
|
||||
base = base.replace(/-v-v(\d+)/g, '-v$1');
|
||||
base = base.replace(/-v(\d)\1+$/g, '-v$1');
|
||||
return `${base}.html`;
|
||||
}
|
||||
|
||||
@@ -330,123 +288,23 @@ export function analyzeChatMessageForSave({
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueRelativePaths(relativePath) {
|
||||
const basename = path.basename(String(relativePath ?? ''));
|
||||
return [
|
||||
String(relativePath ?? '').replace(/^\/+/, ''),
|
||||
String(relativePath ?? '')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^public\//, ''),
|
||||
`public/${basename}`,
|
||||
basename,
|
||||
].filter((value, index, list) => value && list.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function buildPublishHtmlFetchUrls({ userId, link, publicBaseUrl }) {
|
||||
const urls = new Set();
|
||||
const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
|
||||
if (base && link) {
|
||||
for (const rel of uniqueRelativePaths(link.relativePath)) {
|
||||
const asset = buildWorkspaceAssetUrl(userId, rel);
|
||||
if (asset) urls.add(`${base}${asset}`);
|
||||
}
|
||||
}
|
||||
if (link?.publicUrl) urls.add(link.publicUrl);
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
async function mirrorFetchedPublishHtml(h5Root, userId, relativePath, content, { username } = {}) {
|
||||
try {
|
||||
if (username) {
|
||||
migrateUserPublishDir(h5Root, { id: userId, username });
|
||||
}
|
||||
const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath);
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, content, 'utf8');
|
||||
return absolute;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPublishHtmlFallback(
|
||||
analysis,
|
||||
{ publicBaseUrl, fetchImpl = fetch } = {},
|
||||
) {
|
||||
if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) return null;
|
||||
const candidatePaths = uniqueRelativePaths(analysis.selectedLink.relativePath);
|
||||
const urls = buildPublishHtmlFetchUrls({
|
||||
userId: analysis.userId,
|
||||
link: analysis.selectedLink,
|
||||
publicBaseUrl,
|
||||
});
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetchImpl(url, { redirect: 'follow' });
|
||||
if (!response.ok) continue;
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('json')) continue;
|
||||
const content = await response.text();
|
||||
if (!content.trim() || !/<html/i.test(content)) continue;
|
||||
let relativePath = candidatePaths[0];
|
||||
for (const rel of candidatePaths) {
|
||||
if (url.includes(encodeURIComponent(rel)) || url.endsWith(`/${rel}`) || url.endsWith(rel)) {
|
||||
relativePath = rel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const absolute = await mirrorFetchedPublishHtml(
|
||||
analysis.h5Root,
|
||||
analysis.userId,
|
||||
relativePath,
|
||||
content,
|
||||
{ username: analysis.username },
|
||||
);
|
||||
const title = titleFromHtml(content);
|
||||
const summary = summaryFromHtml(content);
|
||||
return {
|
||||
absolute:
|
||||
absolute ??
|
||||
resolvePublishHtmlAbsolutePath(analysis.h5Root, analysis.userId, relativePath),
|
||||
content,
|
||||
relativePath,
|
||||
filename: path.basename(relativePath),
|
||||
suggestedTitle: title || analysis.suggestedTitle,
|
||||
suggestedSummary: summary || analysis.suggestedSummary,
|
||||
publicUrl: analysis.selectedLink.publicUrl,
|
||||
};
|
||||
} catch {
|
||||
// try next url
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveStaticHtmlContent(analysis, options = {}) {
|
||||
export async function resolveStaticHtmlContent(analysis) {
|
||||
if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const loaded = await findPublishHtml(
|
||||
analysis.h5Root,
|
||||
analysis.userId,
|
||||
analysis.selectedLink.relativePath,
|
||||
{ username: analysis.username },
|
||||
);
|
||||
const title = titleFromHtml(loaded.content);
|
||||
const summary = summaryFromHtml(loaded.content);
|
||||
return {
|
||||
...loaded,
|
||||
suggestedTitle: title || analysis.suggestedTitle,
|
||||
suggestedSummary: summary || analysis.suggestedSummary,
|
||||
publicUrl: analysis.selectedLink.publicUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== 'static_page_not_found') throw error;
|
||||
const fetched = await fetchPublishHtmlFallback(analysis, options);
|
||||
if (fetched) return fetched;
|
||||
throw error;
|
||||
}
|
||||
const loaded = await findPublishHtml(
|
||||
analysis.h5Root,
|
||||
analysis.userId,
|
||||
analysis.selectedLink.relativePath,
|
||||
);
|
||||
const title = titleFromHtml(loaded.content);
|
||||
const summary = summaryFromHtml(loaded.content);
|
||||
return {
|
||||
...loaded,
|
||||
suggestedTitle: title || analysis.suggestedTitle,
|
||||
suggestedSummary: summary || analysis.suggestedSummary,
|
||||
publicUrl: analysis.selectedLink.publicUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveChatSaveAnalysis({
|
||||
@@ -455,8 +313,6 @@ export async function resolveChatSaveAnalysis({
|
||||
username,
|
||||
h5Root,
|
||||
selectedLinkIndex = 0,
|
||||
publicBaseUrl,
|
||||
fetchImpl,
|
||||
}) {
|
||||
const sanitized = sanitizeMessageContentForSave(content);
|
||||
let analysis = analyzeChatMessageForSave({
|
||||
@@ -467,11 +323,9 @@ export async function resolveChatSaveAnalysis({
|
||||
selectedLinkIndex,
|
||||
});
|
||||
|
||||
const resolveOptions = { publicBaseUrl, fetchImpl };
|
||||
|
||||
if (analysis.contentMode === 'static_html' && analysis.selectedLink) {
|
||||
try {
|
||||
const resolvedHtml = await resolveStaticHtmlContent(analysis, resolveOptions);
|
||||
const resolvedHtml = await resolveStaticHtmlContent(analysis);
|
||||
return { analysis, resolvedHtml };
|
||||
} catch {
|
||||
// fall through to filename hints
|
||||
@@ -480,7 +334,7 @@ export async function resolveChatSaveAnalysis({
|
||||
|
||||
for (const hint of extractHtmlFilenameHints(sanitized)) {
|
||||
try {
|
||||
const loaded = await findPublishHtml(h5Root, userId, hint, { username });
|
||||
const loaded = await findPublishHtml(h5Root, userId, hint);
|
||||
const syntheticLink = {
|
||||
publicUrl:
|
||||
buildWorkspaceAssetUrl(userId, loaded.relativePath) ??
|
||||
@@ -519,12 +373,5 @@ export async function resolveChatSaveAnalysis({
|
||||
}
|
||||
}
|
||||
|
||||
if (analysis.contentMode === 'static_html' && analysis.selectedLink) {
|
||||
const fetched = await fetchPublishHtmlFallback(analysis, resolveOptions);
|
||||
if (fetched) {
|
||||
return { analysis, resolvedHtml: fetched };
|
||||
}
|
||||
}
|
||||
|
||||
return { analysis, resolvedHtml: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user