html2pptx-local-mcp 1.1.24 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/dist/commands/edit.d.ts +2 -1
- package/lib/local-editor-server.js +5 -1
- package/lib/pptx-studio-mcp-core.js +9 -1
- package/local-editor-app/package.json +1 -1
- package/package.json +2 -1
- package/cli/dist/dist/commands/config-show.d.ts +0 -1
- package/cli/dist/dist/commands/config-show.js +0 -16
- package/cli/dist/dist/commands/convert.d.ts +0 -10
- package/cli/dist/dist/commands/convert.js +0 -311
- package/cli/dist/dist/commands/edit.d.ts +0 -34
- package/cli/dist/dist/commands/edit.js +0 -801
- package/cli/dist/dist/commands/init.d.ts +0 -1
- package/cli/dist/dist/commands/init.js +0 -35
- package/cli/dist/dist/commands/logout.d.ts +0 -1
- package/cli/dist/dist/commands/logout.js +0 -19
- package/cli/dist/dist/commands/publish.d.ts +0 -10
- package/cli/dist/dist/commands/publish.js +0 -17
- package/cli/dist/dist/commands/status.d.ts +0 -5
- package/cli/dist/dist/commands/status.js +0 -71
- package/cli/dist/dist/commands/templates.d.ts +0 -13
- package/cli/dist/dist/commands/templates.js +0 -85
- package/cli/dist/dist/commands/whoami.d.ts +0 -5
- package/cli/dist/dist/commands/whoami.js +0 -51
- package/cli/dist/dist/config.d.ts +0 -7
- package/cli/dist/dist/config.js +0 -24
- package/cli/dist/dist/index.d.ts +0 -2
- package/cli/dist/dist/index.js +0 -93
- package/cli/dist/dist/update-check.d.ts +0 -1
- package/cli/dist/dist/update-check.js +0 -30
|
@@ -1,801 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import { platform } from "node:os";
|
|
5
|
-
import { mkdir, readdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
|
|
6
|
-
import { dirname, extname, join, relative, resolve, sep, } from "node:path";
|
|
7
|
-
import * as p from "@clack/prompts";
|
|
8
|
-
import pc from "picocolors";
|
|
9
|
-
const AUTO_PORT = 0;
|
|
10
|
-
const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
11
|
-
const MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
12
|
-
const ALLOWED_EXTENSIONS = [".html", ".htm"];
|
|
13
|
-
const ALLOWED_EXT = new Set(ALLOWED_EXTENSIONS);
|
|
14
|
-
const ASSET_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"];
|
|
15
|
-
const ASSET_IMAGE_EXT = new Set(ASSET_IMAGE_EXTENSIONS);
|
|
16
|
-
const ASSET_CONTENT_TYPES = {
|
|
17
|
-
".png": "image/png",
|
|
18
|
-
".jpg": "image/jpeg",
|
|
19
|
-
".jpeg": "image/jpeg",
|
|
20
|
-
".gif": "image/gif",
|
|
21
|
-
".webp": "image/webp",
|
|
22
|
-
".svg": "image/svg+xml",
|
|
23
|
-
".avif": "image/avif",
|
|
24
|
-
};
|
|
25
|
-
const ASSET_CONTENT_TYPE_EXT = {
|
|
26
|
-
"image/png": ".png",
|
|
27
|
-
"image/jpeg": ".jpg",
|
|
28
|
-
"image/gif": ".gif",
|
|
29
|
-
"image/webp": ".webp",
|
|
30
|
-
"image/svg+xml": ".svg",
|
|
31
|
-
"image/avif": ".avif",
|
|
32
|
-
};
|
|
33
|
-
const DISALLOWED_TOP_DIRECTORIES = [
|
|
34
|
-
"public",
|
|
35
|
-
".next",
|
|
36
|
-
".git",
|
|
37
|
-
"node_modules",
|
|
38
|
-
"app",
|
|
39
|
-
"pages",
|
|
40
|
-
"components",
|
|
41
|
-
"lib",
|
|
42
|
-
"convex",
|
|
43
|
-
"scripts",
|
|
44
|
-
"mcp",
|
|
45
|
-
"worker",
|
|
46
|
-
"src",
|
|
47
|
-
];
|
|
48
|
-
const DISALLOWED_TOP_DIRS = new Set(DISALLOWED_TOP_DIRECTORIES);
|
|
49
|
-
const EDITOR_SERVER_STATE_FILE = ".html2pptx/edit-slide/editor-server.json";
|
|
50
|
-
const LEGACY_EDITOR_SERVER_STATE_FILE = ".open-slide/editor-server.json";
|
|
51
|
-
const EDITOR_BASE_URL_EXAMPLE = "http://localhost:<port>";
|
|
52
|
-
function sha256(content) {
|
|
53
|
-
return createHash("sha256").update(content).digest("hex");
|
|
54
|
-
}
|
|
55
|
-
function sha256Buffer(buf) {
|
|
56
|
-
return createHash("sha256").update(buf).digest("hex");
|
|
57
|
-
}
|
|
58
|
-
function generateSessionToken() {
|
|
59
|
-
return randomBytes(32).toString("base64url");
|
|
60
|
-
}
|
|
61
|
-
function toPosixPath(filePath) {
|
|
62
|
-
return filePath.split(sep).join("/");
|
|
63
|
-
}
|
|
64
|
-
function relativeToRoot(root, abs) {
|
|
65
|
-
return toPosixPath(relative(root, abs));
|
|
66
|
-
}
|
|
67
|
-
function isLoopbackHostname(hostname) {
|
|
68
|
-
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
69
|
-
return (host === "localhost" ||
|
|
70
|
-
host === "::1" ||
|
|
71
|
-
host === "0:0:0:0:0:0:0:1" ||
|
|
72
|
-
/^127(?:\.\d{1,3}){3}$/.test(host));
|
|
73
|
-
}
|
|
74
|
-
function isLoopbackHostHeader(host) {
|
|
75
|
-
if (!host)
|
|
76
|
-
return false;
|
|
77
|
-
try {
|
|
78
|
-
return isLoopbackHostname(new URL(`http://${host}`).hostname);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function parsePort(value) {
|
|
85
|
-
if (!value)
|
|
86
|
-
return AUTO_PORT;
|
|
87
|
-
const port = Number.parseInt(value, 10);
|
|
88
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
89
|
-
throw new Error("port must be an integer from 0 to 65535");
|
|
90
|
-
}
|
|
91
|
-
return port;
|
|
92
|
-
}
|
|
93
|
-
function isAllowedEditorBaseUrl(url) {
|
|
94
|
-
if (!["http:", "https:"].includes(url.protocol))
|
|
95
|
-
return false;
|
|
96
|
-
return isLoopbackHostname(url.hostname);
|
|
97
|
-
}
|
|
98
|
-
function normalizeBaseUrl(raw) {
|
|
99
|
-
const base = new URL(raw);
|
|
100
|
-
base.hash = "";
|
|
101
|
-
base.search = "";
|
|
102
|
-
if (!isAllowedEditorBaseUrl(base)) {
|
|
103
|
-
throw new Error(`baseUrl for local file editing must be a loopback http(s) origin such as ${EDITOR_BASE_URL_EXAMPLE}. Hosted editor URLs are not allowed.`);
|
|
104
|
-
}
|
|
105
|
-
return base;
|
|
106
|
-
}
|
|
107
|
-
async function readRegisteredEditorBaseUrl(root) {
|
|
108
|
-
for (const stateFile of [EDITOR_SERVER_STATE_FILE, LEGACY_EDITOR_SERVER_STATE_FILE]) {
|
|
109
|
-
try {
|
|
110
|
-
const raw = await readFile(join(root, stateFile), "utf8");
|
|
111
|
-
const state = JSON.parse(raw);
|
|
112
|
-
if (typeof state.baseUrl === "string")
|
|
113
|
-
return state.baseUrl;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
// Try the next known state location.
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
async function resolveEditorBaseUrl(root, explicitBaseUrl) {
|
|
122
|
-
const raw = explicitBaseUrl || await readRegisteredEditorBaseUrl(root);
|
|
123
|
-
if (!raw) {
|
|
124
|
-
throw new Error("Local editor UI is not registered. Start it with `node scripts/dev-studio.mjs`, then rerun `html2pptx edit`, or pass --base-url http://localhost:<port>.");
|
|
125
|
-
}
|
|
126
|
-
const baseUrl = normalizeBaseUrl(raw);
|
|
127
|
-
if (!(await isEditorBaseReachable(baseUrl))) {
|
|
128
|
-
throw new Error(`Local editor UI is not reachable at ${baseUrl.origin}. Start it with \`node scripts/dev-studio.mjs\`, then rerun \`html2pptx edit\`, or pass the active --base-url.`);
|
|
129
|
-
}
|
|
130
|
-
return baseUrl;
|
|
131
|
-
}
|
|
132
|
-
async function isEditorBaseReachable(baseUrl) {
|
|
133
|
-
const probeUrl = new URL("/api/edit-slide/local-health", baseUrl);
|
|
134
|
-
const controller = new AbortController();
|
|
135
|
-
const timer = setTimeout(() => controller.abort(), 7000);
|
|
136
|
-
try {
|
|
137
|
-
const response = await fetch(probeUrl, {
|
|
138
|
-
headers: { accept: "application/json" },
|
|
139
|
-
signal: controller.signal,
|
|
140
|
-
});
|
|
141
|
-
if (!response.ok)
|
|
142
|
-
return false;
|
|
143
|
-
const payload = (await response.json());
|
|
144
|
-
return payload.app === "html2pptx-local-editor" && payload.ok === true;
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
finally {
|
|
150
|
-
clearTimeout(timer);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
function buildEditorUrl(baseUrl, rel, bridgeUrl, sessionToken) {
|
|
154
|
-
const editorUrl = new URL("/edit-slide", baseUrl);
|
|
155
|
-
editorUrl.searchParams.set("file", rel);
|
|
156
|
-
editorUrl.searchParams.set("bridge", bridgeUrl);
|
|
157
|
-
editorUrl.hash = new URLSearchParams({ bridgeToken: sessionToken }).toString();
|
|
158
|
-
return editorUrl;
|
|
159
|
-
}
|
|
160
|
-
function allowedOrigin(origin, editorOrigin) {
|
|
161
|
-
if (!origin)
|
|
162
|
-
return true;
|
|
163
|
-
try {
|
|
164
|
-
const url = new URL(origin);
|
|
165
|
-
return url.origin === editorOrigin || isLoopbackHostname(url.hostname);
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
function applyCors(req, res, editorOrigin) {
|
|
172
|
-
const origin = req.headers.origin;
|
|
173
|
-
if (typeof origin === "string" && !allowedOrigin(origin, editorOrigin)) {
|
|
174
|
-
sendJson(res, 403, { error: "forbidden origin" });
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
|
-
res.setHeader("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method");
|
|
178
|
-
res.setHeader("Access-Control-Allow-Origin", typeof origin === "string" ? origin : editorOrigin);
|
|
179
|
-
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
180
|
-
res.setHeader("Access-Control-Allow-Headers", "content-type,x-edit-slide-local,x-open-slide-local,x-edit-slide-token");
|
|
181
|
-
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
|
-
function sendJson(res, status, payload) {
|
|
185
|
-
res.statusCode = status;
|
|
186
|
-
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
187
|
-
res.end(JSON.stringify(payload));
|
|
188
|
-
}
|
|
189
|
-
async function readExisting(abs) {
|
|
190
|
-
try {
|
|
191
|
-
return await readFile(abs, "utf8");
|
|
192
|
-
}
|
|
193
|
-
catch (error) {
|
|
194
|
-
if (error?.code === "ENOENT")
|
|
195
|
-
return null;
|
|
196
|
-
throw error;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
async function resolveReal(abs) {
|
|
200
|
-
try {
|
|
201
|
-
return await realpath(abs);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
let parent = dirname(abs);
|
|
205
|
-
while (parent !== dirname(parent)) {
|
|
206
|
-
try {
|
|
207
|
-
const realParent = await realpath(parent);
|
|
208
|
-
return join(realParent, relative(parent, abs));
|
|
209
|
-
}
|
|
210
|
-
catch {
|
|
211
|
-
parent = dirname(parent);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return abs;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
async function safePath(ctx, rel) {
|
|
218
|
-
if (typeof rel !== "string" || !rel) {
|
|
219
|
-
throw new Error("missing path");
|
|
220
|
-
}
|
|
221
|
-
const normalized = rel.replace(/^\/+/, "");
|
|
222
|
-
const abs = resolve(ctx.root, normalized);
|
|
223
|
-
if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
|
|
224
|
-
throw new Error("path escape");
|
|
225
|
-
}
|
|
226
|
-
const ext = extname(abs).toLowerCase();
|
|
227
|
-
if (!ALLOWED_EXT.has(ext)) {
|
|
228
|
-
throw new Error("only .html/.htm files are allowed");
|
|
229
|
-
}
|
|
230
|
-
const real = await resolveReal(abs);
|
|
231
|
-
if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
|
|
232
|
-
throw new Error("path escape via symlink");
|
|
233
|
-
}
|
|
234
|
-
for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
|
|
235
|
-
const first = candidate.split(sep)[0];
|
|
236
|
-
if (DISALLOWED_TOP_DIRS.has(first)) {
|
|
237
|
-
throw new Error(`writes under ${first}/ are not allowed`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return real;
|
|
241
|
-
}
|
|
242
|
-
function buildPolicy(ctx) {
|
|
243
|
-
return {
|
|
244
|
-
enabled: true,
|
|
245
|
-
root: ctx.root,
|
|
246
|
-
sessionTokenRequired: true,
|
|
247
|
-
allowedExtensions: ALLOWED_EXTENSIONS,
|
|
248
|
-
disallowedTopDirectories: DISALLOWED_TOP_DIRECTORIES,
|
|
249
|
-
stateDirectory: relativeToRoot(ctx.root, ctx.localStateDir),
|
|
250
|
-
historyEnabled: false,
|
|
251
|
-
maxWriteBytes: MAX_WRITE_BYTES,
|
|
252
|
-
requestGuards: [
|
|
253
|
-
"127.0.0.1 listener only",
|
|
254
|
-
"matching editor Origin",
|
|
255
|
-
"per-session bridge token required for reads and writes",
|
|
256
|
-
"X-Edit-Slide-Local: 1 required for writes",
|
|
257
|
-
"path traversal and symlink escape checks",
|
|
258
|
-
"optimistic hash check before writes",
|
|
259
|
-
],
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
function tokensMatch(actual, expected) {
|
|
263
|
-
if (!actual || !expected)
|
|
264
|
-
return false;
|
|
265
|
-
const actualBuf = Buffer.from(actual);
|
|
266
|
-
const expectedBuf = Buffer.from(expected);
|
|
267
|
-
if (actualBuf.length !== expectedBuf.length)
|
|
268
|
-
return false;
|
|
269
|
-
return timingSafeEqual(actualBuf, expectedBuf);
|
|
270
|
-
}
|
|
271
|
-
function requestToken(req, reqUrl) {
|
|
272
|
-
const headerToken = req.headers["x-edit-slide-token"];
|
|
273
|
-
if (typeof headerToken === "string" && headerToken)
|
|
274
|
-
return headerToken;
|
|
275
|
-
return reqUrl.searchParams.get("token") || "";
|
|
276
|
-
}
|
|
277
|
-
function validateBridgeRequest(req, ctx, reqUrl) {
|
|
278
|
-
if (!isLoopbackHostHeader(req.headers.host)) {
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
if (!allowedOrigin(typeof req.headers.origin === "string" ? req.headers.origin : undefined, ctx.editorOrigin)) {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
|
-
return tokensMatch(requestToken(req, reqUrl), ctx.sessionToken);
|
|
285
|
-
}
|
|
286
|
-
async function readFileMeta(ctx, abs) {
|
|
287
|
-
const content = await readFile(abs, "utf8");
|
|
288
|
-
const fileStat = await stat(abs);
|
|
289
|
-
return {
|
|
290
|
-
path: relativeToRoot(ctx.root, abs),
|
|
291
|
-
size: content.length,
|
|
292
|
-
bytes: Buffer.byteLength(content, "utf8"),
|
|
293
|
-
sha256: sha256(content),
|
|
294
|
-
mtimeMs: fileStat.mtimeMs,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
function validateWriteRequest(req, ctx, reqUrl) {
|
|
298
|
-
if (!validateBridgeRequest(req, ctx, reqUrl)) {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
return (req.headers["x-edit-slide-local"] === "1" ||
|
|
302
|
-
req.headers["x-open-slide-local"] === "1");
|
|
303
|
-
}
|
|
304
|
-
async function readBody(req, maxBytes = MAX_WRITE_BYTES) {
|
|
305
|
-
const chunks = [];
|
|
306
|
-
let total = 0;
|
|
307
|
-
for await (const chunk of req) {
|
|
308
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
309
|
-
total += buf.length;
|
|
310
|
-
if (total > maxBytes + 1024 * 1024) {
|
|
311
|
-
throw new Error("request body too large");
|
|
312
|
-
}
|
|
313
|
-
chunks.push(buf);
|
|
314
|
-
}
|
|
315
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
316
|
-
}
|
|
317
|
-
async function handleGet(ctx, req, reqUrl, res) {
|
|
318
|
-
if (!validateBridgeRequest(req, ctx, reqUrl)) {
|
|
319
|
-
sendJson(res, 403, { error: "forbidden" });
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
if (reqUrl.searchParams.get("policy") === "1") {
|
|
323
|
-
sendJson(res, 200, { policy: buildPolicy(ctx) });
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
const rel = reqUrl.searchParams.get("path");
|
|
327
|
-
try {
|
|
328
|
-
const abs = await safePath(ctx, rel || "");
|
|
329
|
-
if (reqUrl.searchParams.get("versions") === "1") {
|
|
330
|
-
sendJson(res, 200, {
|
|
331
|
-
path: relativeToRoot(ctx.root, abs),
|
|
332
|
-
current: await readFileMeta(ctx, abs),
|
|
333
|
-
versions: [],
|
|
334
|
-
policy: buildPolicy(ctx),
|
|
335
|
-
});
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const versionId = reqUrl.searchParams.get("version");
|
|
339
|
-
if (versionId) {
|
|
340
|
-
sendJson(res, 410, { error: "version history disabled" });
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (reqUrl.searchParams.get("meta") === "1") {
|
|
344
|
-
sendJson(res, 200, {
|
|
345
|
-
...(await readFileMeta(ctx, abs)),
|
|
346
|
-
policy: buildPolicy(ctx),
|
|
347
|
-
});
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const content = await readFile(abs, "utf8");
|
|
351
|
-
const fileStat = await stat(abs);
|
|
352
|
-
const contentHash = sha256(content);
|
|
353
|
-
sendJson(res, 200, {
|
|
354
|
-
path: relativeToRoot(ctx.root, abs),
|
|
355
|
-
content,
|
|
356
|
-
size: content.length,
|
|
357
|
-
bytes: Buffer.byteLength(content, "utf8"),
|
|
358
|
-
sha256: contentHash,
|
|
359
|
-
mtimeMs: fileStat.mtimeMs,
|
|
360
|
-
policy: buildPolicy(ctx),
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
catch (error) {
|
|
364
|
-
const message = error?.message || "read failed";
|
|
365
|
-
const status = error?.status || (message.includes("ENOENT") ? 404 : 400);
|
|
366
|
-
sendJson(res, status, { error: message });
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
async function handlePost(ctx, req, res) {
|
|
370
|
-
const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
371
|
-
if (!validateWriteRequest(req, ctx, reqUrl)) {
|
|
372
|
-
sendJson(res, 403, { error: "forbidden" });
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
let body;
|
|
376
|
-
try {
|
|
377
|
-
body = JSON.parse(await readBody(req));
|
|
378
|
-
}
|
|
379
|
-
catch (error) {
|
|
380
|
-
sendJson(res, 400, { error: error?.message || "invalid json" });
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
const { path: rel, content, baseHash, restoreVersion } = body || {};
|
|
384
|
-
if (typeof restoreVersion === "string" && restoreVersion) {
|
|
385
|
-
sendJson(res, 410, { error: "version history disabled" });
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (typeof content !== "string") {
|
|
389
|
-
sendJson(res, 400, { error: "missing content" });
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
393
|
-
sendJson(res, 413, { error: "content too large (>5MB)" });
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
const abs = await safePath(ctx, rel || "");
|
|
398
|
-
const before = await readExisting(abs);
|
|
399
|
-
const beforeHash = before == null ? null : sha256(before);
|
|
400
|
-
if (beforeHash !== null && typeof baseHash !== "string") {
|
|
401
|
-
sendJson(res, 428, { error: "missing baseHash for existing file", currentHash: beforeHash });
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
if (beforeHash === null && baseHash != null) {
|
|
405
|
-
sendJson(res, 409, { error: "baseHash supplied for a new file", currentHash: null });
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
if (beforeHash !== null && baseHash !== beforeHash) {
|
|
409
|
-
sendJson(res, 409, {
|
|
410
|
-
error: "file changed on disk; reload before saving",
|
|
411
|
-
conflict: true,
|
|
412
|
-
path: relativeToRoot(ctx.root, abs),
|
|
413
|
-
expectedHash: baseHash,
|
|
414
|
-
currentHash: beforeHash,
|
|
415
|
-
policy: buildPolicy(ctx),
|
|
416
|
-
});
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
const afterHash = sha256(content);
|
|
420
|
-
if (beforeHash === afterHash) {
|
|
421
|
-
const fileStat = await stat(abs);
|
|
422
|
-
sendJson(res, 200, {
|
|
423
|
-
ok: true,
|
|
424
|
-
path: relativeToRoot(ctx.root, abs),
|
|
425
|
-
bytes: Buffer.byteLength(content, "utf8"),
|
|
426
|
-
sha256: afterHash,
|
|
427
|
-
beforeHash,
|
|
428
|
-
backupPath: null,
|
|
429
|
-
mtimeMs: fileStat.mtimeMs,
|
|
430
|
-
policy: buildPolicy(ctx),
|
|
431
|
-
});
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
435
|
-
await writeFile(abs, content, "utf8");
|
|
436
|
-
const fileStat = await stat(abs);
|
|
437
|
-
const bytes = Buffer.byteLength(content, "utf8");
|
|
438
|
-
sendJson(res, 200, {
|
|
439
|
-
ok: true,
|
|
440
|
-
path: relativeToRoot(ctx.root, abs),
|
|
441
|
-
bytes,
|
|
442
|
-
sha256: afterHash,
|
|
443
|
-
beforeHash,
|
|
444
|
-
backupPath: null,
|
|
445
|
-
mtimeMs: fileStat.mtimeMs,
|
|
446
|
-
policy: buildPolicy(ctx),
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
catch (error) {
|
|
450
|
-
sendJson(res, 400, { error: error?.message || "write failed" });
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
async function safeAssetPath(ctx, rel) {
|
|
454
|
-
if (typeof rel !== "string" || !rel) {
|
|
455
|
-
throw new Error("missing path");
|
|
456
|
-
}
|
|
457
|
-
const normalized = rel.replace(/^\/+/, "");
|
|
458
|
-
const abs = resolve(ctx.root, normalized);
|
|
459
|
-
if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
|
|
460
|
-
throw new Error("path escape");
|
|
461
|
-
}
|
|
462
|
-
const ext = extname(abs).toLowerCase();
|
|
463
|
-
if (!ASSET_IMAGE_EXT.has(ext)) {
|
|
464
|
-
throw new Error("only image files are allowed");
|
|
465
|
-
}
|
|
466
|
-
const real = await resolveReal(abs);
|
|
467
|
-
if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
|
|
468
|
-
throw new Error("path escape via symlink");
|
|
469
|
-
}
|
|
470
|
-
for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
|
|
471
|
-
const first = candidate.split(sep)[0];
|
|
472
|
-
if (DISALLOWED_TOP_DIRS.has(first)) {
|
|
473
|
-
throw new Error(`assets under ${first}/ are not allowed`);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
return real;
|
|
477
|
-
}
|
|
478
|
-
function assetExt(name, contentType) {
|
|
479
|
-
const fromName = extname(String(name || "")).toLowerCase();
|
|
480
|
-
if (ASSET_IMAGE_EXT.has(fromName))
|
|
481
|
-
return fromName;
|
|
482
|
-
return ASSET_CONTENT_TYPE_EXT[String(contentType || "").toLowerCase()] || "";
|
|
483
|
-
}
|
|
484
|
-
function assetSlug(name) {
|
|
485
|
-
const base = String(name || "image").replace(/\.[^.]+$/, "");
|
|
486
|
-
const slug = base
|
|
487
|
-
.normalize("NFKD")
|
|
488
|
-
.replace(/[^\w.-]+/g, "-")
|
|
489
|
-
.replace(/^[-.]+|[-.]+$/g, "")
|
|
490
|
-
.toLowerCase();
|
|
491
|
-
return slug || "image";
|
|
492
|
-
}
|
|
493
|
-
function assetDirRel(scope, htmlDirRel) {
|
|
494
|
-
if (scope === "global")
|
|
495
|
-
return "assets";
|
|
496
|
-
return htmlDirRel ? join(htmlDirRel, "assets") : "assets";
|
|
497
|
-
}
|
|
498
|
-
async function readBytesOrNull(abs) {
|
|
499
|
-
try {
|
|
500
|
-
return await readFile(abs);
|
|
501
|
-
}
|
|
502
|
-
catch (error) {
|
|
503
|
-
if (error?.code === "ENOENT")
|
|
504
|
-
return null;
|
|
505
|
-
throw error;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
function sendBinary(res, status, contentType, buf) {
|
|
509
|
-
res.statusCode = status;
|
|
510
|
-
res.setHeader("content-type", contentType);
|
|
511
|
-
res.setHeader("cache-control", "no-store");
|
|
512
|
-
res.setHeader("x-content-type-options", "nosniff");
|
|
513
|
-
res.setHeader("content-security-policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
|
514
|
-
res.end(buf);
|
|
515
|
-
}
|
|
516
|
-
async function handleAssetGet(ctx, req, reqUrl, res) {
|
|
517
|
-
if (!validateBridgeRequest(req, ctx, reqUrl)) {
|
|
518
|
-
sendJson(res, 403, { error: "forbidden" });
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const file = reqUrl.searchParams.get("file") || "";
|
|
522
|
-
const htmlDirRel = file ? dirname(file) : "";
|
|
523
|
-
if (reqUrl.searchParams.get("list") === "1") {
|
|
524
|
-
const scope = reqUrl.searchParams.get("scope") === "global" ? "global" : "project";
|
|
525
|
-
const dirRel = assetDirRel(scope, htmlDirRel);
|
|
526
|
-
const absDir = resolve(ctx.root, dirRel);
|
|
527
|
-
const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
|
|
528
|
-
let assets = [];
|
|
529
|
-
try {
|
|
530
|
-
if (absDir === ctx.root || absDir.startsWith(ctx.root + sep)) {
|
|
531
|
-
const names = await readdir(absDir);
|
|
532
|
-
assets = names
|
|
533
|
-
.filter((name) => ASSET_IMAGE_EXT.has(extname(name).toLowerCase()))
|
|
534
|
-
.map((name) => ({
|
|
535
|
-
name,
|
|
536
|
-
src: toPosixPath(relative(htmlDirAbs, join(absDir, name))),
|
|
537
|
-
}));
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
catch {
|
|
541
|
-
assets = [];
|
|
542
|
-
}
|
|
543
|
-
sendJson(res, 200, { assets, policy: buildPolicy(ctx) });
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
const src = reqUrl.searchParams.get("src") || "";
|
|
547
|
-
if (!src) {
|
|
548
|
-
sendJson(res, 400, { error: "missing src" });
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
try {
|
|
552
|
-
const abs = await safeAssetPath(ctx, join(htmlDirRel, src));
|
|
553
|
-
const buf = await readFile(abs);
|
|
554
|
-
const ext = extname(abs).toLowerCase();
|
|
555
|
-
sendBinary(res, 200, ASSET_CONTENT_TYPES[ext] || "application/octet-stream", buf);
|
|
556
|
-
}
|
|
557
|
-
catch (error) {
|
|
558
|
-
const message = error?.message || "read failed";
|
|
559
|
-
const status = message.includes("ENOENT") ? 404 : 400;
|
|
560
|
-
sendJson(res, status, { error: message });
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
async function handleAssetPost(ctx, req, res) {
|
|
564
|
-
const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
565
|
-
if (!validateWriteRequest(req, ctx, reqUrl)) {
|
|
566
|
-
sendJson(res, 403, { error: "forbidden" });
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
let body;
|
|
570
|
-
try {
|
|
571
|
-
body = JSON.parse(await readBody(req, MAX_ASSET_BYTES * 2));
|
|
572
|
-
}
|
|
573
|
-
catch (error) {
|
|
574
|
-
sendJson(res, 400, { error: error?.message || "invalid json" });
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
const { file, scope: rawScope, name, contentType, dataBase64 } = body || {};
|
|
578
|
-
if (typeof dataBase64 !== "string" || !dataBase64) {
|
|
579
|
-
sendJson(res, 400, { error: "missing image data" });
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
const buf = Buffer.from(dataBase64, "base64");
|
|
583
|
-
if (!buf.length) {
|
|
584
|
-
sendJson(res, 400, { error: "empty image data" });
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
if (buf.length > MAX_ASSET_BYTES) {
|
|
588
|
-
sendJson(res, 413, { error: "image too large (>8MB)" });
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const ext = assetExt(name, contentType);
|
|
592
|
-
if (!ASSET_IMAGE_EXT.has(ext)) {
|
|
593
|
-
sendJson(res, 400, { error: "unsupported image type" });
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
const scope = rawScope === "global" ? "global" : "project";
|
|
597
|
-
const htmlDirRel = typeof file === "string" && file ? dirname(file) : "";
|
|
598
|
-
const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
|
|
599
|
-
const dirRel = assetDirRel(scope, htmlDirRel);
|
|
600
|
-
const slug = assetSlug(name);
|
|
601
|
-
try {
|
|
602
|
-
let fileName = `${slug}${ext}`;
|
|
603
|
-
let absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
|
|
604
|
-
const existing = await readBytesOrNull(absTarget);
|
|
605
|
-
if (existing && !existing.equals(buf)) {
|
|
606
|
-
fileName = `${slug}-${sha256Buffer(buf).slice(0, 8)}${ext}`;
|
|
607
|
-
absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
|
|
608
|
-
}
|
|
609
|
-
const alreadyIdentical = Boolean(existing && existing.equals(buf));
|
|
610
|
-
if (!alreadyIdentical) {
|
|
611
|
-
await mkdir(dirname(absTarget), { recursive: true });
|
|
612
|
-
await writeFile(absTarget, buf);
|
|
613
|
-
}
|
|
614
|
-
sendJson(res, 200, {
|
|
615
|
-
ok: true,
|
|
616
|
-
scope,
|
|
617
|
-
name: fileName,
|
|
618
|
-
path: toPosixPath(relative(ctx.root, absTarget)),
|
|
619
|
-
src: toPosixPath(relative(htmlDirAbs, absTarget)),
|
|
620
|
-
bytes: buf.length,
|
|
621
|
-
reused: alreadyIdentical,
|
|
622
|
-
policy: buildPolicy(ctx),
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
catch (error) {
|
|
626
|
-
sendJson(res, 400, { error: error?.message || "asset write failed" });
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
function createBridgeServer(ctx) {
|
|
630
|
-
return createServer(async (req, res) => {
|
|
631
|
-
if (!applyCors(req, res, ctx.editorOrigin))
|
|
632
|
-
return;
|
|
633
|
-
if (req.method === "OPTIONS") {
|
|
634
|
-
res.statusCode = 204;
|
|
635
|
-
res.end();
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
if (!isLoopbackHostHeader(req.headers.host)) {
|
|
639
|
-
sendJson(res, 403, { error: "forbidden host" });
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
643
|
-
if (reqUrl.pathname === "/") {
|
|
644
|
-
sendJson(res, 200, { ok: true, service: "html2pptx edit bridge", root: ctx.root });
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
if (reqUrl.pathname === "/api/edit-slide/asset") {
|
|
648
|
-
if (req.method === "GET") {
|
|
649
|
-
await handleAssetGet(ctx, req, reqUrl, res);
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (req.method === "POST") {
|
|
653
|
-
await handleAssetPost(ctx, req, res);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
if (reqUrl.pathname !== "/api/edit-slide/file") {
|
|
660
|
-
sendJson(res, 404, { error: "not found" });
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
if (req.method === "GET") {
|
|
664
|
-
await handleGet(ctx, req, reqUrl, res);
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
if (req.method === "POST") {
|
|
668
|
-
await handlePost(ctx, req, res);
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
function listen(server, requestedPort) {
|
|
675
|
-
return new Promise((resolveListen, rejectListen) => {
|
|
676
|
-
const onError = (error) => {
|
|
677
|
-
server.off("listening", onListening);
|
|
678
|
-
rejectListen(error);
|
|
679
|
-
};
|
|
680
|
-
const onListening = () => {
|
|
681
|
-
server.off("error", onError);
|
|
682
|
-
const address = server.address();
|
|
683
|
-
if (!address || typeof address === "string") {
|
|
684
|
-
rejectListen(new Error("failed to read bridge port"));
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
resolveListen(address.port);
|
|
688
|
-
};
|
|
689
|
-
server.once("error", onError);
|
|
690
|
-
server.once("listening", onListening);
|
|
691
|
-
server.listen(requestedPort, "127.0.0.1");
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
function openUrl(url) {
|
|
695
|
-
const os = platform();
|
|
696
|
-
const child = os === "darwin"
|
|
697
|
-
? spawn("open", [url], { detached: true, stdio: "ignore" })
|
|
698
|
-
: os === "win32"
|
|
699
|
-
? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })
|
|
700
|
-
: spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
701
|
-
child.unref();
|
|
702
|
-
}
|
|
703
|
-
function stopOnSignal(server) {
|
|
704
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
705
|
-
process.once(signal, () => {
|
|
706
|
-
server.close(() => process.exit(0));
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
function bail(message, options) {
|
|
711
|
-
if (options.json) {
|
|
712
|
-
console.log(JSON.stringify({ success: false, error: message }));
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
p.log.error(message);
|
|
716
|
-
}
|
|
717
|
-
process.exit(1);
|
|
718
|
-
}
|
|
719
|
-
export async function editCommand(input, options = {}) {
|
|
720
|
-
const noOpen = options.noOpen === true || options.open === false;
|
|
721
|
-
const normalizedOptions = { ...options, noOpen };
|
|
722
|
-
if (!input) {
|
|
723
|
-
bail("HTML file path is required. Example: html2pptx edit ./html2pptx/slides.html", normalizedOptions);
|
|
724
|
-
}
|
|
725
|
-
const root = await realpath(process.cwd());
|
|
726
|
-
const abs = resolve(root, input);
|
|
727
|
-
const rel = relativeToRoot(root, abs);
|
|
728
|
-
const ext = extname(abs).toLowerCase();
|
|
729
|
-
if (!ALLOWED_EXT.has(ext)) {
|
|
730
|
-
bail("Only .html/.htm files can be opened in the editor.", normalizedOptions);
|
|
731
|
-
}
|
|
732
|
-
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
733
|
-
bail("The file must be inside the current working directory.", normalizedOptions);
|
|
734
|
-
}
|
|
735
|
-
try {
|
|
736
|
-
await stat(abs);
|
|
737
|
-
}
|
|
738
|
-
catch {
|
|
739
|
-
bail(`File not found: ${rel}`, normalizedOptions);
|
|
740
|
-
}
|
|
741
|
-
let baseUrl;
|
|
742
|
-
let requestedPort;
|
|
743
|
-
try {
|
|
744
|
-
baseUrl = await resolveEditorBaseUrl(root, options.baseUrl);
|
|
745
|
-
requestedPort = parsePort(options.port);
|
|
746
|
-
}
|
|
747
|
-
catch (error) {
|
|
748
|
-
bail(error.message, normalizedOptions);
|
|
749
|
-
}
|
|
750
|
-
const ctx = {
|
|
751
|
-
root,
|
|
752
|
-
editorOrigin: baseUrl.origin,
|
|
753
|
-
localStateDir: join(root, ".html2pptx", "edit-slide"),
|
|
754
|
-
sessionToken: generateSessionToken(),
|
|
755
|
-
};
|
|
756
|
-
const server = createBridgeServer(ctx);
|
|
757
|
-
let bridgePort;
|
|
758
|
-
try {
|
|
759
|
-
bridgePort = await listen(server, requestedPort);
|
|
760
|
-
}
|
|
761
|
-
catch (error) {
|
|
762
|
-
bail(error.message || "failed to start local edit bridge", options);
|
|
763
|
-
}
|
|
764
|
-
const bridgeUrl = `http://127.0.0.1:${bridgePort}`;
|
|
765
|
-
const editorUrl = buildEditorUrl(baseUrl, rel, bridgeUrl, ctx.sessionToken);
|
|
766
|
-
if (normalizedOptions.json) {
|
|
767
|
-
console.log(JSON.stringify({
|
|
768
|
-
success: true,
|
|
769
|
-
editorUrl: editorUrl.toString(),
|
|
770
|
-
bridgeUrl,
|
|
771
|
-
sessionTokenRequired: true,
|
|
772
|
-
file: rel,
|
|
773
|
-
root,
|
|
774
|
-
}));
|
|
775
|
-
}
|
|
776
|
-
else {
|
|
777
|
-
p.log.success(`Local edit bridge listening on ${pc.cyan(bridgeUrl)} ${pc.dim("(session token required)")}`);
|
|
778
|
-
if (normalizedOptions.noOpen) {
|
|
779
|
-
p.log.info(`Open in editor: ${pc.cyan(editorUrl.toString())}`);
|
|
780
|
-
}
|
|
781
|
-
else {
|
|
782
|
-
p.log.info("Opening the editor in your browser. Use --no-open to print the tokenized URL instead.");
|
|
783
|
-
}
|
|
784
|
-
p.log.info(pc.dim("Press Ctrl+C to stop the bridge."));
|
|
785
|
-
}
|
|
786
|
-
if (!normalizedOptions.noOpen) {
|
|
787
|
-
openUrl(editorUrl.toString());
|
|
788
|
-
}
|
|
789
|
-
stopOnSignal(server);
|
|
790
|
-
await new Promise((resolveClose) => server.on("close", resolveClose));
|
|
791
|
-
}
|
|
792
|
-
export const editCommandInternalsForTest = {
|
|
793
|
-
buildEditorUrl,
|
|
794
|
-
createBridgeServer,
|
|
795
|
-
generateSessionToken,
|
|
796
|
-
listen,
|
|
797
|
-
normalizeBaseUrl,
|
|
798
|
-
parsePort,
|
|
799
|
-
readRegisteredEditorBaseUrl,
|
|
800
|
-
resolveEditorBaseUrl,
|
|
801
|
-
};
|