@agent-native/core 0.149.0 → 0.149.1
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/corpus/README.md +1 -1
- package/corpus/templates/slides/actions/patch-deck.ts +18 -2
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
- package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +10 -1
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +26 -4
- package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
- package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -0
- package/corpus/templates/slides/app/pages/Index.tsx +16 -9
- package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
- package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
- package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
- package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
- package/dist/client/progress/RunsTray.js +14 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { lookup as dnsLookup, type LookupAddress } from "node:dns";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import type { LookupFunction } from "node:net";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
isPrivateAddress,
|
|
8
|
+
MAX_PROXIED_IMAGE_BYTES,
|
|
9
|
+
MAX_PROXY_REDIRECTS,
|
|
10
|
+
parseProxyableImageUrl,
|
|
11
|
+
} from "./image-proxy-url.js";
|
|
12
|
+
|
|
13
|
+
export type RemoteImageFailure =
|
|
14
|
+
| "unsupported-url"
|
|
15
|
+
| "blocked-address"
|
|
16
|
+
| "fetch-failed"
|
|
17
|
+
| "too-many-redirects"
|
|
18
|
+
| "not-an-image"
|
|
19
|
+
| "too-large";
|
|
20
|
+
|
|
21
|
+
export type RemoteImageResult =
|
|
22
|
+
| { ok: true; contentType: string; body: Buffer }
|
|
23
|
+
| { ok: false; reason: RemoteImageFailure };
|
|
24
|
+
|
|
25
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A `lookup` implementation that only ever hands the socket an address we have
|
|
29
|
+
* classified as public.
|
|
30
|
+
*
|
|
31
|
+
* Validating the hostname separately and then calling `fetch` leaves a gap: the
|
|
32
|
+
* two resolutions are independent, so a DNS-rebinding host can answer with a
|
|
33
|
+
* public address for the check and a loopback or metadata address for the
|
|
34
|
+
* actual connection. Because Node passes this straight to `net.connect`, the
|
|
35
|
+
* address that is checked here is the address that gets dialled.
|
|
36
|
+
*/
|
|
37
|
+
export const publicOnlyLookup: LookupFunction = (
|
|
38
|
+
hostname,
|
|
39
|
+
options,
|
|
40
|
+
callback,
|
|
41
|
+
) => {
|
|
42
|
+
const wantsAll =
|
|
43
|
+
typeof options === "object" && options !== null && options.all === true;
|
|
44
|
+
const hints = typeof options === "object" && options !== null ? options : {};
|
|
45
|
+
|
|
46
|
+
dnsLookup(hostname, { ...hints, all: true }, (err, addresses) => {
|
|
47
|
+
if (err) {
|
|
48
|
+
(callback as (e: NodeJS.ErrnoException) => void)(err);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const safe = (addresses as LookupAddress[]).filter(
|
|
52
|
+
(entry) => !isPrivateAddress(entry.address),
|
|
53
|
+
);
|
|
54
|
+
if (safe.length === 0) {
|
|
55
|
+
const blocked: NodeJS.ErrnoException = new Error(
|
|
56
|
+
`Refusing to connect to a non-public address for ${hostname}`,
|
|
57
|
+
);
|
|
58
|
+
blocked.code = "EBLOCKED";
|
|
59
|
+
(callback as (e: NodeJS.ErrnoException) => void)(blocked);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (wantsAll) {
|
|
63
|
+
(callback as unknown as (e: null, a: LookupAddress[]) => void)(
|
|
64
|
+
null,
|
|
65
|
+
safe,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
callback(null, safe[0].address, safe[0].family);
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
interface HopResult {
|
|
74
|
+
status: number;
|
|
75
|
+
headers: http.IncomingHttpHeaders;
|
|
76
|
+
read: () => Promise<Buffer | "too-large">;
|
|
77
|
+
discard: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function requestHop(target: URL): Promise<HopResult> {
|
|
81
|
+
return new Promise<HopResult>((resolve, reject) => {
|
|
82
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
83
|
+
const request = transport.request(
|
|
84
|
+
target,
|
|
85
|
+
{
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: { Accept: "image/*" },
|
|
88
|
+
lookup: publicOnlyLookup,
|
|
89
|
+
},
|
|
90
|
+
(response) => {
|
|
91
|
+
resolve({
|
|
92
|
+
status: response.statusCode ?? 0,
|
|
93
|
+
headers: response.headers,
|
|
94
|
+
discard: () => response.destroy(),
|
|
95
|
+
read: () =>
|
|
96
|
+
new Promise<Buffer | "too-large">((resolveBody, rejectBody) => {
|
|
97
|
+
const chunks: Buffer[] = [];
|
|
98
|
+
let total = 0;
|
|
99
|
+
response.on("data", (chunk: Buffer) => {
|
|
100
|
+
total += chunk.length;
|
|
101
|
+
// Stop at the cap instead of buffering first and measuring
|
|
102
|
+
// after: a chunked response, or one that lies about
|
|
103
|
+
// Content-Length, would otherwise pull the whole body into
|
|
104
|
+
// memory before anyone checks it.
|
|
105
|
+
if (total > MAX_PROXIED_IMAGE_BYTES) {
|
|
106
|
+
response.destroy();
|
|
107
|
+
resolveBody("too-large");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
chunks.push(chunk);
|
|
111
|
+
});
|
|
112
|
+
response.on("end", () => resolveBody(Buffer.concat(chunks)));
|
|
113
|
+
response.on("error", rejectBody);
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
120
|
+
request.destroy(new Error("Timed out fetching image"));
|
|
121
|
+
});
|
|
122
|
+
request.on("error", reject);
|
|
123
|
+
request.end();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetch a remote image for the proxy route. Every hop is re-parsed against the
|
|
129
|
+
* URL policy and every connection is pinned to a validated public address.
|
|
130
|
+
*/
|
|
131
|
+
export async function fetchRemoteImage(
|
|
132
|
+
raw: string,
|
|
133
|
+
): Promise<RemoteImageResult> {
|
|
134
|
+
const firstTarget = parseProxyableImageUrl(raw);
|
|
135
|
+
if (!firstTarget) return { ok: false, reason: "unsupported-url" };
|
|
136
|
+
let target: URL = firstTarget;
|
|
137
|
+
|
|
138
|
+
for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) {
|
|
139
|
+
let response: HopResult;
|
|
140
|
+
try {
|
|
141
|
+
response = await requestHop(target);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: code === "EBLOCKED" ? "blocked-address" : "fetch-failed",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (response.status >= 300 && response.status < 400) {
|
|
151
|
+
response.discard();
|
|
152
|
+
const location = response.headers.location;
|
|
153
|
+
const next: URL | null = location
|
|
154
|
+
? parseProxyableImageUrl(new URL(location, target).href)
|
|
155
|
+
: null;
|
|
156
|
+
if (!next) return { ok: false, reason: "fetch-failed" };
|
|
157
|
+
target = next;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (response.status < 200 || response.status >= 300) {
|
|
162
|
+
response.discard();
|
|
163
|
+
return { ok: false, reason: "fetch-failed" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const contentType = String(response.headers["content-type"] ?? "");
|
|
167
|
+
if (!contentType.startsWith("image/")) {
|
|
168
|
+
response.discard();
|
|
169
|
+
return { ok: false, reason: "not-an-image" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const declared = Number(response.headers["content-length"]);
|
|
173
|
+
if (Number.isFinite(declared) && declared > MAX_PROXIED_IMAGE_BYTES) {
|
|
174
|
+
response.discard();
|
|
175
|
+
return { ok: false, reason: "too-large" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let body: Buffer | "too-large";
|
|
179
|
+
try {
|
|
180
|
+
body = await response.read();
|
|
181
|
+
} catch {
|
|
182
|
+
// coercion-ok: a truncated transfer has no partial-success form here;
|
|
183
|
+
// the caller answers 502 either way.
|
|
184
|
+
return { ok: false, reason: "fetch-failed" };
|
|
185
|
+
}
|
|
186
|
+
if (body === "too-large") return { ok: false, reason: "too-large" };
|
|
187
|
+
|
|
188
|
+
return { ok: true, contentType, body };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: false, reason: "too-many-redirects" };
|
|
192
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hostnames that never belong to a legitimate slide image but are classic
|
|
5
|
+
* SSRF targets. Cloud metadata endpoints resolve to a link-local address
|
|
6
|
+
* that `isPrivateAddress` already rejects; they are listed here so the
|
|
7
|
+
* request is refused before a DNS query is issued.
|
|
8
|
+
*/
|
|
9
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
10
|
+
"localhost",
|
|
11
|
+
"metadata.google.internal",
|
|
12
|
+
"metadata",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"];
|
|
16
|
+
|
|
17
|
+
/** Largest image we are willing to buffer and hand back to the browser. */
|
|
18
|
+
export const MAX_PROXIED_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Redirect hops to follow. Each hop is re-validated before it is fetched. */
|
|
21
|
+
export const MAX_PROXY_REDIRECTS = 3;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether an IP literal points somewhere inside our own infrastructure.
|
|
25
|
+
* Anything unroutable, loopback, link-local, or RFC1918 is refused, as is
|
|
26
|
+
* any address we cannot parse — unknown means unsafe here.
|
|
27
|
+
*/
|
|
28
|
+
export function isPrivateAddress(address: string): boolean {
|
|
29
|
+
const version = net.isIP(address);
|
|
30
|
+
|
|
31
|
+
if (version === 4) {
|
|
32
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
33
|
+
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const [a, b] = parts;
|
|
37
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
38
|
+
if (a === 169 && b === 254) return true;
|
|
39
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
40
|
+
if (a === 192 && b === 168) return true;
|
|
41
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
42
|
+
if (a >= 224) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (version === 6) {
|
|
47
|
+
// Classify on the expanded hextets, never on the text. `::ffff:127.0.0.1`
|
|
48
|
+
// and `::ffff:7f00:1` are the same address, and a prefix-matching check
|
|
49
|
+
// sees only the first.
|
|
50
|
+
const hextets = expandIpv6(address);
|
|
51
|
+
if (!hextets) return true;
|
|
52
|
+
|
|
53
|
+
const embedsIpv4 =
|
|
54
|
+
hextets.slice(0, 5).every((part) => part === 0) &&
|
|
55
|
+
(hextets[5] === 0xffff || hextets[5] === 0);
|
|
56
|
+
if (embedsIpv4 && (hextets[6] !== 0 || hextets[7] !== 0)) {
|
|
57
|
+
const ipv4 = [
|
|
58
|
+
hextets[6] >> 8,
|
|
59
|
+
hextets[6] & 0xff,
|
|
60
|
+
hextets[7] >> 8,
|
|
61
|
+
hextets[7] & 0xff,
|
|
62
|
+
].join(".");
|
|
63
|
+
return isPrivateAddress(ipv4);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Unspecified (::) and loopback (::1).
|
|
67
|
+
if (hextets.every((part) => part === 0)) return true;
|
|
68
|
+
if (hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Unique-local fc00::/7, link-local fe80::/10, multicast ff00::/8.
|
|
72
|
+
if ((hextets[0] & 0xfe00) === 0xfc00) return true;
|
|
73
|
+
if ((hextets[0] & 0xffc0) === 0xfe80) return true;
|
|
74
|
+
if ((hextets[0] & 0xff00) === 0xff00) return true;
|
|
75
|
+
// NAT64 well-known prefix 64:ff9b::/96 tunnels an IPv4 destination.
|
|
76
|
+
if (hextets[0] === 0x0064 && hextets[1] === 0xff9b) {
|
|
77
|
+
const ipv4 = [
|
|
78
|
+
hextets[6] >> 8,
|
|
79
|
+
hextets[6] & 0xff,
|
|
80
|
+
hextets[7] >> 8,
|
|
81
|
+
hextets[7] & 0xff,
|
|
82
|
+
].join(".");
|
|
83
|
+
return isPrivateAddress(ipv4);
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Expand any textual IPv6 form into its 8 hextets, including `::` elision and
|
|
93
|
+
* a dotted IPv4 tail. Returns null when the input is not parseable.
|
|
94
|
+
*/
|
|
95
|
+
export function expandIpv6(address: string): number[] | null {
|
|
96
|
+
// Zone indices ("fe80::1%eth0") are routing hints, not part of the address.
|
|
97
|
+
const bare = address.toLowerCase().split("%")[0];
|
|
98
|
+
if (!bare) return null;
|
|
99
|
+
|
|
100
|
+
let head = bare;
|
|
101
|
+
let tail = "";
|
|
102
|
+
const elision = bare.indexOf("::");
|
|
103
|
+
if (elision !== -1) {
|
|
104
|
+
head = bare.slice(0, elision);
|
|
105
|
+
tail = bare.slice(elision + 2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parseGroups = (segment: string): string[] =>
|
|
109
|
+
segment.length === 0 ? [] : segment.split(":");
|
|
110
|
+
|
|
111
|
+
const headGroups = parseGroups(head);
|
|
112
|
+
const tailGroups = parseGroups(tail);
|
|
113
|
+
const all = [...headGroups, ...tailGroups];
|
|
114
|
+
|
|
115
|
+
// A dotted IPv4 tail occupies the final two hextets.
|
|
116
|
+
const last = all[all.length - 1];
|
|
117
|
+
let ipv4Tail: number[] | null = null;
|
|
118
|
+
if (last?.includes(".")) {
|
|
119
|
+
if (net.isIP(last) !== 4) return null;
|
|
120
|
+
const octets = last.split(".").map((part) => Number(part));
|
|
121
|
+
if (
|
|
122
|
+
octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
|
123
|
+
)
|
|
124
|
+
return null;
|
|
125
|
+
ipv4Tail = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
|
|
126
|
+
if (tailGroups.length > 0) tailGroups.pop();
|
|
127
|
+
else headGroups.pop();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const toHextets = (groups: string[]): number[] | null => {
|
|
131
|
+
const out: number[] = [];
|
|
132
|
+
for (const group of groups) {
|
|
133
|
+
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
|
|
134
|
+
out.push(parseInt(group, 16));
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const headParts = toHextets(headGroups);
|
|
140
|
+
const tailParts = toHextets(tailGroups);
|
|
141
|
+
if (!headParts || !tailParts) return null;
|
|
142
|
+
|
|
143
|
+
const explicit = [...headParts, ...tailParts, ...(ipv4Tail ? ipv4Tail : [])]
|
|
144
|
+
.length;
|
|
145
|
+
if (explicit > 8) return null;
|
|
146
|
+
|
|
147
|
+
if (elision === -1) {
|
|
148
|
+
if (explicit !== 8) return null;
|
|
149
|
+
return [...headParts, ...(ipv4Tail ?? [])];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const fill = new Array(8 - explicit).fill(0) as number[];
|
|
153
|
+
return [...headParts, ...fill, ...tailParts, ...(ipv4Tail ?? [])];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a caller-supplied image URL, refusing anything that is not a plain
|
|
158
|
+
* public http(s) resource. Returns null rather than throwing so callers can
|
|
159
|
+
* answer with a single 400.
|
|
160
|
+
*/
|
|
161
|
+
export function parseProxyableImageUrl(raw: string): URL | null {
|
|
162
|
+
if (!raw) return null;
|
|
163
|
+
|
|
164
|
+
let url: URL;
|
|
165
|
+
try {
|
|
166
|
+
url = new URL(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
// coercion-ok: null is this function's documented "refused" result and
|
|
169
|
+
// the caller answers 400; an unparseable URL carries no other detail.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
174
|
+
// Credentials in the URL would be replayed by the server on the user's
|
|
175
|
+
// behalf against a host they may not control.
|
|
176
|
+
if (url.username || url.password) return null;
|
|
177
|
+
|
|
178
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
179
|
+
if (!host) return null;
|
|
180
|
+
if (BLOCKED_HOSTNAMES.has(host)) return null;
|
|
181
|
+
if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return null;
|
|
182
|
+
|
|
183
|
+
const literal = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
184
|
+
if (net.isIP(literal) && isPrivateAddress(literal)) return null;
|
|
185
|
+
|
|
186
|
+
return url;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A hostname is no longer validated here before fetching. Resolving once for
|
|
190
|
+
// the check and again for the connection let a DNS-rebinding host answer
|
|
191
|
+
// differently each time, so the address check now lives in the socket's
|
|
192
|
+
// `lookup` — see `publicOnlyLookup` in fetch-remote-image.ts.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getSession } from "@agent-native/core/server";
|
|
2
|
+
import { defineEventHandler, getQuery, setResponseStatus } from "h3";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
fetchRemoteImage,
|
|
6
|
+
type RemoteImageFailure,
|
|
7
|
+
} from "../../lib/fetch-remote-image.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Re-serve a remote image from our own origin.
|
|
11
|
+
*
|
|
12
|
+
* PDF/PPTX export rasterizes the slide DOM through a canvas, and the browser
|
|
13
|
+
* blanks out any image whose host does not send `Access-Control-Allow-Origin`.
|
|
14
|
+
* No client-side flag can override that, so images on hosts without CORS have
|
|
15
|
+
* to come back through us to be same-origin.
|
|
16
|
+
*
|
|
17
|
+
* This is an authenticated, image-only, size-capped fetcher — not a general
|
|
18
|
+
* proxy. See `fetch-remote-image.ts` for the address pinning that keeps it
|
|
19
|
+
* from being turned into an SSRF primitive.
|
|
20
|
+
*/
|
|
21
|
+
const FAILURE_STATUS: Record<RemoteImageFailure, number> = {
|
|
22
|
+
"unsupported-url": 400,
|
|
23
|
+
"blocked-address": 400,
|
|
24
|
+
"fetch-failed": 502,
|
|
25
|
+
"too-many-redirects": 502,
|
|
26
|
+
"not-an-image": 415,
|
|
27
|
+
"too-large": 413,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const FAILURE_MESSAGE: Record<RemoteImageFailure, string> = {
|
|
31
|
+
"unsupported-url": "Unsupported image URL",
|
|
32
|
+
"blocked-address": "Unsupported image URL",
|
|
33
|
+
"fetch-failed": "Could not fetch image",
|
|
34
|
+
"too-many-redirects": "Too many redirects",
|
|
35
|
+
"not-an-image": "Not an image",
|
|
36
|
+
"too-large": "Image too large",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default defineEventHandler(async (event) => {
|
|
40
|
+
const session = await getSession(event);
|
|
41
|
+
if (!session?.email) {
|
|
42
|
+
setResponseStatus(event, 401);
|
|
43
|
+
return { error: "Unauthorized" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const raw = getQuery(event).url;
|
|
47
|
+
if (typeof raw !== "string") {
|
|
48
|
+
setResponseStatus(event, 400);
|
|
49
|
+
return { error: "Missing url" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = await fetchRemoteImage(raw);
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
setResponseStatus(event, FAILURE_STATUS[result.reason]);
|
|
55
|
+
return { error: FAILURE_MESSAGE[result.reason] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
event.node?.res?.setHeader("Content-Type", result.contentType);
|
|
59
|
+
event.node?.res?.setHeader("Content-Length", String(result.body.byteLength));
|
|
60
|
+
event.node?.res?.setHeader("Cache-Control", "private, max-age=3600");
|
|
61
|
+
// The canvas reads these pixels back, so the response must be explicitly
|
|
62
|
+
// usable cross-origin even though it is served from our own host.
|
|
63
|
+
event.node?.res?.setHeader("Access-Control-Allow-Origin", "*");
|
|
64
|
+
return result.body;
|
|
65
|
+
});
|
|
@@ -9,6 +9,15 @@ import { useChangeVersion } from "../use-change-version.js";
|
|
|
9
9
|
import { usePollLoop } from "../use-poll-loop.js";
|
|
10
10
|
import { cn } from "../utils.js";
|
|
11
11
|
const RUN_CHANGE_SETTLE_MS = 250;
|
|
12
|
+
/**
|
|
13
|
+
* Cadence used while a run still reads as active, even for hosts that opted
|
|
14
|
+
* out of idle polling with `pollMs={0}`. Those hosts only refresh on mount and
|
|
15
|
+
* on a `runs` change event, and a run abandoned mid-flight (budget exhausted,
|
|
16
|
+
* dead worker) emits neither — so the spinner has no path back to a terminal
|
|
17
|
+
* status. Polling only while something looks active keeps the idle cost at
|
|
18
|
+
* zero and still lets the server's stale sweep terminalize the row.
|
|
19
|
+
*/
|
|
20
|
+
const ACTIVE_RUN_POLL_MS = 5000;
|
|
12
21
|
function useRunsTrayState({ pollMs = 3000, limit = 5, hideWhenIdle = true, showRecent, }) {
|
|
13
22
|
const t = useT();
|
|
14
23
|
const [runs, setRuns] = useState([]);
|
|
@@ -39,7 +48,11 @@ function useRunsTrayState({ pollMs = 3000, limit = 5, hideWhenIdle = true, showR
|
|
|
39
48
|
const timeout = window.setTimeout(() => void refresh(), RUN_CHANGE_SETTLE_MS);
|
|
40
49
|
return () => window.clearTimeout(timeout);
|
|
41
50
|
}, [refresh, runsVersion]);
|
|
42
|
-
|
|
51
|
+
const hasActiveRun = runs.some((run) => run.status === "running");
|
|
52
|
+
usePollLoop(refresh, {
|
|
53
|
+
intervalMs: pollMs > 0 ? pollMs : ACTIVE_RUN_POLL_MS,
|
|
54
|
+
enabled: pollMs > 0 || hasActiveRun,
|
|
55
|
+
});
|
|
43
56
|
const dismissRun = useCallback(async (runId) => {
|
|
44
57
|
setRuns((current) => current.filter((run) => run.id !== runId));
|
|
45
58
|
try {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
error: string;
|
|
17
16
|
ok?: undefined;
|
|
17
|
+
error: string;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
|
|
|
48
48
|
}>;
|
|
49
49
|
/** DELETE /_agent-native/resources/:id — delete a resource */
|
|
50
50
|
export declare function handleDeleteResource(event: any): Promise<{
|
|
51
|
-
ok?: undefined;
|
|
52
51
|
error: string;
|
|
52
|
+
ok?: undefined;
|
|
53
53
|
} | {
|
|
54
54
|
error?: undefined;
|
|
55
55
|
ok: boolean;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,16 +34,16 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
ok?: undefined;
|
|
38
37
|
status?: undefined;
|
|
38
|
+
ok?: undefined;
|
|
39
39
|
} | {
|
|
40
40
|
ok: boolean;
|
|
41
41
|
status: string;
|
|
42
42
|
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
-
ok?: undefined;
|
|
45
44
|
error: string;
|
|
46
45
|
removed?: undefined;
|
|
46
|
+
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
48
|
ok: boolean;
|
|
49
49
|
removed: boolean;
|
|
@@ -54,9 +54,9 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
|
|
|
54
54
|
* or the current stored value without changing anything.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
-
ok?: undefined;
|
|
58
57
|
error: string;
|
|
59
58
|
note?: undefined;
|
|
59
|
+
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
61
|
ok: boolean;
|
|
62
62
|
note?: undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.149.
|
|
3
|
+
"version": "0.149.1",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|