@kodelyth/tlon 2026.5.42 → 2026.6.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/klaw.plugin.json +203 -3
- package/package.json +17 -4
- package/api.ts +0 -16
- package/channel-plugin-api.ts +0 -1
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -16
- package/runtime-api.ts +0 -17
- package/setup-api.ts +0 -2
- package/setup-entry.ts +0 -9
- package/src/account-fields.ts +0 -31
- package/src/channel.message-adapter.test.ts +0 -145
- package/src/channel.runtime.ts +0 -259
- package/src/channel.ts +0 -192
- package/src/config-schema.ts +0 -54
- package/src/core.test.ts +0 -298
- package/src/doctor-contract.ts +0 -9
- package/src/doctor.test.ts +0 -46
- package/src/doctor.ts +0 -10
- package/src/logger-runtime.ts +0 -1
- package/src/monitor/approval-runtime.ts +0 -363
- package/src/monitor/approval.test.ts +0 -33
- package/src/monitor/approval.ts +0 -283
- package/src/monitor/authorization.ts +0 -30
- package/src/monitor/cites.ts +0 -54
- package/src/monitor/discovery.ts +0 -68
- package/src/monitor/history.ts +0 -226
- package/src/monitor/index.ts +0 -1523
- package/src/monitor/media.test.ts +0 -80
- package/src/monitor/media.ts +0 -156
- package/src/monitor/processed-messages.test.ts +0 -58
- package/src/monitor/processed-messages.ts +0 -89
- package/src/monitor/settings-helpers.test.ts +0 -113
- package/src/monitor/settings-helpers.ts +0 -158
- package/src/monitor/utils.ts +0 -402
- package/src/runtime.ts +0 -9
- package/src/security.test.ts +0 -658
- package/src/session-route.ts +0 -40
- package/src/settings.ts +0 -391
- package/src/setup-core.ts +0 -231
- package/src/setup-surface.ts +0 -99
- package/src/targets.ts +0 -102
- package/src/tlon-api.test.ts +0 -572
- package/src/tlon-api.ts +0 -389
- package/src/types.ts +0 -160
- package/src/urbit/auth.ssrf.test.ts +0 -45
- package/src/urbit/auth.ts +0 -48
- package/src/urbit/base-url.test.ts +0 -48
- package/src/urbit/base-url.ts +0 -61
- package/src/urbit/channel-ops.test.ts +0 -36
- package/src/urbit/channel-ops.ts +0 -149
- package/src/urbit/context.ts +0 -50
- package/src/urbit/errors.ts +0 -51
- package/src/urbit/fetch.ts +0 -38
- package/src/urbit/foreigns.ts +0 -49
- package/src/urbit/send.test.ts +0 -83
- package/src/urbit/send.ts +0 -228
- package/src/urbit/sse-client.test.ts +0 -234
- package/src/urbit/sse-client.ts +0 -492
- package/src/urbit/story.ts +0 -332
- package/src/urbit/upload.test.ts +0 -155
- package/src/urbit/upload.ts +0 -60
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
package/src/urbit/story.ts
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tlon Story Format - Rich text converter
|
|
3
|
-
*
|
|
4
|
-
* Converts markdown-like text to Tlon's story format.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
// Inline content types
|
|
8
|
-
type StoryInline =
|
|
9
|
-
| string
|
|
10
|
-
| { bold: StoryInline[] }
|
|
11
|
-
| { italics: StoryInline[] }
|
|
12
|
-
| { strike: StoryInline[] }
|
|
13
|
-
| { blockquote: StoryInline[] }
|
|
14
|
-
| { "inline-code": string }
|
|
15
|
-
| { code: string }
|
|
16
|
-
| { ship: string }
|
|
17
|
-
| { link: { href: string; content: string } }
|
|
18
|
-
| { break: null }
|
|
19
|
-
| { tag: string };
|
|
20
|
-
|
|
21
|
-
// Block content types
|
|
22
|
-
type StoryBlock =
|
|
23
|
-
| { header: { tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; content: StoryInline[] } }
|
|
24
|
-
| { code: { code: string; lang: string } }
|
|
25
|
-
| { image: { src: string; height: number; width: number; alt: string } }
|
|
26
|
-
| { rule: null }
|
|
27
|
-
| { listing: StoryListing };
|
|
28
|
-
|
|
29
|
-
type StoryListing =
|
|
30
|
-
| {
|
|
31
|
-
list: {
|
|
32
|
-
type: "ordered" | "unordered" | "tasklist";
|
|
33
|
-
items: StoryListing[];
|
|
34
|
-
contents: StoryInline[];
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
| { item: StoryInline[] };
|
|
38
|
-
|
|
39
|
-
// A verse is either a block or inline content
|
|
40
|
-
type StoryVerse = { block: StoryBlock } | { inline: StoryInline[] };
|
|
41
|
-
|
|
42
|
-
// A story is a list of verses
|
|
43
|
-
export type Story = StoryVerse[];
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Parse inline markdown formatting (bold, italic, code, links, mentions)
|
|
47
|
-
*/
|
|
48
|
-
function parseInlineMarkdown(text: string): StoryInline[] {
|
|
49
|
-
const result: StoryInline[] = [];
|
|
50
|
-
let remaining = text;
|
|
51
|
-
|
|
52
|
-
while (remaining.length > 0) {
|
|
53
|
-
// Ship mentions: ~sampel-palnet
|
|
54
|
-
const shipMatch = remaining.match(/^(~[a-z][-a-z0-9]*)/);
|
|
55
|
-
if (shipMatch) {
|
|
56
|
-
result.push({ ship: shipMatch[1] });
|
|
57
|
-
remaining = remaining.slice(shipMatch[0].length);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Bold: **text** or __text__
|
|
62
|
-
const boldMatch = remaining.match(/^\*\*(.+?)\*\*|^__(.+?)__/);
|
|
63
|
-
if (boldMatch) {
|
|
64
|
-
const content = boldMatch[1] || boldMatch[2];
|
|
65
|
-
result.push({ bold: parseInlineMarkdown(content) });
|
|
66
|
-
remaining = remaining.slice(boldMatch[0].length);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Italics: *text* or _text_ (but not inside words for _)
|
|
71
|
-
const italicsMatch = remaining.match(/^\*([^*]+?)\*|^_([^_]+?)_(?![a-zA-Z0-9])/);
|
|
72
|
-
if (italicsMatch) {
|
|
73
|
-
const content = italicsMatch[1] || italicsMatch[2];
|
|
74
|
-
result.push({ italics: parseInlineMarkdown(content) });
|
|
75
|
-
remaining = remaining.slice(italicsMatch[0].length);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Strikethrough: ~~text~~
|
|
80
|
-
const strikeMatch = remaining.match(/^~~(.+?)~~/);
|
|
81
|
-
if (strikeMatch) {
|
|
82
|
-
result.push({ strike: parseInlineMarkdown(strikeMatch[1]) });
|
|
83
|
-
remaining = remaining.slice(strikeMatch[0].length);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Inline code: `code`
|
|
88
|
-
const codeMatch = remaining.match(/^`([^`]+)`/);
|
|
89
|
-
if (codeMatch) {
|
|
90
|
-
result.push({ "inline-code": codeMatch[1] });
|
|
91
|
-
remaining = remaining.slice(codeMatch[0].length);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Links: [text](url)
|
|
96
|
-
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
|
97
|
-
if (linkMatch) {
|
|
98
|
-
result.push({ link: { href: linkMatch[2], content: linkMatch[1] } });
|
|
99
|
-
remaining = remaining.slice(linkMatch[0].length);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Markdown images: 
|
|
104
|
-
const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
|
|
105
|
-
if (imageMatch) {
|
|
106
|
-
// Return a special marker that will be hoisted to a block
|
|
107
|
-
result.push({
|
|
108
|
-
__image: { src: imageMatch[2], alt: imageMatch[1] },
|
|
109
|
-
} as unknown as StoryInline);
|
|
110
|
-
remaining = remaining.slice(imageMatch[0].length);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Plain URL detection
|
|
115
|
-
const urlMatch = remaining.match(/^(https?:\/\/[^\s<>"\]]+)/);
|
|
116
|
-
if (urlMatch) {
|
|
117
|
-
result.push({ link: { href: urlMatch[1], content: urlMatch[1] } });
|
|
118
|
-
remaining = remaining.slice(urlMatch[0].length);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Hashtags: #tag - disabled, chat UI doesn't render them
|
|
123
|
-
// const tagMatch = remaining.match(/^#([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
124
|
-
// if (tagMatch) {
|
|
125
|
-
// result.push({ tag: tagMatch[1] });
|
|
126
|
-
// remaining = remaining.slice(tagMatch[0].length);
|
|
127
|
-
// continue;
|
|
128
|
-
// }
|
|
129
|
-
|
|
130
|
-
// Plain text: consume until next special character or URL start
|
|
131
|
-
// Exclude : and / to allow URL detection to work (stops before https://)
|
|
132
|
-
const plainMatch = remaining.match(/^[^*_`~[#~\n:/]+/);
|
|
133
|
-
if (plainMatch) {
|
|
134
|
-
result.push(plainMatch[0]);
|
|
135
|
-
remaining = remaining.slice(plainMatch[0].length);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Single special char that didn't match a pattern
|
|
140
|
-
result.push(remaining[0]);
|
|
141
|
-
remaining = remaining.slice(1);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Merge adjacent strings
|
|
145
|
-
return mergeAdjacentStrings(result);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Merge adjacent string elements in an inline array
|
|
150
|
-
*/
|
|
151
|
-
function mergeAdjacentStrings(inlines: StoryInline[]): StoryInline[] {
|
|
152
|
-
const result: StoryInline[] = [];
|
|
153
|
-
for (const item of inlines) {
|
|
154
|
-
if (typeof item === "string" && typeof result[result.length - 1] === "string") {
|
|
155
|
-
result[result.length - 1] = (result[result.length - 1] as string) + item;
|
|
156
|
-
} else {
|
|
157
|
-
result.push(item);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return result;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Create an image block
|
|
165
|
-
*/
|
|
166
|
-
export function createImageBlock(
|
|
167
|
-
src: string,
|
|
168
|
-
alt: string = "",
|
|
169
|
-
height: number = 0,
|
|
170
|
-
width: number = 0,
|
|
171
|
-
): StoryVerse {
|
|
172
|
-
return {
|
|
173
|
-
block: {
|
|
174
|
-
image: { src, height, width, alt },
|
|
175
|
-
},
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Check if URL looks like an image
|
|
181
|
-
*/
|
|
182
|
-
export function isImageUrl(url: string): boolean {
|
|
183
|
-
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i;
|
|
184
|
-
return imageExtensions.test(url);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Process inlines and extract any image markers into blocks
|
|
189
|
-
*/
|
|
190
|
-
function processInlinesForImages(inlines: StoryInline[]): {
|
|
191
|
-
inlines: StoryInline[];
|
|
192
|
-
imageBlocks: StoryVerse[];
|
|
193
|
-
} {
|
|
194
|
-
const cleanInlines: StoryInline[] = [];
|
|
195
|
-
const imageBlocks: StoryVerse[] = [];
|
|
196
|
-
|
|
197
|
-
for (const inline of inlines) {
|
|
198
|
-
if (typeof inline === "object" && "__image" in inline) {
|
|
199
|
-
const img = (inline as unknown as { __image: { src: string; alt: string } })["__image"];
|
|
200
|
-
imageBlocks.push(createImageBlock(img.src, img.alt));
|
|
201
|
-
} else {
|
|
202
|
-
cleanInlines.push(inline);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return { inlines: cleanInlines, imageBlocks };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Convert markdown text to Tlon story format
|
|
211
|
-
*/
|
|
212
|
-
export function markdownToStory(markdown: string): Story {
|
|
213
|
-
const story: Story = [];
|
|
214
|
-
const lines = markdown.split("\n");
|
|
215
|
-
let i = 0;
|
|
216
|
-
|
|
217
|
-
while (i < lines.length) {
|
|
218
|
-
const line = lines[i];
|
|
219
|
-
|
|
220
|
-
// Code block: ```lang\ncode\n```
|
|
221
|
-
if (line.startsWith("```")) {
|
|
222
|
-
const lang = line.slice(3).trim() || "plaintext";
|
|
223
|
-
const codeLines: string[] = [];
|
|
224
|
-
i++;
|
|
225
|
-
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
226
|
-
codeLines.push(lines[i]);
|
|
227
|
-
i++;
|
|
228
|
-
}
|
|
229
|
-
story.push({
|
|
230
|
-
block: {
|
|
231
|
-
code: {
|
|
232
|
-
code: codeLines.join("\n"),
|
|
233
|
-
lang,
|
|
234
|
-
},
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
i++; // skip closing ```
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Headers: # H1, ## H2, etc.
|
|
242
|
-
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
243
|
-
if (headerMatch) {
|
|
244
|
-
const level = headerMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
|
|
245
|
-
const tag = `h${level}` as const;
|
|
246
|
-
story.push({
|
|
247
|
-
block: {
|
|
248
|
-
header: {
|
|
249
|
-
tag,
|
|
250
|
-
content: parseInlineMarkdown(headerMatch[2]),
|
|
251
|
-
},
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
i++;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Horizontal rule: --- or ***
|
|
259
|
-
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
|
260
|
-
story.push({ block: { rule: null } });
|
|
261
|
-
i++;
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Blockquote: > text
|
|
266
|
-
if (line.startsWith("> ")) {
|
|
267
|
-
const quoteLines: string[] = [];
|
|
268
|
-
while (i < lines.length && lines[i].startsWith("> ")) {
|
|
269
|
-
quoteLines.push(lines[i].slice(2));
|
|
270
|
-
i++;
|
|
271
|
-
}
|
|
272
|
-
const quoteText = quoteLines.join("\n");
|
|
273
|
-
story.push({
|
|
274
|
-
inline: [{ blockquote: parseInlineMarkdown(quoteText) }],
|
|
275
|
-
});
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Empty line - skip
|
|
280
|
-
if (line.trim() === "") {
|
|
281
|
-
i++;
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Regular paragraph - collect consecutive non-empty lines
|
|
286
|
-
const paragraphLines: string[] = [];
|
|
287
|
-
while (
|
|
288
|
-
i < lines.length &&
|
|
289
|
-
lines[i].trim() !== "" &&
|
|
290
|
-
!lines[i].startsWith("#") &&
|
|
291
|
-
!lines[i].startsWith("```") &&
|
|
292
|
-
!lines[i].startsWith("> ") &&
|
|
293
|
-
!/^(-{3,}|\*{3,})$/.test(lines[i].trim())
|
|
294
|
-
) {
|
|
295
|
-
paragraphLines.push(lines[i]);
|
|
296
|
-
i++;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (paragraphLines.length > 0) {
|
|
300
|
-
const paragraphText = paragraphLines.join("\n");
|
|
301
|
-
// Convert newlines within paragraph to break elements
|
|
302
|
-
const inlines = parseInlineMarkdown(paragraphText);
|
|
303
|
-
// Replace \n in strings with break elements
|
|
304
|
-
const withBreaks: StoryInline[] = [];
|
|
305
|
-
for (const inline of inlines) {
|
|
306
|
-
if (typeof inline === "string" && inline.includes("\n")) {
|
|
307
|
-
const parts = inline.split("\n");
|
|
308
|
-
for (let j = 0; j < parts.length; j++) {
|
|
309
|
-
if (parts[j]) {
|
|
310
|
-
withBreaks.push(parts[j]);
|
|
311
|
-
}
|
|
312
|
-
if (j < parts.length - 1) {
|
|
313
|
-
withBreaks.push({ break: null });
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
} else {
|
|
317
|
-
withBreaks.push(inline);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Extract any images from inlines and add as separate blocks
|
|
322
|
-
const { inlines: cleanInlines, imageBlocks } = processInlinesForImages(withBreaks);
|
|
323
|
-
|
|
324
|
-
if (cleanInlines.length > 0) {
|
|
325
|
-
story.push({ inline: cleanInlines });
|
|
326
|
-
}
|
|
327
|
-
story.push(...imageBlocks);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return story;
|
|
332
|
-
}
|
package/src/urbit/upload.test.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
|
|
2
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
-
import { uploadFile } from "../tlon-api.js";
|
|
4
|
-
import { uploadImageFromUrl } from "./upload.js";
|
|
5
|
-
|
|
6
|
-
vi.mock("klaw/plugin-sdk/ssrf-runtime", () => ({
|
|
7
|
-
fetchWithSsrFGuard: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
vi.mock("../tlon-api.js", () => ({
|
|
11
|
-
uploadFile: vi.fn(),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
|
15
|
-
const mockUploadFile = vi.mocked(uploadFile);
|
|
16
|
-
|
|
17
|
-
type FetchMock = typeof mockFetch;
|
|
18
|
-
|
|
19
|
-
function mockSuccessfulFetch(params: {
|
|
20
|
-
mockFetch: FetchMock;
|
|
21
|
-
blob: Blob;
|
|
22
|
-
finalUrl: string;
|
|
23
|
-
contentType: string;
|
|
24
|
-
}) {
|
|
25
|
-
params.mockFetch.mockResolvedValue({
|
|
26
|
-
response: {
|
|
27
|
-
ok: true,
|
|
28
|
-
headers: new Headers({ "content-type": params.contentType }),
|
|
29
|
-
blob: () => Promise.resolve(params.blob),
|
|
30
|
-
} as unknown as Response,
|
|
31
|
-
finalUrl: params.finalUrl,
|
|
32
|
-
release: vi.fn().mockResolvedValue(undefined),
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function setupSuccessfulUpload(params?: {
|
|
37
|
-
sourceUrl?: string;
|
|
38
|
-
contentType?: string;
|
|
39
|
-
uploadedUrl?: string;
|
|
40
|
-
}) {
|
|
41
|
-
const sourceUrl = params?.sourceUrl ?? "https://example.com/image.png";
|
|
42
|
-
const contentType = params?.contentType ?? "image/png";
|
|
43
|
-
const mockBlob = new Blob(["fake-image"], { type: contentType });
|
|
44
|
-
mockSuccessfulFetch({
|
|
45
|
-
mockFetch,
|
|
46
|
-
blob: mockBlob,
|
|
47
|
-
finalUrl: sourceUrl,
|
|
48
|
-
contentType,
|
|
49
|
-
});
|
|
50
|
-
if (params?.uploadedUrl) {
|
|
51
|
-
mockUploadFile.mockResolvedValue({ url: params.uploadedUrl });
|
|
52
|
-
}
|
|
53
|
-
return { mockBlob };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function requireUploadParams(): { blob?: Blob; contentType?: string; fileName?: string } {
|
|
57
|
-
const [call] = mockUploadFile.mock.calls;
|
|
58
|
-
if (!call) {
|
|
59
|
-
throw new Error("expected Tlon uploadFile call");
|
|
60
|
-
}
|
|
61
|
-
const [uploadParams] = call;
|
|
62
|
-
if (!uploadParams || typeof uploadParams !== "object" || Array.isArray(uploadParams)) {
|
|
63
|
-
throw new Error("expected Tlon uploadFile params");
|
|
64
|
-
}
|
|
65
|
-
return uploadParams as { blob?: Blob; contentType?: string; fileName?: string };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
describe("uploadImageFromUrl", () => {
|
|
69
|
-
beforeEach(() => {
|
|
70
|
-
vi.clearAllMocks();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
|
|
74
|
-
const { mockBlob } = await setupSuccessfulUpload({
|
|
75
|
-
uploadedUrl: "https://memex.tlon.network/uploaded.png",
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const result = await uploadImageFromUrl("https://example.com/image.png");
|
|
79
|
-
|
|
80
|
-
expect(result).toBe("https://memex.tlon.network/uploaded.png");
|
|
81
|
-
expect(mockUploadFile).toHaveBeenCalledTimes(1);
|
|
82
|
-
const uploadParams = requireUploadParams();
|
|
83
|
-
expect(uploadParams.blob).toBe(mockBlob);
|
|
84
|
-
expect(uploadParams.contentType).toBe("image/png");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("returns original URL if fetch fails", async () => {
|
|
88
|
-
mockFetch.mockResolvedValue({
|
|
89
|
-
response: {
|
|
90
|
-
ok: false,
|
|
91
|
-
status: 404,
|
|
92
|
-
} as unknown as Response,
|
|
93
|
-
finalUrl: "https://example.com/image.png",
|
|
94
|
-
release: vi.fn().mockResolvedValue(undefined),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const result = await uploadImageFromUrl("https://example.com/image.png");
|
|
98
|
-
|
|
99
|
-
expect(result).toBe("https://example.com/image.png");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("returns original URL if upload fails", async () => {
|
|
103
|
-
await setupSuccessfulUpload();
|
|
104
|
-
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
|
|
105
|
-
|
|
106
|
-
const result = await uploadImageFromUrl("https://example.com/image.png");
|
|
107
|
-
|
|
108
|
-
expect(result).toBe("https://example.com/image.png");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("rejects non-http(s) URLs", async () => {
|
|
112
|
-
const result = await uploadImageFromUrl("file:///etc/passwd");
|
|
113
|
-
expect(result).toBe("file:///etc/passwd");
|
|
114
|
-
|
|
115
|
-
const result2 = await uploadImageFromUrl("ftp://example.com/image.png");
|
|
116
|
-
expect(result2).toBe("ftp://example.com/image.png");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("handles invalid URLs gracefully", async () => {
|
|
120
|
-
const result = await uploadImageFromUrl("not-a-valid-url");
|
|
121
|
-
expect(result).toBe("not-a-valid-url");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("extracts filename from URL path", async () => {
|
|
125
|
-
const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
|
|
126
|
-
mockSuccessfulFetch({
|
|
127
|
-
mockFetch,
|
|
128
|
-
blob: mockBlob,
|
|
129
|
-
finalUrl: "https://example.com/path/to/my-image.jpg",
|
|
130
|
-
contentType: "image/jpeg",
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
|
|
134
|
-
|
|
135
|
-
await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
|
|
136
|
-
|
|
137
|
-
expect(requireUploadParams().fileName).toBe("my-image.jpg");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("uses default filename when URL has no path", async () => {
|
|
141
|
-
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
|
142
|
-
mockSuccessfulFetch({
|
|
143
|
-
mockFetch,
|
|
144
|
-
blob: mockBlob,
|
|
145
|
-
finalUrl: "https://example.com/",
|
|
146
|
-
contentType: "image/png",
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
|
150
|
-
|
|
151
|
-
await uploadImageFromUrl("https://example.com/");
|
|
152
|
-
|
|
153
|
-
expect(requireUploadParams().fileName).toMatch(/^upload-\d+\.png$/);
|
|
154
|
-
});
|
|
155
|
-
});
|
package/src/urbit/upload.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Upload an image from a URL to Tlon storage.
|
|
3
|
-
*/
|
|
4
|
-
import { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
|
|
5
|
-
import { uploadFile } from "../tlon-api.js";
|
|
6
|
-
import { getDefaultSsrFPolicy } from "./context.js";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Fetch an image from a URL and upload it to Tlon storage.
|
|
10
|
-
* Returns the uploaded URL, or falls back to the original URL on error.
|
|
11
|
-
*
|
|
12
|
-
* Note: configureClient must be called before using this function.
|
|
13
|
-
*/
|
|
14
|
-
export async function uploadImageFromUrl(imageUrl: string): Promise<string> {
|
|
15
|
-
try {
|
|
16
|
-
// Validate URL is http/https before fetching
|
|
17
|
-
const url = new URL(imageUrl);
|
|
18
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
19
|
-
console.warn(`[tlon] Rejected non-http(s) URL: ${imageUrl}`);
|
|
20
|
-
return imageUrl;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Fetch the image with SSRF protection
|
|
24
|
-
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
|
|
25
|
-
const { response, release } = await fetchWithSsrFGuard({
|
|
26
|
-
url: imageUrl,
|
|
27
|
-
init: { method: "GET" },
|
|
28
|
-
policy: getDefaultSsrFPolicy(),
|
|
29
|
-
auditContext: "tlon-upload-image",
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
if (!response.ok) {
|
|
34
|
-
console.warn(`[tlon] Failed to fetch image from ${imageUrl}: ${response.status}`);
|
|
35
|
-
return imageUrl;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const contentType = response.headers.get("content-type") || "image/png";
|
|
39
|
-
const blob = await response.blob();
|
|
40
|
-
|
|
41
|
-
// Extract filename from URL or use a default
|
|
42
|
-
const urlPath = new URL(imageUrl).pathname;
|
|
43
|
-
const fileName = urlPath.split("/").pop() || `upload-${Date.now()}.png`;
|
|
44
|
-
|
|
45
|
-
// Upload to Tlon storage
|
|
46
|
-
const result = await uploadFile({
|
|
47
|
-
blob,
|
|
48
|
-
fileName,
|
|
49
|
-
contentType,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return result.url;
|
|
53
|
-
} finally {
|
|
54
|
-
await release();
|
|
55
|
-
}
|
|
56
|
-
} catch (err) {
|
|
57
|
-
console.warn(`[tlon] Failed to upload image, using original URL: ${String(err)}`);
|
|
58
|
-
return imageUrl;
|
|
59
|
-
}
|
|
60
|
-
}
|
package/test-api.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { tlonPlugin } from "./src/channel.js";
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../tsconfig.package-boundary.base.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"rootDir": "."
|
|
5
|
-
},
|
|
6
|
-
"include": ["./*.ts", "./src/**/*.ts"],
|
|
7
|
-
"exclude": [
|
|
8
|
-
"./**/*.test.ts",
|
|
9
|
-
"./dist/**",
|
|
10
|
-
"./node_modules/**",
|
|
11
|
-
"./src/test-support/**",
|
|
12
|
-
"./src/**/*test-helpers.ts",
|
|
13
|
-
"./src/**/*test-harness.ts",
|
|
14
|
-
"./src/**/*test-support.ts"
|
|
15
|
-
]
|
|
16
|
-
}
|