mindspace: close authority boundaries
This commit is contained in:
@@ -0,0 +1,875 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertNoProhibitedBrowserStorage,
|
||||
} from './mindspace-browser-storage-policy.mjs';
|
||||
import {
|
||||
buildMindSpacePublicUrlForUser,
|
||||
resolveMindSpaceUserPublishDir,
|
||||
} from './mindspace-runtime-config.mjs';
|
||||
import {
|
||||
renderLongImage,
|
||||
} from './mindspace-long-image.mjs';
|
||||
import {
|
||||
UPLOAD_ZONE_CODES,
|
||||
} from './user-space.mjs';
|
||||
|
||||
function workspaceToolError(message, code) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function requireServiceMethod(
|
||||
service,
|
||||
methodName,
|
||||
) {
|
||||
if (
|
||||
!service ||
|
||||
typeof service[methodName] !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
`createMindSpaceWorkspaceToolService requires conversationArtifactService.${methodName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value, field) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw workspaceToolError(
|
||||
`${field} is required`,
|
||||
'invalid_workspace_tool_input',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function parseMindSpaceWorkspaceRef(
|
||||
workspaceRef,
|
||||
) {
|
||||
const normalized = requiredString(
|
||||
workspaceRef,
|
||||
'workspaceRef',
|
||||
);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw workspaceToolError(
|
||||
'Invalid MindSpace workspaceRef',
|
||||
'invalid_workspace_ref',
|
||||
);
|
||||
}
|
||||
let segments;
|
||||
try {
|
||||
segments = parsed.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) =>
|
||||
decodeURIComponent(segment),
|
||||
);
|
||||
} catch {
|
||||
throw workspaceToolError(
|
||||
'Invalid MindSpace workspaceRef encoding',
|
||||
'invalid_workspace_ref',
|
||||
);
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'mindspace:' ||
|
||||
parsed.hostname !== 'users' ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.port ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
segments.length !== 2 ||
|
||||
segments[1] !== 'workspace' ||
|
||||
segments[0] === '.' ||
|
||||
segments[0] === '..' ||
|
||||
/[\\/]/.test(segments[0])
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Invalid MindSpace workspaceRef',
|
||||
'invalid_workspace_ref',
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspaceRef: normalized,
|
||||
userId: requiredString(
|
||||
segments[0],
|
||||
'workspaceRef.userId',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMindSpaceWorkspacePath(
|
||||
value,
|
||||
{ allowRoot = false } = {},
|
||||
) {
|
||||
const raw = String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.replace(/\\/g, '/');
|
||||
if (
|
||||
(allowRoot && (!raw || raw === '.')) ||
|
||||
(allowRoot && raw === './')
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (
|
||||
!raw ||
|
||||
raw.startsWith('/') ||
|
||||
/^[a-z]:\//i.test(raw) ||
|
||||
raw.includes('\0')
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Workspace path must be relative',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
const segments = raw
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (
|
||||
segments.some(
|
||||
(segment) =>
|
||||
segment === '.' ||
|
||||
segment === '..',
|
||||
) ||
|
||||
segments[0] === '.mindspace'
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Workspace path is outside the allowed logical workspace',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function pathInsideRoot(root, target) {
|
||||
return (
|
||||
target === root ||
|
||||
target.startsWith(`${root}${path.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNoSymlinkTraversal(
|
||||
root,
|
||||
relativePath,
|
||||
{ includeLeaf = true } = {},
|
||||
) {
|
||||
const rootReal = await fs.realpath(root);
|
||||
const segments = relativePath
|
||||
? relativePath.split('/')
|
||||
: [];
|
||||
const limit = includeLeaf
|
||||
? segments.length
|
||||
: Math.max(0, segments.length - 1);
|
||||
let current = rootReal;
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
current = path.join(
|
||||
current,
|
||||
segments[index],
|
||||
);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.lstat(current);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') break;
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw workspaceToolError(
|
||||
'Workspace symlink traversal is forbidden',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
}
|
||||
return rootReal;
|
||||
}
|
||||
|
||||
async function resolveExistingWorkspacePath(
|
||||
root,
|
||||
relativePath,
|
||||
) {
|
||||
const rootReal =
|
||||
await ensureNoSymlinkTraversal(
|
||||
root,
|
||||
relativePath,
|
||||
);
|
||||
const target = path.join(
|
||||
rootReal,
|
||||
...relativePath.split('/'),
|
||||
);
|
||||
let targetReal;
|
||||
try {
|
||||
targetReal = await fs.realpath(target);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
throw workspaceToolError(
|
||||
`Workspace entry not found: ${relativePath}`,
|
||||
'workspace_entry_not_found',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!pathInsideRoot(rootReal, targetReal)) {
|
||||
throw workspaceToolError(
|
||||
'Workspace path escaped its logical root',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
return targetReal;
|
||||
}
|
||||
|
||||
async function resolveWritableWorkspacePath(
|
||||
root,
|
||||
relativePath,
|
||||
) {
|
||||
const rootReal =
|
||||
await ensureNoSymlinkTraversal(
|
||||
root,
|
||||
relativePath,
|
||||
{ includeLeaf: false },
|
||||
);
|
||||
const target = path.join(
|
||||
rootReal,
|
||||
...relativePath.split('/'),
|
||||
);
|
||||
await fs.mkdir(path.dirname(target), {
|
||||
recursive: true,
|
||||
});
|
||||
const parentReal = await fs.realpath(
|
||||
path.dirname(target),
|
||||
);
|
||||
if (!pathInsideRoot(rootReal, parentReal)) {
|
||||
throw workspaceToolError(
|
||||
'Workspace path escaped its logical root',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const stat = await fs.lstat(target);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw workspaceToolError(
|
||||
'Workspace symlink writes are forbidden',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function assertPackageScope(
|
||||
sessionId,
|
||||
packageId,
|
||||
) {
|
||||
const expected = `cp_${sessionId}`;
|
||||
if (packageId !== expected) {
|
||||
throw workspaceToolError(
|
||||
'packageId does not match the scoped session',
|
||||
'invalid_workspace_package_scope',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mimeSafeByteLength(content) {
|
||||
return Buffer.byteLength(
|
||||
String(content ?? ''),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function decodeWorkspaceBinary(
|
||||
value,
|
||||
{ maxBytes },
|
||||
) {
|
||||
const encoded = String(value ?? '').trim();
|
||||
if (
|
||||
!encoded ||
|
||||
encoded.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Workspace binary body must be valid base64',
|
||||
'invalid_workspace_binary',
|
||||
);
|
||||
}
|
||||
const body = Buffer.from(encoded, 'base64');
|
||||
if (
|
||||
body.length === 0 ||
|
||||
body.length > maxBytes ||
|
||||
body.toString('base64') !== encoded
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
body.length > maxBytes
|
||||
? 'Workspace binary file exceeds the write limit'
|
||||
: 'Workspace binary body must be valid base64',
|
||||
body.length > maxBytes
|
||||
? 'workspace_file_too_large'
|
||||
: 'invalid_workspace_binary',
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function createMindSpaceWorkspaceToolService({
|
||||
h5Root,
|
||||
env = process.env,
|
||||
syncWorkspaceAssets = null,
|
||||
conversationArtifactService,
|
||||
workspacePageDeliveryService = null,
|
||||
resolveMindSpaceUserPublishDirFn =
|
||||
resolveMindSpaceUserPublishDir,
|
||||
buildMindSpacePublicUrlForUserFn =
|
||||
buildMindSpacePublicUrlForUser,
|
||||
renderLongImageFn = renderLongImage,
|
||||
maxReadBytes = 5 * 1024 * 1024,
|
||||
maxWriteBytes = 10 * 1024 * 1024,
|
||||
maxBinaryWriteBytes = 8 * 1024 * 1024,
|
||||
maxGeneratedBinaryBytes =
|
||||
40 * 1024 * 1024,
|
||||
} = {}) {
|
||||
if (!h5Root) {
|
||||
throw new Error(
|
||||
'createMindSpaceWorkspaceToolService requires h5Root',
|
||||
);
|
||||
}
|
||||
requireServiceMethod(
|
||||
conversationArtifactService,
|
||||
'registerPublicHtmlArtifacts',
|
||||
);
|
||||
requireServiceMethod(
|
||||
conversationArtifactService,
|
||||
'registerWorkspaceFileArtifact',
|
||||
);
|
||||
if (
|
||||
!workspacePageDeliveryService ||
|
||||
typeof workspacePageDeliveryService
|
||||
.syncAndDeliver !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'createMindSpaceWorkspaceToolService requires workspacePageDeliveryService.syncAndDeliver',
|
||||
);
|
||||
}
|
||||
|
||||
const resolveScope = (input = {}) => {
|
||||
const {
|
||||
workspaceRef,
|
||||
sessionId,
|
||||
packageId,
|
||||
} = input;
|
||||
const parsed =
|
||||
parseMindSpaceWorkspaceRef(workspaceRef);
|
||||
const suppliedUserId = String(
|
||||
input.userId ?? '',
|
||||
).trim();
|
||||
if (
|
||||
suppliedUserId &&
|
||||
suppliedUserId !== parsed.userId
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'userId does not match workspaceRef',
|
||||
'invalid_workspace_user_scope',
|
||||
);
|
||||
}
|
||||
const normalizedSessionId =
|
||||
requiredString(sessionId, 'sessionId');
|
||||
const normalizedPackageId =
|
||||
requiredString(packageId, 'packageId');
|
||||
assertPackageScope(
|
||||
normalizedSessionId,
|
||||
normalizedPackageId,
|
||||
);
|
||||
const workspaceRoot =
|
||||
resolveMindSpaceUserPublishDirFn(
|
||||
h5Root,
|
||||
{ id: parsed.userId },
|
||||
);
|
||||
return {
|
||||
...parsed,
|
||||
sessionId: normalizedSessionId,
|
||||
packageId: normalizedPackageId,
|
||||
workspaceRoot,
|
||||
};
|
||||
};
|
||||
|
||||
const registerWrite = async ({
|
||||
scope,
|
||||
relativePath,
|
||||
messageId = null,
|
||||
body = null,
|
||||
}) => {
|
||||
let publicHtmlArtifacts = [];
|
||||
let workspaceFileArtifact = null;
|
||||
if (
|
||||
relativePath.startsWith('public/') &&
|
||||
relativePath.toLowerCase().endsWith('.html') &&
|
||||
typeof conversationArtifactService
|
||||
?.registerPublicHtmlArtifacts ===
|
||||
'function'
|
||||
) {
|
||||
publicHtmlArtifacts =
|
||||
await conversationArtifactService
|
||||
.registerPublicHtmlArtifacts({
|
||||
userId: scope.userId,
|
||||
sessionId: scope.sessionId,
|
||||
relativePaths: [relativePath],
|
||||
artifactRefs: [
|
||||
{
|
||||
relativePath,
|
||||
messageId:
|
||||
String(messageId ?? '').trim() ||
|
||||
null,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (
|
||||
body != null &&
|
||||
typeof conversationArtifactService
|
||||
?.registerWorkspaceFileArtifact ===
|
||||
'function'
|
||||
) {
|
||||
workspaceFileArtifact =
|
||||
await conversationArtifactService
|
||||
.registerWorkspaceFileArtifact({
|
||||
userId: scope.userId,
|
||||
sessionId: scope.sessionId,
|
||||
messageId:
|
||||
String(messageId ?? '').trim() ||
|
||||
null,
|
||||
relativePath,
|
||||
body,
|
||||
});
|
||||
}
|
||||
const categoryCode =
|
||||
relativePath.split('/')[0];
|
||||
let assetSync = null;
|
||||
if (
|
||||
typeof syncWorkspaceAssets === 'function' &&
|
||||
UPLOAD_ZONE_CODES.includes(categoryCode)
|
||||
) {
|
||||
assetSync = await syncWorkspaceAssets(
|
||||
scope.userId,
|
||||
{
|
||||
categoryCode,
|
||||
onlyRelativePaths: [relativePath],
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
assetSync,
|
||||
publicHtmlArtifacts,
|
||||
workspaceFileArtifact,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async readFile(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
);
|
||||
const target =
|
||||
await resolveExistingWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
);
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
throw workspaceToolError(
|
||||
'Workspace path is not a file',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
if (stat.size > maxReadBytes) {
|
||||
throw workspaceToolError(
|
||||
'Workspace file exceeds the read limit',
|
||||
'workspace_file_too_large',
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
content: await fs.readFile(
|
||||
target,
|
||||
'utf8',
|
||||
),
|
||||
sizeBytes: stat.size,
|
||||
};
|
||||
},
|
||||
|
||||
async writeFile(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
);
|
||||
const content = String(
|
||||
input.content ?? '',
|
||||
);
|
||||
const sizeBytes =
|
||||
mimeSafeByteLength(content);
|
||||
if (sizeBytes > maxWriteBytes) {
|
||||
throw workspaceToolError(
|
||||
'Workspace file exceeds the write limit',
|
||||
'workspace_file_too_large',
|
||||
);
|
||||
}
|
||||
assertNoProhibitedBrowserStorage(
|
||||
content,
|
||||
{ relativePath },
|
||||
);
|
||||
const target =
|
||||
await resolveWritableWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
);
|
||||
await fs.writeFile(target, content, 'utf8');
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
relativePath,
|
||||
messageId: input.messageId,
|
||||
body: content,
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
sizeBytes,
|
||||
registration,
|
||||
};
|
||||
},
|
||||
|
||||
async writeBinaryFile(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
);
|
||||
const body = decodeWorkspaceBinary(
|
||||
input.bodyBase64,
|
||||
{ maxBytes: maxBinaryWriteBytes },
|
||||
);
|
||||
const target =
|
||||
await resolveWritableWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
);
|
||||
await fs.writeFile(target, body);
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
relativePath,
|
||||
messageId: input.messageId,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
sizeBytes: body.length,
|
||||
registration,
|
||||
};
|
||||
},
|
||||
|
||||
async editFile(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
);
|
||||
const target =
|
||||
await resolveExistingWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
);
|
||||
const original = await fs.readFile(
|
||||
target,
|
||||
'utf8',
|
||||
);
|
||||
const oldString = String(
|
||||
input.oldString ?? input.old_str ?? '',
|
||||
);
|
||||
if (
|
||||
oldString &&
|
||||
!original.includes(oldString)
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'edit_file: old_str was not found',
|
||||
'workspace_edit_not_found',
|
||||
);
|
||||
}
|
||||
const newString = String(
|
||||
input.newString ?? input.new_str ?? '',
|
||||
);
|
||||
const updated = oldString
|
||||
? original.replace(oldString, newString)
|
||||
: newString;
|
||||
const sizeBytes =
|
||||
mimeSafeByteLength(updated);
|
||||
if (sizeBytes > maxWriteBytes) {
|
||||
throw workspaceToolError(
|
||||
'Workspace file exceeds the write limit',
|
||||
'workspace_file_too_large',
|
||||
);
|
||||
}
|
||||
assertNoProhibitedBrowserStorage(
|
||||
updated,
|
||||
{ relativePath },
|
||||
);
|
||||
await fs.writeFile(target, updated, 'utf8');
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
relativePath,
|
||||
messageId: input.messageId,
|
||||
body: updated,
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
sizeBytes,
|
||||
registration,
|
||||
};
|
||||
},
|
||||
|
||||
async listDirectory(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
{ allowRoot: true },
|
||||
);
|
||||
const target = relativePath
|
||||
? await resolveExistingWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
)
|
||||
: await fs.realpath(
|
||||
scope.workspaceRoot,
|
||||
);
|
||||
const entries = await fs.readdir(target, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
entries: entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.name !== '.mindspace',
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory()
|
||||
? 'directory'
|
||||
: entry.isFile()
|
||||
? 'file'
|
||||
: 'other',
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
async createDirectory(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path,
|
||||
);
|
||||
const target =
|
||||
await resolveWritableWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
`${relativePath}/.mindspace-create-placeholder`,
|
||||
);
|
||||
await fs.mkdir(path.dirname(target), {
|
||||
recursive: true,
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
};
|
||||
},
|
||||
|
||||
async generateLongImage(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const htmlRelativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.htmlPath ??
|
||||
input.html_path ??
|
||||
input.path,
|
||||
);
|
||||
if (
|
||||
!htmlRelativePath
|
||||
.toLowerCase()
|
||||
.endsWith('.html')
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'generate_long_image requires an HTML source',
|
||||
'invalid_workspace_page_path',
|
||||
);
|
||||
}
|
||||
const outputRelativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.outputPath ??
|
||||
input.output_path ??
|
||||
htmlRelativePath.replace(
|
||||
/\.html$/i,
|
||||
'.long.png',
|
||||
),
|
||||
);
|
||||
if (
|
||||
!outputRelativePath
|
||||
.toLowerCase()
|
||||
.endsWith('.png')
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'generate_long_image output must be PNG',
|
||||
'invalid_workspace_binary',
|
||||
);
|
||||
}
|
||||
const htmlTarget =
|
||||
await resolveExistingWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
htmlRelativePath,
|
||||
);
|
||||
const htmlStat = await fs.stat(htmlTarget);
|
||||
if (!htmlStat.isFile()) {
|
||||
throw workspaceToolError(
|
||||
'Long-image source is not a file',
|
||||
'invalid_workspace_path',
|
||||
);
|
||||
}
|
||||
const outputTarget =
|
||||
await resolveWritableWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
outputRelativePath,
|
||||
);
|
||||
const sourceCanonicalUrl =
|
||||
buildMindSpacePublicUrlForUserFn({
|
||||
h5Root,
|
||||
env,
|
||||
user: {
|
||||
id: scope.userId,
|
||||
},
|
||||
relativePath:
|
||||
htmlRelativePath,
|
||||
});
|
||||
await renderLongImageFn({
|
||||
url: sourceCanonicalUrl,
|
||||
outputPath: outputTarget,
|
||||
});
|
||||
const outputStat =
|
||||
await fs.stat(outputTarget);
|
||||
if (
|
||||
!outputStat.isFile() ||
|
||||
outputStat.size <= 0
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Long-image renderer did not produce a file',
|
||||
'workspace_entry_not_found',
|
||||
);
|
||||
}
|
||||
if (
|
||||
outputStat.size >
|
||||
maxGeneratedBinaryBytes
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'Generated long image exceeds the write limit',
|
||||
'workspace_file_too_large',
|
||||
);
|
||||
}
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
relativePath: outputRelativePath,
|
||||
messageId: input.messageId,
|
||||
body: await fs.readFile(outputTarget),
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath: outputRelativePath,
|
||||
sizeBytes: outputStat.size,
|
||||
sourceCanonicalUrl,
|
||||
canonicalUrl:
|
||||
outputRelativePath.startsWith(
|
||||
'public/',
|
||||
)
|
||||
? buildMindSpacePublicUrlForUserFn({
|
||||
h5Root,
|
||||
env,
|
||||
user: {
|
||||
id: scope.userId,
|
||||
},
|
||||
relativePath:
|
||||
outputRelativePath,
|
||||
})
|
||||
: null,
|
||||
registration,
|
||||
};
|
||||
},
|
||||
|
||||
async publishPage(input = {}) {
|
||||
const scope = resolveScope(input);
|
||||
const relativePath =
|
||||
normalizeMindSpaceWorkspacePath(
|
||||
input.path ?? input.relativePath,
|
||||
);
|
||||
if (
|
||||
!relativePath.startsWith('public/') ||
|
||||
!relativePath.toLowerCase().endsWith('.html')
|
||||
) {
|
||||
throw workspaceToolError(
|
||||
'publish_page requires public/*.html',
|
||||
'invalid_workspace_page_path',
|
||||
);
|
||||
}
|
||||
await resolveExistingWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
relativePath,
|
||||
);
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
relativePath,
|
||||
messageId: input.messageId,
|
||||
});
|
||||
const delivery =
|
||||
await workspacePageDeliveryService
|
||||
.syncAndDeliver(scope.userId, {
|
||||
pageDataRelativePaths: [
|
||||
relativePath,
|
||||
],
|
||||
});
|
||||
return {
|
||||
workspaceRef: scope.workspaceRef,
|
||||
packageId: scope.packageId,
|
||||
relativePath,
|
||||
canonicalUrl:
|
||||
buildMindSpacePublicUrlForUserFn({
|
||||
h5Root,
|
||||
env,
|
||||
user: { id: scope.userId },
|
||||
relativePath,
|
||||
}),
|
||||
registration,
|
||||
delivery,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mindSpaceWorkspaceToolServiceInternals = {
|
||||
assertPackageScope,
|
||||
decodeWorkspaceBinary,
|
||||
ensureNoSymlinkTraversal,
|
||||
requireServiceMethod,
|
||||
resolveExistingWorkspacePath,
|
||||
resolveWritableWorkspacePath,
|
||||
};
|
||||
Reference in New Issue
Block a user