ima2-gen 3.13.1 → 3.14.0
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/.env.example +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/commands/capabilities.js +5 -3
- package/bin/commands/config.js +15 -4
- package/bin/commands/defaults.js +7 -5
- package/bin/commands/doctor.js +88 -39
- package/bin/commands/gen.js +21 -4
- package/bin/commands/models.js +2 -2
- package/bin/commands/ping.js +1 -1
- package/bin/commands/prompt.js +5 -3
- package/bin/commands/service.js +32 -4
- package/bin/commands/tools.js +19 -3
- package/bin/commands/upscale.js +18 -4
- package/bin/commands/video.js +37 -50
- package/bin/ima2.js +28 -9
- package/bin/lib/characterResolve.js +2 -1
- package/bin/lib/client.js +155 -20
- package/bin/lib/config-store.js +40 -7
- package/bin/lib/doctor-bundle.js +6 -2
- package/bin/lib/doctor-checks.js +13 -29
- package/bin/lib/doctor-media.js +3 -3
- package/bin/lib/doctor-providers.js +48 -35
- package/bin/lib/doctor-report.js +78 -0
- package/bin/lib/doctor-runtime.js +173 -0
- package/bin/lib/mcpJob.js +19 -7
- package/bin/lib/output.js +3 -0
- package/bin/lib/platform.js +12 -9
- package/bin/lib/recover-output.js +9 -2
- package/bin/lib/sse.js +9 -1
- package/bin/lib/videoMcp.js +16 -8
- package/config.js +41 -5
- package/docs/API.md +154 -16
- package/docs/CLI.md +65 -1
- package/docs/DOCKER.md +19 -4
- package/docs/DOCKER.zh-CN.md +11 -4
- package/docs/DOCKER.zh-TW.md +11 -4
- package/docs/FAQ.md +13 -0
- package/docs/NPX_QUICKSTART.md +6 -0
- package/docs/README.ja.md +15 -2
- package/docs/README.ko.md +15 -2
- package/docs/README.zh-CN.md +15 -2
- package/docs/README.zh-TW.md +15 -2
- package/docs/migration/runtime-test-inventory.md +79 -8
- package/lib/agyArtifact.js +82 -0
- package/lib/agyArtifactRead.js +249 -0
- package/lib/agyImageAdapter.js +2 -329
- package/lib/agyProcess.js +122 -0
- package/lib/apiRequestBudget.js +48 -0
- package/lib/assetLifecycle.js +42 -9
- package/lib/canvasVersionStore.js +46 -14
- package/lib/capabilities.js +7 -1
- package/lib/configKeys.js +2 -0
- package/lib/errors/providerMap.js +26 -0
- package/lib/eventBus.js +10 -6
- package/lib/eventsPolicy.js +9 -0
- package/lib/geminiApiImageAdapter.js +1 -214
- package/lib/generatePipeline.js +39 -165
- package/lib/generatedMediaAccess.js +74 -0
- package/lib/generationErrors.js +3 -22
- package/lib/grokImageAdapter.js +2 -353
- package/lib/grokImageCore.js +1 -57
- package/lib/grokImageDownload.js +159 -0
- package/lib/grokImageDownloadPolicy.js +124 -0
- package/lib/grokImagePlanner.js +301 -0
- package/lib/grokMultimodeAdapter.js +1 -75
- package/lib/grokVideoDownload.js +137 -34
- package/lib/inflight.js +112 -112
- package/lib/jobs/terminalStore.js +45 -0
- package/lib/lanSessionStore.js +131 -0
- package/lib/localAccessPolicy.js +180 -0
- package/lib/localLanAccess.js +281 -0
- package/lib/logger.js +22 -13
- package/lib/mcp/downloadMediaResult.js +72 -78
- package/lib/multimodePipeline.js +39 -126
- package/lib/nodeGeneration.js +44 -101
- package/lib/nodeStore.js +25 -11
- package/lib/nodeValidation.js +5 -1
- package/lib/pinnedHttpGet.js +174 -0
- package/lib/promptImport/githubFolder.js +32 -18
- package/lib/promptImport/githubSource.js +14 -10
- package/lib/promptImport/parsePromptCandidates.js +32 -2
- package/lib/providers/adapters/agyOperations.js +179 -0
- package/lib/providers/adapters/comfy.js +1 -0
- package/lib/providers/adapters/geminiOperations.js +214 -0
- package/lib/providers/adapters/googleExecution.js +97 -0
- package/lib/providers/adapters/grokExecution.js +137 -0
- package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
- package/lib/providers/adapters/grokOperations.js +63 -0
- package/lib/providers/adapters/openaiExecution.js +118 -0
- package/lib/providers/adapters/openaiOperations.js +201 -0
- package/lib/providers/adapters/openaiTypes.js +1 -0
- package/lib/providers/derive.js +4 -0
- package/lib/providers/deriveCore.js +2 -7
- package/lib/providers/execution/admission.js +25 -0
- package/lib/providers/execution/index.js +34 -0
- package/lib/providers/execution/legacy.js +17 -0
- package/lib/providers/execution/legacyClassic.js +66 -0
- package/lib/providers/execution/legacyEdit.js +30 -0
- package/lib/providers/execution/legacyMultimode.js +34 -0
- package/lib/providers/execution/legacyNode.js +41 -0
- package/lib/providers/execution/types.js +1 -0
- package/lib/providers/registry.js +19 -14
- package/lib/providers/surfaceSupport.js +22 -0
- package/lib/responsesImageAdapter.js +1 -389
- package/lib/responsesTransport.js +190 -0
- package/lib/spriteJobEvents.js +20 -2
- package/lib/ssePublish.js +5 -4
- package/lib/videoExtendI2vOperation.js +76 -0
- package/package.json +5 -2
- package/routes/edit.js +31 -123
- package/routes/events.js +140 -63
- package/routes/mcpMedia.js +10 -2
- package/routes/models.js +28 -25
- package/routes/videoExtended.js +9 -68
- package/server.js +24 -46
- package/ui/dist/.vite/manifest.json +90 -63
- package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
- package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
- package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
- package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
- package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
- package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
- package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
- package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
- package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
- package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
- package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
- package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
- package/ui/dist/assets/index-7ngEOVxt.js +5 -0
- package/ui/dist/assets/index-B5jy7ePg.css +1 -0
- package/ui/dist/assets/index-gxh_02DQ.js +22 -0
- package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
- package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
- package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
- package/ui/dist/index.html +3 -2
- package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
- package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
- package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
- package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
- package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
- package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
- package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
- package/ui/dist/assets/index-0fpfY8vu.css +0 -1
- package/ui/dist/assets/index-BFciPGTn.js +0 -5
- package/ui/dist/assets/index-BbykeXR9.js +0 -30
- package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/lib/nodeStore.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { writeFile, readFile, access, mkdir } from "fs/promises";
|
|
2
|
-
import {
|
|
1
|
+
import { writeFile, readFile, access, mkdir, realpath } from "fs/promises";
|
|
2
|
+
import { resolve, sep } from "path";
|
|
3
3
|
import { randomBytes } from "crypto";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
|
-
import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
|
|
5
|
+
import { embedImageMetadataBestEffort, isSupportedMetadataFormat } from "./imageMetadataStore.js";
|
|
6
6
|
import { invalidateHistoryIndex } from "./historyIndex.js";
|
|
7
7
|
export function newNodeId() {
|
|
8
8
|
return "n_" + randomBytes(config.ids.nodeHexBytes).toString("hex");
|
|
9
9
|
}
|
|
10
10
|
export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png", generatedDir = config.storage.generatedDir }) {
|
|
11
|
-
|
|
11
|
+
if (typeof ext !== "string" || !isSupportedMetadataFormat(ext)) {
|
|
12
|
+
throw Object.assign(new Error("Format must be png, jpeg, jpg, or webp."), { code: "INVALID_FORMAT", status: 400 });
|
|
13
|
+
}
|
|
12
14
|
const filename = `${nodeId}.${ext}`;
|
|
15
|
+
const imagePath = resolveGeneratedPath(rootDir, filename, generatedDir);
|
|
16
|
+
const metadataPath = resolveGeneratedPath(rootDir, filename + ".json", generatedDir);
|
|
13
17
|
await mkdir(generatedDir, { recursive: true });
|
|
14
18
|
const imageMeta = {
|
|
15
19
|
...meta,
|
|
@@ -22,8 +26,8 @@ export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png", genera
|
|
|
22
26
|
if (!embedded.embedded) {
|
|
23
27
|
console.warn("[nodeStore] metadata embed skipped:", embedded.warning);
|
|
24
28
|
}
|
|
25
|
-
await writeFile(
|
|
26
|
-
await writeFile(
|
|
29
|
+
await writeFile(imagePath, embedded.buffer);
|
|
30
|
+
await writeFile(metadataPath, JSON.stringify(meta, null, 2));
|
|
27
31
|
invalidateHistoryIndex();
|
|
28
32
|
return { filename };
|
|
29
33
|
}
|
|
@@ -38,13 +42,15 @@ export async function loadNodeB64(rootDir, filename, generatedDir = config.stora
|
|
|
38
42
|
err.status = 404;
|
|
39
43
|
throw err;
|
|
40
44
|
}
|
|
41
|
-
const
|
|
45
|
+
const containedPath = await resolveCanonicalGeneratedPath(rootDir, p, generatedDir);
|
|
46
|
+
const buf = await readFile(containedPath);
|
|
42
47
|
return buf.toString("base64");
|
|
43
48
|
}
|
|
44
49
|
export async function loadNodeMeta(rootDir, nodeId, ext = "png", generatedDir = config.storage.generatedDir) {
|
|
45
|
-
void rootDir;
|
|
46
50
|
try {
|
|
47
|
-
|
|
51
|
+
const metadataPath = resolveGeneratedPath(rootDir, `${nodeId}.${ext}.json`, generatedDir);
|
|
52
|
+
const containedPath = await resolveCanonicalGeneratedPath(rootDir, metadataPath, generatedDir);
|
|
53
|
+
return JSON.parse(await readFile(containedPath, "utf-8"));
|
|
48
54
|
}
|
|
49
55
|
catch {
|
|
50
56
|
return null;
|
|
@@ -61,9 +67,16 @@ export async function loadAssetB64(rootDir, externalSrc, generatedDir = config.s
|
|
|
61
67
|
err.status = 404;
|
|
62
68
|
throw err;
|
|
63
69
|
}
|
|
64
|
-
const
|
|
70
|
+
const containedPath = await resolveCanonicalGeneratedPath(rootDir, p, generatedDir);
|
|
71
|
+
const buf = await readFile(containedPath);
|
|
65
72
|
return buf.toString("base64");
|
|
66
73
|
}
|
|
74
|
+
async function resolveCanonicalGeneratedPath(rootDir, path, generatedDir) {
|
|
75
|
+
const canonicalRoot = await realpath(generatedDir);
|
|
76
|
+
const canonicalPath = await realpath(path);
|
|
77
|
+
// Reuse the explicit-dir boundary after resolving directory and leaf symlinks.
|
|
78
|
+
return resolveGeneratedPath(rootDir, canonicalPath, canonicalRoot);
|
|
79
|
+
}
|
|
67
80
|
function resolveGeneratedPath(rootDir, relPath, generatedDir = config.storage.generatedDir) {
|
|
68
81
|
void rootDir;
|
|
69
82
|
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
@@ -74,7 +87,8 @@ function resolveGeneratedPath(rootDir, relPath, generatedDir = config.storage.ge
|
|
|
74
87
|
}
|
|
75
88
|
const baseDir = resolve(generatedDir);
|
|
76
89
|
const target = resolve(baseDir, relPath);
|
|
77
|
-
|
|
90
|
+
const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
|
|
91
|
+
if (target !== baseDir && !target.startsWith(prefix)) {
|
|
78
92
|
const err = new Error(`Asset path escapes generated/: ${relPath}`);
|
|
79
93
|
err.code = "NODE_SOURCE_INVALID";
|
|
80
94
|
err.status = 400;
|
package/lib/nodeValidation.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { validateAndNormalizeRefs } from "./refs.js";
|
|
2
2
|
import { validateModeration } from "./routeHelpers.js";
|
|
3
3
|
import { validateGenerationPrompt } from "./generationInputValidation.js";
|
|
4
|
-
|
|
4
|
+
import { isSupportedMetadataFormat } from "./imageMetadataStore.js";
|
|
5
|
+
export function validateNodeInputs(ctx, prompt, references, moderation, format = "png") {
|
|
5
6
|
const promptError = validateGenerationPrompt(prompt);
|
|
6
7
|
if (promptError)
|
|
7
8
|
return promptError;
|
|
9
|
+
if (typeof format !== "string" || !isSupportedMetadataFormat(format)) {
|
|
10
|
+
return { error: { code: "INVALID_FORMAT", message: "Format must be png, jpeg, jpg, or webp." } };
|
|
11
|
+
}
|
|
8
12
|
const refCheckResult = validateAndNormalizeRefs(references);
|
|
9
13
|
if (refCheckResult.error) {
|
|
10
14
|
return {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { request as httpRequest } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { resolvePublicDownloadTarget } from "./grokImageDownloadPolicy.js";
|
|
5
|
+
export class PinnedGetFailure extends Error {
|
|
6
|
+
}
|
|
7
|
+
function pinnedLookup(target) {
|
|
8
|
+
const hostname = target.url.hostname.replace(/^\[|\]$/g, "");
|
|
9
|
+
return (host, options, callback) => {
|
|
10
|
+
const family = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family || 0;
|
|
11
|
+
const addresses = target.addresses.filter((address) => !family || address.family === family);
|
|
12
|
+
const first = addresses[0];
|
|
13
|
+
if (host !== hostname || !first || (family !== 0 && family !== 4 && family !== 6)) {
|
|
14
|
+
callback(new PinnedGetFailure("Image download address selection failed"), []);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (options.all)
|
|
18
|
+
callback(null, addresses.map((address) => ({ ...address })));
|
|
19
|
+
else
|
|
20
|
+
callback(null, first.address, first.family);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Own error-event handling independently of the already-settled header promise. */
|
|
24
|
+
class PinnedGetLifecycle {
|
|
25
|
+
signal;
|
|
26
|
+
requestUrl;
|
|
27
|
+
resolve;
|
|
28
|
+
reject;
|
|
29
|
+
request;
|
|
30
|
+
response;
|
|
31
|
+
stopped = false;
|
|
32
|
+
headersReceived = false;
|
|
33
|
+
onAbort;
|
|
34
|
+
constructor(signal, requestUrl, resolve, reject) {
|
|
35
|
+
this.signal = signal;
|
|
36
|
+
this.requestUrl = requestUrl;
|
|
37
|
+
this.resolve = resolve;
|
|
38
|
+
this.reject = reject;
|
|
39
|
+
this.onAbort = () => {
|
|
40
|
+
this.reject(signal.reason);
|
|
41
|
+
this.cancel(signal.reason instanceof Error ? signal.reason : undefined);
|
|
42
|
+
};
|
|
43
|
+
signal.addEventListener("abort", this.onAbort, { once: true });
|
|
44
|
+
if (signal.aborted)
|
|
45
|
+
this.onAbort();
|
|
46
|
+
}
|
|
47
|
+
cancel = (reason) => {
|
|
48
|
+
if (this.stopped)
|
|
49
|
+
return;
|
|
50
|
+
this.stopped = true;
|
|
51
|
+
this.signal.removeEventListener("abort", this.onAbort);
|
|
52
|
+
this.response?.destroy(reason);
|
|
53
|
+
this.request?.destroy(reason);
|
|
54
|
+
};
|
|
55
|
+
fail = (error) => {
|
|
56
|
+
this.reject(error); // Raw pre-header reset/EPIPE must reach the real retry classifier.
|
|
57
|
+
this.cancel(error instanceof Error ? error : undefined);
|
|
58
|
+
};
|
|
59
|
+
attach(request) {
|
|
60
|
+
this.request = request;
|
|
61
|
+
request.on("error", this.fail);
|
|
62
|
+
// Keep the response handler available for a late response even after abort.
|
|
63
|
+
request.on("response", this.accept);
|
|
64
|
+
request.once("close", () => {
|
|
65
|
+
request.removeListener("error", this.fail);
|
|
66
|
+
if (!this.headersReceived && !this.stopped) {
|
|
67
|
+
this.fail(new PinnedGetFailure("Image download closed before response"));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
if (this.stopped)
|
|
71
|
+
request.destroy();
|
|
72
|
+
}
|
|
73
|
+
accept = (response) => {
|
|
74
|
+
const onError = (error) => this.fail(error);
|
|
75
|
+
response.on("error", onError);
|
|
76
|
+
response.once("close", () => response.removeListener("error", onError));
|
|
77
|
+
if (this.stopped || this.signal.aborted || this.headersReceived) {
|
|
78
|
+
response.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.response = response;
|
|
82
|
+
this.headersReceived = true;
|
|
83
|
+
this.resolve({
|
|
84
|
+
url: this.requestUrl,
|
|
85
|
+
status: response.statusCode ?? 0,
|
|
86
|
+
headers: { get: (name) => {
|
|
87
|
+
const value = response.headers[name.toLowerCase()];
|
|
88
|
+
return Array.isArray(value) ? value.join(", ") : value ?? null;
|
|
89
|
+
} },
|
|
90
|
+
body: response,
|
|
91
|
+
cancel: this.cancel,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export async function pinnedHttpGet(target, signal, options = {}) {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const lifecycle = new PinnedGetLifecycle(signal, target.url.href, resolve, reject);
|
|
99
|
+
try {
|
|
100
|
+
if (lifecycle.stopped)
|
|
101
|
+
return;
|
|
102
|
+
const hostname = target.url.hostname.replace(/^\[|\]$/g, "");
|
|
103
|
+
const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
104
|
+
lifecycle.attach(request(target.url, {
|
|
105
|
+
method: "GET", agent: false, signal,
|
|
106
|
+
headers: { ...options.headers, Host: target.url.host },
|
|
107
|
+
...(options.family ? { family: options.family } : {}),
|
|
108
|
+
lookup: pinnedLookup(target),
|
|
109
|
+
...(isIP(hostname) ? {} : { servername: hostname }),
|
|
110
|
+
}));
|
|
111
|
+
if (!lifecycle.stopped)
|
|
112
|
+
lifecycle.request?.end();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
lifecycle.fail(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const MAX_PUBLIC_REDIRECTS = 5;
|
|
120
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
121
|
+
/** Public consumers cannot opt into Grok's configured proxy exception. */
|
|
122
|
+
export async function publicPinnedHttpGet(rawUrl, signal, validateUrl, headers) {
|
|
123
|
+
let url = new URL(rawUrl);
|
|
124
|
+
try {
|
|
125
|
+
for (let hop = 0;; hop++) {
|
|
126
|
+
signal.throwIfAborted();
|
|
127
|
+
validateUrl(url.href); // Includes scheme/allowhost, before DNS and every socket.
|
|
128
|
+
const target = await resolvePublicDownloadTarget(url, signal);
|
|
129
|
+
const response = await pinnedHttpGet(target, signal, headers ? { headers } : {});
|
|
130
|
+
if (!REDIRECT_STATUSES.has(response.status))
|
|
131
|
+
return response;
|
|
132
|
+
try {
|
|
133
|
+
const location = response.headers.get("location");
|
|
134
|
+
if (!location?.trim() || hop >= MAX_PUBLIC_REDIRECTS)
|
|
135
|
+
throw new Error("PINNED_GET_REDIRECT_REFUSED");
|
|
136
|
+
url = new URL(location, url);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
response.cancel();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export class PinnedBodyTooLarge extends Error {
|
|
148
|
+
constructor() { super("Remote file is too large"); }
|
|
149
|
+
}
|
|
150
|
+
/** Bound bytes while streaming, including absent or dishonest Content-Length. */
|
|
151
|
+
export async function readPinnedBody(response, maxBytes) {
|
|
152
|
+
try {
|
|
153
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
154
|
+
throw new PinnedBodyTooLarge();
|
|
155
|
+
if (Number(response.headers.get("content-length")) > maxBytes)
|
|
156
|
+
throw new PinnedBodyTooLarge();
|
|
157
|
+
const chunks = [];
|
|
158
|
+
let total = 0;
|
|
159
|
+
if (response.body)
|
|
160
|
+
for await (const chunk of response.body) {
|
|
161
|
+
total += chunk.byteLength;
|
|
162
|
+
if (total > maxBytes)
|
|
163
|
+
throw new PinnedBodyTooLarge();
|
|
164
|
+
chunks.push(Buffer.from(chunk));
|
|
165
|
+
}
|
|
166
|
+
return Buffer.concat(chunks, total); // Empty text remains a valid import input.
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
response.cancel();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { promptImportError } from "./errors.js";
|
|
3
3
|
import { errInfo } from "../errInfo.js";
|
|
4
|
+
import { publicPinnedHttpGet, readPinnedBody, PinnedBodyTooLarge } from "../pinnedHttpGet.js";
|
|
4
5
|
const GITHUB_HOST = "github.com";
|
|
5
6
|
const GITHUB_API_HOST = "api.github.com";
|
|
6
7
|
const RAW_HOST = "raw.githubusercontent.com";
|
|
@@ -27,7 +28,13 @@ function safePath(path) {
|
|
|
27
28
|
if (decoded.includes("\\") || decoded.split("/").includes("..")) {
|
|
28
29
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder traversal is not allowed");
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
+
let start = 0;
|
|
32
|
+
let end = decoded.length;
|
|
33
|
+
while (start < end && decoded[start] === "/")
|
|
34
|
+
start++;
|
|
35
|
+
while (end > start && decoded[end - 1] === "/")
|
|
36
|
+
end--;
|
|
37
|
+
return decoded.slice(start, end);
|
|
31
38
|
}
|
|
32
39
|
function assertOwnerRepo(owner, repo) {
|
|
33
40
|
if (!OWNER_REPO_RE.test(owner || "") || !OWNER_REPO_RE.test(repo || "")) {
|
|
@@ -118,7 +125,7 @@ function assertGithubApiUrl(rawUrl) {
|
|
|
118
125
|
catch {
|
|
119
126
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder fetch returned an invalid URL");
|
|
120
127
|
}
|
|
121
|
-
if (
|
|
128
|
+
if (url.protocol !== "https:" || url.hostname !== GITHUB_API_HOST) {
|
|
122
129
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder fetch used an unsupported host");
|
|
123
130
|
}
|
|
124
131
|
}
|
|
@@ -130,7 +137,7 @@ function assertRawDownloadUrl(rawUrl) {
|
|
|
130
137
|
catch {
|
|
131
138
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder file has an invalid download URL");
|
|
132
139
|
}
|
|
133
|
-
if (
|
|
140
|
+
if (url.protocol !== "https:" || url.hostname !== RAW_HOST) {
|
|
134
141
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder file download host is unsupported");
|
|
135
142
|
}
|
|
136
143
|
const path = safePath(url.pathname.split("/").filter(Boolean).slice(3).join("/"));
|
|
@@ -177,20 +184,24 @@ async function fetchJson(url, limits) {
|
|
|
177
184
|
assertGithubApiUrl(url);
|
|
178
185
|
const controller = new AbortController();
|
|
179
186
|
const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
|
|
187
|
+
let response;
|
|
180
188
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
});
|
|
185
|
-
assertGithubApiUrl(response.url || url);
|
|
186
|
-
if (response.status === 404)
|
|
189
|
+
response = await publicPinnedHttpGet(url, controller.signal, assertGithubApiUrl, { Accept: "application/vnd.github+json", "User-Agent": "ima2-gen" });
|
|
190
|
+
if (response.status === 404) {
|
|
191
|
+
await response.cancel();
|
|
187
192
|
return { notFound: true };
|
|
188
|
-
|
|
193
|
+
}
|
|
194
|
+
if (response.status < 200 || response.status >= 300) {
|
|
195
|
+
await response.cancel();
|
|
189
196
|
throw promptImportError("GITHUB_FOLDER_NOT_FOUND", `GitHub folder fetch failed with ${response.status}`, 422);
|
|
190
197
|
}
|
|
191
|
-
|
|
198
|
+
const body = await readPinnedBody(response, limits.maxFileBytesForPreview);
|
|
199
|
+
return { json: JSON.parse(new TextDecoder().decode(body)) };
|
|
192
200
|
}
|
|
193
201
|
catch (error) {
|
|
202
|
+
if (error instanceof PinnedBodyTooLarge) {
|
|
203
|
+
throw promptImportError("REMOTE_FILE_TOO_LARGE", "GitHub folder index is too large", 413);
|
|
204
|
+
}
|
|
194
205
|
const err = errInfo(error);
|
|
195
206
|
if (err.name === "AbortError") {
|
|
196
207
|
throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub folder fetch timed out", 504);
|
|
@@ -198,6 +209,7 @@ async function fetchJson(url, limits) {
|
|
|
198
209
|
throw error;
|
|
199
210
|
}
|
|
200
211
|
finally {
|
|
212
|
+
response?.cancel();
|
|
201
213
|
clearTimeout(timer);
|
|
202
214
|
}
|
|
203
215
|
}
|
|
@@ -283,20 +295,18 @@ async function fetchRawFile(rawUrl, limits) {
|
|
|
283
295
|
assertRawDownloadUrl(rawUrl);
|
|
284
296
|
const controller = new AbortController();
|
|
285
297
|
const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
|
|
298
|
+
let response;
|
|
286
299
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
300
|
+
response = await publicPinnedHttpGet(rawUrl, controller.signal, assertRawDownloadUrl);
|
|
301
|
+
if (response.status < 200 || response.status >= 300) {
|
|
302
|
+
await response.cancel();
|
|
290
303
|
throw promptImportError("INVALID_GITHUB_SOURCE", `GitHub folder file fetch failed with ${response.status}`, 422);
|
|
291
304
|
}
|
|
292
305
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
293
306
|
if (contentLength > limits.maxFileBytesForPreview) {
|
|
294
307
|
throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
|
|
295
308
|
}
|
|
296
|
-
const buffer = await response.
|
|
297
|
-
if (buffer.byteLength > limits.maxFileBytesForPreview) {
|
|
298
|
-
throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
|
|
299
|
-
}
|
|
309
|
+
const buffer = await readPinnedBody(response, limits.maxFileBytesForPreview);
|
|
300
310
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
|
|
301
311
|
return {
|
|
302
312
|
text,
|
|
@@ -304,6 +314,9 @@ async function fetchRawFile(rawUrl, limits) {
|
|
|
304
314
|
};
|
|
305
315
|
}
|
|
306
316
|
catch (error) {
|
|
317
|
+
if (error instanceof PinnedBodyTooLarge) {
|
|
318
|
+
throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
|
|
319
|
+
}
|
|
307
320
|
const err = errInfo(error);
|
|
308
321
|
if (err.name === "AbortError") {
|
|
309
322
|
throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub folder file fetch timed out", 504);
|
|
@@ -311,6 +324,7 @@ async function fetchRawFile(rawUrl, limits) {
|
|
|
311
324
|
throw error;
|
|
312
325
|
}
|
|
313
326
|
finally {
|
|
327
|
+
response?.cancel();
|
|
314
328
|
clearTimeout(timer);
|
|
315
329
|
}
|
|
316
330
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { promptImportError } from "./errors.js";
|
|
3
3
|
import { errInfo } from "../errInfo.js";
|
|
4
|
+
import { publicPinnedHttpGet, readPinnedBody, PinnedBodyTooLarge } from "../pinnedHttpGet.js";
|
|
4
5
|
const ALLOWED_HOSTS = new Set(["github.com", "raw.githubusercontent.com"]);
|
|
5
6
|
const SUPPORTED_EXTENSIONS = new Set(["md", "markdown", "txt"]);
|
|
6
7
|
const OWNER_REPO_RE = /^[A-Za-z0-9_.-]+$/;
|
|
@@ -53,8 +54,8 @@ function normalizeUrlInput(input) {
|
|
|
53
54
|
catch {
|
|
54
55
|
return null;
|
|
55
56
|
}
|
|
56
|
-
if (
|
|
57
|
-
throw promptImportError("INVALID_GITHUB_SOURCE", "Only
|
|
57
|
+
if (url.protocol !== "https:") {
|
|
58
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Only HTTPS GitHub URLs are supported");
|
|
58
59
|
}
|
|
59
60
|
if (!ALLOWED_HOSTS.has(url.hostname)) {
|
|
60
61
|
throw promptImportError("INVALID_GITHUB_SOURCE", "Only GitHub file URLs are supported");
|
|
@@ -157,7 +158,7 @@ function validateFinalFetchUrl(rawUrl) {
|
|
|
157
158
|
catch {
|
|
158
159
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an invalid final URL");
|
|
159
160
|
}
|
|
160
|
-
if (
|
|
161
|
+
if (url.protocol !== "https:") {
|
|
161
162
|
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an unsupported protocol");
|
|
162
163
|
}
|
|
163
164
|
if (!ALLOWED_HOSTS.has(url.hostname)) {
|
|
@@ -189,20 +190,19 @@ export function normalizeGitHubSource(input) {
|
|
|
189
190
|
export async function fetchGitHubSource(source, limits) {
|
|
190
191
|
const controller = new AbortController();
|
|
191
192
|
const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
|
|
193
|
+
let response;
|
|
192
194
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
response = await publicPinnedHttpGet(source.rawUrl, controller.signal, validateFinalFetchUrl);
|
|
196
|
+
if (response.status < 200 || response.status >= 300) {
|
|
197
|
+
await response.cancel();
|
|
196
198
|
throw promptImportError("INVALID_GITHUB_SOURCE", `GitHub file fetch failed with ${response.status}`, 422);
|
|
197
199
|
}
|
|
198
200
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
199
201
|
if (contentLength > limits.maxFileBytesForPreview) {
|
|
202
|
+
await response.cancel();
|
|
200
203
|
throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
|
|
201
204
|
}
|
|
202
|
-
const buffer = await response.
|
|
203
|
-
if (buffer.byteLength > limits.maxFileBytesForPreview) {
|
|
204
|
-
throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
|
|
205
|
-
}
|
|
205
|
+
const buffer = await readPinnedBody(response, limits.maxFileBytesForPreview);
|
|
206
206
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
|
|
207
207
|
return {
|
|
208
208
|
text,
|
|
@@ -213,6 +213,9 @@ export async function fetchGitHubSource(source, limits) {
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
catch (error) {
|
|
216
|
+
if (error instanceof PinnedBodyTooLarge) {
|
|
217
|
+
throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
|
|
218
|
+
}
|
|
216
219
|
const err = errInfo(error);
|
|
217
220
|
if (err.name === "AbortError") {
|
|
218
221
|
throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub fetch timed out", 504);
|
|
@@ -220,6 +223,7 @@ export async function fetchGitHubSource(source, limits) {
|
|
|
220
223
|
throw error;
|
|
221
224
|
}
|
|
222
225
|
finally {
|
|
226
|
+
response?.cancel();
|
|
223
227
|
clearTimeout(timer);
|
|
224
228
|
}
|
|
225
229
|
}
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { createHash } from "crypto";
|
|
2
2
|
function normalizeWhitespace(text) {
|
|
3
|
-
return text.replace(/\r\n/g, "\n").
|
|
3
|
+
return text.replace(/\r\n/g, "\n").split("\n").map((line, index, lines) => {
|
|
4
|
+
if (index === lines.length - 1)
|
|
5
|
+
return line;
|
|
6
|
+
let end = line.length;
|
|
7
|
+
while (end > 0 && (line[end - 1] === " " || line[end - 1] === "\t"))
|
|
8
|
+
end--;
|
|
9
|
+
return line.slice(0, end);
|
|
10
|
+
}).join("\n").trim();
|
|
4
11
|
}
|
|
5
12
|
function stripFrontmatter(text) {
|
|
6
|
-
|
|
13
|
+
if (!text.startsWith("---"))
|
|
14
|
+
return text;
|
|
15
|
+
const openingEnd = text.indexOf("\n");
|
|
16
|
+
if (openingEnd < 0 || text.slice(3, openingEnd).trim() !== "")
|
|
17
|
+
return text;
|
|
18
|
+
let searchFrom = openingEnd + 1;
|
|
19
|
+
while (searchFrom < text.length) {
|
|
20
|
+
const closingStart = text.indexOf("\n---", searchFrom);
|
|
21
|
+
if (closingStart < 0)
|
|
22
|
+
break;
|
|
23
|
+
let end = closingStart + 4;
|
|
24
|
+
let lastNewline = -1;
|
|
25
|
+
// A complete delimiter requires whitespace through a newline. Consume the
|
|
26
|
+
// same trailing whitespace as the old regex without retrying suffixes.
|
|
27
|
+
while (end < text.length && /\s/.test(text[end])) {
|
|
28
|
+
if (text[end] === "\n")
|
|
29
|
+
lastNewline = end;
|
|
30
|
+
end++;
|
|
31
|
+
}
|
|
32
|
+
if (lastNewline >= 0)
|
|
33
|
+
return text.slice(lastNewline + 1);
|
|
34
|
+
searchFrom = end;
|
|
35
|
+
}
|
|
36
|
+
return text;
|
|
7
37
|
}
|
|
8
38
|
function isBoilerplate(line) {
|
|
9
39
|
const trimmed = line.trim();
|