@ixo/oracle-runtime-workers 0.30.0 → 0.31.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/package.json +7 -5
- package/src/attachments/channel-links.ts +135 -0
- package/src/attachments/channel.ts +203 -0
- package/src/attachments/download.ts +23 -6
- package/src/attachments/index.ts +23 -0
- package/src/attachments/pipeline.ts +120 -42
- package/src/attachments/retention.ts +11 -3
- package/src/attachments/sanitize.ts +20 -8
- package/src/attachments/types.ts +7 -0
- package/src/attachments/view.ts +24 -7
- package/src/channels/contract.ts +69 -0
- package/src/channels/turns.ts +23 -1
- package/src/core/main-agent-types.ts +2 -0
- package/src/core/runtime-context.ts +3 -0
- package/src/delivery/plan.ts +3 -1
- package/src/do/contracts.ts +3 -0
- package/src/do/turn-attachments.ts +103 -0
- package/src/do/user-oracle-do.ts +142 -42
- package/src/hub-actions/client.ts +87 -7
- package/src/hub-actions/hub-text.ts +82 -0
- package/src/hub-actions/request.ts +7 -4
- package/src/hub-actions/store.ts +5 -1
- package/src/hub-actions/transfer.ts +23 -6
- package/src/index.ts +1 -0
- package/src/plugin-api/index.ts +1 -0
- package/src/plugin-api/types.ts +43 -2
- package/src/plugins/attachments/view-attachment-tool.ts +7 -3
- package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
- package/src/plugins/ixo-chain/blocksync.ts +6 -2
- package/src/plugins/ixo-chain/http.ts +16 -8
- package/src/plugins/ixo-chain/rest.ts +7 -1
- package/src/plugins/yoma/catalog.ts +780 -0
- package/src/plugins/yoma/clef.ts +98 -0
- package/src/plugins/yoma/drafts.ts +339 -0
- package/src/plugins/yoma/engine.ts +926 -0
- package/src/plugins/yoma/index.ts +13 -0
- package/src/plugins/yoma/judge.ts +358 -0
- package/src/plugins/yoma/permission.ts +62 -0
- package/src/plugins/yoma/reads.ts +195 -0
- package/src/plugins/yoma/submission.ts +534 -0
- package/src/plugins/yoma/text.ts +16 -0
- package/src/plugins/yoma/work.ts +190 -0
- package/src/plugins/yoma/yoma.plugin.ts +2162 -0
- package/src/sqlite/serialization.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"./gateway": "./src/gateway-worker.ts",
|
|
22
22
|
"./hub-actions/client": "./src/hub-actions/client.ts",
|
|
23
23
|
"./hub-actions/request": "./src/hub-actions/request.ts",
|
|
24
|
-
"./prompt": "./src/core/prompt-composer.ts"
|
|
24
|
+
"./prompt": "./src/core/prompt-composer.ts",
|
|
25
|
+
"./plugins/yoma": "./src/plugins/yoma/index.ts"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
27
28
|
"src",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"!src/plugins/pod-creator/test-fixtures.ts"
|
|
34
35
|
],
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@ixo/action-protocol": "0.
|
|
37
|
+
"@ixo/action-protocol": "0.7.0",
|
|
37
38
|
"@blocknote/server-util": "0.29.1",
|
|
38
39
|
"@composio/core": "0.10.0",
|
|
39
40
|
"@composio/langchain": "0.9.2",
|
|
@@ -65,9 +66,10 @@
|
|
|
65
66
|
"@ixo/impactxclient-sdk": "^3.0.0",
|
|
66
67
|
"protobufjs": "^7.6.5",
|
|
67
68
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
68
|
-
"
|
|
69
|
+
"survey-core": "2.3.11",
|
|
69
70
|
"@ixo/ixo-transaction": "0.2.0",
|
|
70
|
-
"@ixo/ucan": "2.3.0"
|
|
71
|
+
"@ixo/ucan": "2.3.0",
|
|
72
|
+
"@ixo/common": "1.7.1"
|
|
71
73
|
},
|
|
72
74
|
"devDependencies": {
|
|
73
75
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links behind a session's channel files (`channel.ts`), kept in the
|
|
3
|
+
* user's own SQLite file in their own table, apart from the messages: the
|
|
4
|
+
* messages, the model and the transcript carry only the `channel:`
|
|
5
|
+
* reference, and `view_attachment` resolves it here once the turn that
|
|
6
|
+
* brought the file has ended. A row lives until its link expires (the
|
|
7
|
+
* gateway signs links for 30 days; `exp` in the link) and goes with its
|
|
8
|
+
* session (`forgetSession`). Failures are logged and treated as a miss: a
|
|
9
|
+
* missing link makes the file unavailable, never the turn.
|
|
10
|
+
*/
|
|
11
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
12
|
+
import type { Logger } from '../plugin-api/types';
|
|
13
|
+
import type { ChannelMediaLink } from './channel';
|
|
14
|
+
|
|
15
|
+
/** The gateway's link lifetime, for a link that carries no readable `exp`. */
|
|
16
|
+
export const CHANNEL_LINK_DEFAULT_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
17
|
+
|
|
18
|
+
/** When `url` stops working: its `exp` (unix seconds), else 30 days from `now`. */
|
|
19
|
+
export function channelLinkExpiry(url: string, now: number): number {
|
|
20
|
+
let exp: string | null = null;
|
|
21
|
+
try {
|
|
22
|
+
exp = new URL(url).searchParams.get('exp');
|
|
23
|
+
} catch {
|
|
24
|
+
exp = null;
|
|
25
|
+
}
|
|
26
|
+
return exp && /^\d{1,12}$/.test(exp)
|
|
27
|
+
? Number(exp) * 1000
|
|
28
|
+
: now + CHANNEL_LINK_DEFAULT_TTL_MS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ChannelAttachmentLinkStore {
|
|
32
|
+
private setupPromise: Promise<void> | undefined;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly db: Pick<DoSqliteDatabase, 'run' | 'get'>,
|
|
36
|
+
private readonly log: Pick<Logger, 'warn'>,
|
|
37
|
+
private readonly now: () => number = Date.now,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
private setup(): Promise<void> {
|
|
41
|
+
this.setupPromise ??= this.createTables().catch((error: unknown) => {
|
|
42
|
+
this.setupPromise = undefined;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
return this.setupPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async createTables(): Promise<void> {
|
|
49
|
+
await this.db.run(
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS channel_attachment_links (
|
|
51
|
+
session_id TEXT NOT NULL,
|
|
52
|
+
ref TEXT NOT NULL,
|
|
53
|
+
binding_id TEXT NOT NULL,
|
|
54
|
+
url TEXT NOT NULL,
|
|
55
|
+
sha256 TEXT NOT NULL,
|
|
56
|
+
expires_at INTEGER NOT NULL,
|
|
57
|
+
UNIQUE (session_id, ref)
|
|
58
|
+
)`,
|
|
59
|
+
);
|
|
60
|
+
await this.db.run(
|
|
61
|
+
`CREATE INDEX IF NOT EXISTS idx_channel_attachment_links_expiry
|
|
62
|
+
ON channel_attachment_links(expires_at)`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Keep the links of a turn's files for its session (a resend refreshes them). */
|
|
67
|
+
async put(
|
|
68
|
+
sessionId: string,
|
|
69
|
+
links: ReadonlyArray<ChannelMediaLink & { ref: string }>,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (links.length === 0) return;
|
|
72
|
+
try {
|
|
73
|
+
await this.setup();
|
|
74
|
+
const now = this.now();
|
|
75
|
+
for (const link of links)
|
|
76
|
+
await this.db.run(
|
|
77
|
+
`INSERT OR REPLACE INTO channel_attachment_links
|
|
78
|
+
(session_id, ref, binding_id, url, sha256, expires_at)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
80
|
+
[
|
|
81
|
+
sessionId,
|
|
82
|
+
link.ref,
|
|
83
|
+
link.bindingId,
|
|
84
|
+
link.url,
|
|
85
|
+
link.sha256,
|
|
86
|
+
channelLinkExpiry(link.url, now),
|
|
87
|
+
],
|
|
88
|
+
);
|
|
89
|
+
await this.db.run(
|
|
90
|
+
'DELETE FROM channel_attachment_links WHERE expires_at <= ?',
|
|
91
|
+
[now],
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.log.warn(
|
|
95
|
+
`[attachments] could not keep the channel file links of ${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The unexpired link behind `ref` in `sessionId`, if one is kept. */
|
|
101
|
+
async get(
|
|
102
|
+
sessionId: string,
|
|
103
|
+
ref: string,
|
|
104
|
+
): Promise<ChannelMediaLink | undefined> {
|
|
105
|
+
try {
|
|
106
|
+
await this.setup();
|
|
107
|
+
const row = await this.db.get<{
|
|
108
|
+
url: string;
|
|
109
|
+
sha256: string;
|
|
110
|
+
binding_id: string;
|
|
111
|
+
}>(
|
|
112
|
+
`SELECT url, sha256, binding_id FROM channel_attachment_links
|
|
113
|
+
WHERE session_id = ? AND ref = ? AND expires_at > ?`,
|
|
114
|
+
[sessionId, ref, this.now()],
|
|
115
|
+
);
|
|
116
|
+
return row
|
|
117
|
+
? { url: row.url, sha256: row.sha256, bindingId: row.binding_id }
|
|
118
|
+
: undefined;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.log.warn(
|
|
121
|
+
`[attachments] channel file link read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
122
|
+
);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Drop every link of a deleted session. */
|
|
128
|
+
async forgetSession(sessionId: string): Promise<void> {
|
|
129
|
+
await this.setup();
|
|
130
|
+
await this.db.run(
|
|
131
|
+
'DELETE FROM channel_attachment_links WHERE session_id = ?',
|
|
132
|
+
[sessionId],
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the person sent on an IXO Channels turn. The channel gateway keeps
|
|
3
|
+
* each file and serves it from a signed link of its own,
|
|
4
|
+
* `https://<gateway host>/media/<binding id>/<object id>?exp=…&kid=…&sig=…`,
|
|
5
|
+
* valid for 30 days. That link is a bearer capability: it never enters
|
|
6
|
+
* model context, hidden notes, stored message metadata or traces. The model,
|
|
7
|
+
* the transcript and `view_attachment` name a channel file by an opaque
|
|
8
|
+
* reference, `channel:<the first 16 hex digits of its sha256>`, and the
|
|
9
|
+
* owner object resolves it to the link server-side (`ChannelMediaSource`):
|
|
10
|
+
* from the turn's own list, else from the links it keeps for the session
|
|
11
|
+
* (`ChannelAttachmentLinkStore`).
|
|
12
|
+
*
|
|
13
|
+
* A link is fetched only when it is the gateway's own (its origin is the
|
|
14
|
+
* host of a `did:web` in `CHANNEL_SERVICE_DID` and its path lies under
|
|
15
|
+
* `/media/<the binding the file came on>/`), without following redirects,
|
|
16
|
+
* and the bytes are kept only when their SHA-256 is the one the gateway
|
|
17
|
+
* sent. The file always goes to a model as these bytes, never by URL.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
AttachmentRejectedError,
|
|
21
|
+
AttachmentTooLargeError,
|
|
22
|
+
downloadFromUrl,
|
|
23
|
+
} from './download';
|
|
24
|
+
|
|
25
|
+
export const CHANNEL_REF_PREFIX = 'channel:';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The gateway's per-file bound for a video or a document
|
|
29
|
+
* (`maxLargeMediaBytes`, 50 MiB); photos are at most 16 MB there. A file
|
|
30
|
+
* above `MAX_FILE_SIZE` is archived to the sandbox but never handed to a
|
|
31
|
+
* model (see the pipeline).
|
|
32
|
+
*/
|
|
33
|
+
export const CHANNEL_MAX_FILE_BYTES = 52_428_800;
|
|
34
|
+
|
|
35
|
+
/** The opaque reference of a channel file: `channel:` + 16 hex digits of its sha256. */
|
|
36
|
+
export function channelAttachmentRef(sha256: string): string {
|
|
37
|
+
return `${CHANNEL_REF_PREFIX}${sha256.slice(0, 16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isChannelAttachmentRef(ref: string): boolean {
|
|
41
|
+
return /^channel:[0-9a-f]{16}$/.test(ref);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the owner object knows of a channel file: never shown to a model. */
|
|
45
|
+
export interface ChannelMediaLink {
|
|
46
|
+
url: string;
|
|
47
|
+
/** Lowercase hex SHA-256 of the bytes the gateway serves. */
|
|
48
|
+
sha256: string;
|
|
49
|
+
/** The binding the file arrived on: its link lies under `/media/<bindingId>/`. */
|
|
50
|
+
bindingId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A channel file the gateway could not serve as sent (the link expired,
|
|
55
|
+
* was refused, or is not the gateway's own): noted, never retried, and
|
|
56
|
+
* described without its link.
|
|
57
|
+
*/
|
|
58
|
+
export class ChannelFileUnavailableError extends AttachmentRejectedError {
|
|
59
|
+
override readonly name: string = 'ChannelFileUnavailableError';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The https origins of the gateways allowed to call this deployment. */
|
|
63
|
+
export function channelMediaOrigins(
|
|
64
|
+
channelServiceDids: readonly string[],
|
|
65
|
+
): string[] {
|
|
66
|
+
const origins = new Set<string>();
|
|
67
|
+
for (const did of channelServiceDids) {
|
|
68
|
+
const host = /^did:web:([a-z0-9.-]+)$/.exec(did)?.[1];
|
|
69
|
+
if (host) origins.add(`https://${host}`);
|
|
70
|
+
}
|
|
71
|
+
return [...origins];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether `link.url` is the gateway's own link for a file of `link.bindingId`. */
|
|
75
|
+
export function isGatewayMediaUrl(
|
|
76
|
+
link: Pick<ChannelMediaLink, 'url' | 'bindingId'>,
|
|
77
|
+
origins: readonly string[],
|
|
78
|
+
): boolean {
|
|
79
|
+
let parsed: URL;
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(link.url);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const prefix = `/media/${link.bindingId}/`;
|
|
86
|
+
return (
|
|
87
|
+
parsed.protocol === 'https:' &&
|
|
88
|
+
!parsed.username &&
|
|
89
|
+
!parsed.password &&
|
|
90
|
+
origins.includes(parsed.origin) &&
|
|
91
|
+
parsed.pathname.startsWith(prefix) &&
|
|
92
|
+
parsed.pathname.length > prefix.length &&
|
|
93
|
+
!parsed.pathname.slice(prefix.length).includes('/')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hex(bytes: ArrayBuffer): string {
|
|
98
|
+
return Array.from(new Uint8Array(bytes), (byte) =>
|
|
99
|
+
byte.toString(16).padStart(2, '0'),
|
|
100
|
+
).join('');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The bytes of a channel file. Every failure is described without the
|
|
105
|
+
* link: a refused or expired link, a redirect or a transport error is
|
|
106
|
+
* `ChannelFileUnavailableError`, and so are bytes whose SHA-256 is not the
|
|
107
|
+
* one the gateway sent; a file over `maxBytes` stays an
|
|
108
|
+
* `AttachmentTooLargeError`, and an abort keeps its reason.
|
|
109
|
+
*/
|
|
110
|
+
export async function downloadChannelFile(
|
|
111
|
+
link: ChannelMediaLink,
|
|
112
|
+
opts: {
|
|
113
|
+
origins: readonly string[];
|
|
114
|
+
maxBytes: number;
|
|
115
|
+
fetchImpl?: typeof fetch;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
},
|
|
119
|
+
): Promise<{ bytes: Uint8Array; contentType?: string }> {
|
|
120
|
+
if (!isGatewayMediaUrl(link, opts.origins))
|
|
121
|
+
throw new ChannelFileUnavailableError(
|
|
122
|
+
'its link is not one this channel gateway serves',
|
|
123
|
+
);
|
|
124
|
+
let result: Awaited<ReturnType<typeof downloadFromUrl>>;
|
|
125
|
+
try {
|
|
126
|
+
result = await downloadFromUrl(link.url, {
|
|
127
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
128
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
129
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
130
|
+
maxBytes: opts.maxBytes,
|
|
131
|
+
maxRedirects: 0,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (opts.signal?.aborted) throw error;
|
|
135
|
+
if (error instanceof AttachmentTooLargeError) throw error;
|
|
136
|
+
const status =
|
|
137
|
+
error instanceof Error ? /^HTTP (\d{3}) /.exec(error.message)?.[1] : '';
|
|
138
|
+
throw new ChannelFileUnavailableError(
|
|
139
|
+
status
|
|
140
|
+
? `the channel gateway answered ${status}`
|
|
141
|
+
: 'the channel gateway could not be reached',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const digest = hex(await crypto.subtle.digest('SHA-256', result.data));
|
|
145
|
+
if (digest !== link.sha256)
|
|
146
|
+
throw new ChannelFileUnavailableError(
|
|
147
|
+
'its bytes do not match the digest the channel sent',
|
|
148
|
+
);
|
|
149
|
+
return {
|
|
150
|
+
bytes: result.data,
|
|
151
|
+
...(result.contentType ? { contentType: result.contentType } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** How the attachment layer reads a `channel:` reference (built by the owner object). */
|
|
156
|
+
export interface ChannelMediaSource {
|
|
157
|
+
load(
|
|
158
|
+
ref: string,
|
|
159
|
+
opts: {
|
|
160
|
+
maxBytes: number;
|
|
161
|
+
fetchImpl?: typeof fetch;
|
|
162
|
+
signal?: AbortSignal;
|
|
163
|
+
timeoutMs?: number;
|
|
164
|
+
},
|
|
165
|
+
): Promise<{ bytes: Uint8Array; contentType?: string }>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A `ChannelMediaSource` over `resolve` (the turn's files, then the
|
|
170
|
+
* session's stored links) for the gateways in `origins`.
|
|
171
|
+
*/
|
|
172
|
+
export function createChannelMediaSource(input: {
|
|
173
|
+
resolve: (ref: string) => Promise<ChannelMediaLink | undefined>;
|
|
174
|
+
origins: readonly string[];
|
|
175
|
+
}): ChannelMediaSource {
|
|
176
|
+
return {
|
|
177
|
+
load: async (ref, opts) => {
|
|
178
|
+
const link = await input.resolve(ref);
|
|
179
|
+
if (!link)
|
|
180
|
+
throw new ChannelFileUnavailableError(
|
|
181
|
+
'it is no longer available here; ask the person to send it again',
|
|
182
|
+
);
|
|
183
|
+
return downloadChannelFile(link, { ...opts, origins: input.origins });
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The sandbox archive name of a channel file: the cleaned name with the
|
|
190
|
+
* first 12 hex digits of its digest before the extension
|
|
191
|
+
* (`photo-0123456789ab.jpg`), so two photos the gateway names alike never
|
|
192
|
+
* overwrite each other.
|
|
193
|
+
*/
|
|
194
|
+
export function channelArchiveName(safeName: string, ref: string): string {
|
|
195
|
+
const tag = ref.slice(
|
|
196
|
+
CHANNEL_REF_PREFIX.length,
|
|
197
|
+
CHANNEL_REF_PREFIX.length + 12,
|
|
198
|
+
);
|
|
199
|
+
const dot = safeName.lastIndexOf('.');
|
|
200
|
+
return dot > 0
|
|
201
|
+
? `${safeName.slice(0, dot)}-${tag}${safeName.slice(dot)}`
|
|
202
|
+
: `${safeName}-${tag}`;
|
|
203
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fetching attachment bytes — Matrix media (through the gateway, which holds
|
|
3
|
-
* the authenticated client and the room keys)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* the authenticated client and the room keys), channel files (through the
|
|
4
|
+
* owner object's `ChannelMediaSource`, see `channel.ts`) and plain `http(s)`
|
|
5
|
+
* URLs with the Node runtime's SSRF guard, manual redirect validation and
|
|
6
|
+
* streaming size cap.
|
|
6
7
|
*/
|
|
8
|
+
import type { ChannelMediaSource } from './channel';
|
|
7
9
|
import { containerMatchesClaim, detectMimeFromMagicBytes } from './magic';
|
|
8
10
|
import type { AttachmentInput } from './types';
|
|
9
11
|
|
|
@@ -142,6 +144,10 @@ export interface DownloadOptions {
|
|
|
142
144
|
signal?: AbortSignal;
|
|
143
145
|
timeoutMs?: number;
|
|
144
146
|
maxBytes?: number;
|
|
147
|
+
/** Redirects followed (each hop SSRF-checked); default `MAX_REDIRECT_COUNT`. */
|
|
148
|
+
maxRedirects?: number;
|
|
149
|
+
/** Where a `channelRef` attachment's bytes come from; absent = none can be read. */
|
|
150
|
+
channel?: ChannelMediaSource;
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/**
|
|
@@ -248,7 +254,7 @@ export async function downloadFromUrl(
|
|
|
248
254
|
try {
|
|
249
255
|
let currentUrl = url;
|
|
250
256
|
let response: Response | undefined;
|
|
251
|
-
for (let i = 0; i <= MAX_REDIRECT_COUNT; i += 1) {
|
|
257
|
+
for (let i = 0; i <= (opts.maxRedirects ?? MAX_REDIRECT_COUNT); i += 1) {
|
|
252
258
|
response = await fetchImpl(currentUrl, {
|
|
253
259
|
signal: bound.signal,
|
|
254
260
|
redirect: 'manual',
|
|
@@ -307,7 +313,7 @@ export async function loadAttachmentBytes(
|
|
|
307
313
|
source: MatrixMediaSource,
|
|
308
314
|
opts: DownloadOptions = {},
|
|
309
315
|
): Promise<{ bytes: Uint8Array; mimetype: string; sniffed: boolean }> {
|
|
310
|
-
if (!attachment.eventId && !attachment.mxcUri) {
|
|
316
|
+
if (!attachment.eventId && !attachment.mxcUri && !attachment.channelRef) {
|
|
311
317
|
throw new Error('Either mxcUri or eventId must be provided');
|
|
312
318
|
}
|
|
313
319
|
if (attachment.mxcUri && !ALLOWED_URI_SCHEMES.test(attachment.mxcUri)) {
|
|
@@ -320,7 +326,18 @@ export async function loadAttachmentBytes(
|
|
|
320
326
|
|
|
321
327
|
let bytes: Uint8Array;
|
|
322
328
|
let httpType: string | undefined;
|
|
323
|
-
if (attachment.
|
|
329
|
+
if (attachment.channelRef) {
|
|
330
|
+
if (!opts.channel)
|
|
331
|
+
throw new Error('Channel files cannot be read on this host');
|
|
332
|
+
const loaded = await opts.channel.load(attachment.channelRef, {
|
|
333
|
+
maxBytes,
|
|
334
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
335
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
336
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
337
|
+
});
|
|
338
|
+
bytes = loaded.bytes;
|
|
339
|
+
httpType = loaded.contentType;
|
|
340
|
+
} else if (attachment.eventId || attachment.mxcUri!.startsWith('mxc://')) {
|
|
324
341
|
const bound = boundedSignal(
|
|
325
342
|
opts.signal,
|
|
326
343
|
opts.timeoutMs ?? DOWNLOAD_TIMEOUT_MS,
|
package/src/attachments/index.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CHANNEL_MAX_FILE_BYTES,
|
|
3
|
+
CHANNEL_REF_PREFIX,
|
|
4
|
+
channelArchiveName,
|
|
5
|
+
channelAttachmentRef,
|
|
6
|
+
ChannelFileUnavailableError,
|
|
7
|
+
channelMediaOrigins,
|
|
8
|
+
createChannelMediaSource,
|
|
9
|
+
downloadChannelFile,
|
|
10
|
+
isChannelAttachmentRef,
|
|
11
|
+
isGatewayMediaUrl,
|
|
12
|
+
type ChannelMediaLink,
|
|
13
|
+
type ChannelMediaSource,
|
|
14
|
+
} from './channel';
|
|
15
|
+
export {
|
|
16
|
+
CHANNEL_LINK_DEFAULT_TTL_MS,
|
|
17
|
+
ChannelAttachmentLinkStore,
|
|
18
|
+
channelLinkExpiry,
|
|
19
|
+
} from './channel-links';
|
|
1
20
|
export { classifyAttachment, type AttachmentKind } from './classify';
|
|
2
21
|
export {
|
|
3
22
|
buildUserMessageContent,
|
|
@@ -40,6 +59,7 @@ export {
|
|
|
40
59
|
type FileCategory,
|
|
41
60
|
} from './magic';
|
|
42
61
|
export {
|
|
62
|
+
attachmentMetaFor,
|
|
43
63
|
nativeAttachmentNote,
|
|
44
64
|
prepareAttachments,
|
|
45
65
|
SANDBOX_TRUNCATE_LIMIT,
|
|
@@ -61,8 +81,11 @@ export {
|
|
|
61
81
|
type SandboxUploadResult,
|
|
62
82
|
} from './sandbox-archive';
|
|
63
83
|
export {
|
|
84
|
+
NOTE_FILENAME_MAX,
|
|
85
|
+
NOTE_MIMETYPE_MAX,
|
|
64
86
|
SANDBOX_OUTPUT_PREFIX,
|
|
65
87
|
sanitizeAttachmentFilename,
|
|
88
|
+
sanitizeNoteField,
|
|
66
89
|
sanitizeSandboxPath,
|
|
67
90
|
} from './sanitize';
|
|
68
91
|
export {
|