openworkgraph 0.2.12
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/README.md +64 -0
- package/dist/api-image-agent.d.ts +32 -0
- package/dist/api-image-agent.js +132 -0
- package/dist/api-image-output.d.ts +8 -0
- package/dist/api-image-output.js +41 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.js +153 -0
- package/dist/auth.d.ts +58 -0
- package/dist/auth.js +273 -0
- package/dist/backend/adapter.d.ts +35 -0
- package/dist/backend/adapter.js +439 -0
- package/dist/backend/capabilities.d.ts +23 -0
- package/dist/backend/capabilities.js +75 -0
- package/dist/backend/diagnostics.d.ts +9 -0
- package/dist/backend/diagnostics.js +80 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +7 -0
- package/dist/backend/models.d.ts +15 -0
- package/dist/backend/models.js +43 -0
- package/dist/backend/paths.d.ts +6 -0
- package/dist/backend/paths.js +69 -0
- package/dist/backend/stdio.d.ts +66 -0
- package/dist/backend/stdio.js +172 -0
- package/dist/backend/types.d.ts +90 -0
- package/dist/backend/types.js +11 -0
- package/dist/backup-api.d.ts +10 -0
- package/dist/backup-api.js +40 -0
- package/dist/blob-store.d.ts +40 -0
- package/dist/blob-store.js +218 -0
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.js +63 -0
- package/dist/cli-output.d.ts +4 -0
- package/dist/cli-output.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/client-pairing.d.ts +8 -0
- package/dist/client-pairing.js +30 -0
- package/dist/directories.d.ts +14 -0
- package/dist/directories.js +34 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +7 -0
- package/dist/events.d.ts +22 -0
- package/dist/events.js +155 -0
- package/dist/execution-chains.d.ts +24 -0
- package/dist/execution-chains.js +231 -0
- package/dist/execution-settings.d.ts +10 -0
- package/dist/execution-settings.js +38 -0
- package/dist/graph-document-history.d.ts +43 -0
- package/dist/graph-document-history.js +275 -0
- package/dist/graph-files.d.ts +14 -0
- package/dist/graph-files.js +59 -0
- package/dist/graph-transfer.d.ts +53 -0
- package/dist/graph-transfer.js +501 -0
- package/dist/graphs.d.ts +68 -0
- package/dist/graphs.js +771 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +61 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +42 -0
- package/dist/image-credentials.d.ts +11 -0
- package/dist/image-credentials.js +130 -0
- package/dist/image-providers.d.ts +51 -0
- package/dist/image-providers.js +271 -0
- package/dist/input-materialization.d.ts +17 -0
- package/dist/input-materialization.js +95 -0
- package/dist/inputs.d.ts +74 -0
- package/dist/inputs.js +301 -0
- package/dist/local-control.d.ts +13 -0
- package/dist/local-control.js +37 -0
- package/dist/network.d.ts +11 -0
- package/dist/network.js +48 -0
- package/dist/openai-image-driver.d.ts +42 -0
- package/dist/openai-image-driver.js +368 -0
- package/dist/operations/backups.d.ts +40 -0
- package/dist/operations/backups.js +391 -0
- package/dist/operations/capacity.d.ts +7 -0
- package/dist/operations/capacity.js +35 -0
- package/dist/operations/cli-context.d.ts +7 -0
- package/dist/operations/cli-context.js +58 -0
- package/dist/operations/daemon.d.ts +36 -0
- package/dist/operations/daemon.js +159 -0
- package/dist/operations/files.d.ts +12 -0
- package/dist/operations/files.js +84 -0
- package/dist/operations/lifecycle.d.ts +28 -0
- package/dist/operations/lifecycle.js +155 -0
- package/dist/operations/local-admin.d.ts +6 -0
- package/dist/operations/local-admin.js +38 -0
- package/dist/operations/logs.d.ts +16 -0
- package/dist/operations/logs.js +37 -0
- package/dist/operations/plugins.d.ts +10 -0
- package/dist/operations/plugins.js +11 -0
- package/dist/operations/snapshot-worker.d.ts +1 -0
- package/dist/operations/snapshot-worker.js +18 -0
- package/dist/operations/snapshot.d.ts +1 -0
- package/dist/operations/snapshot.js +14 -0
- package/dist/operations/update.d.ts +26 -0
- package/dist/operations/update.js +216 -0
- package/dist/persistence/asset-business-schema.d.ts +2 -0
- package/dist/persistence/asset-business-schema.js +36 -0
- package/dist/persistence/database.d.ts +7 -0
- package/dist/persistence/database.js +91 -0
- package/dist/persistence/plugin-schema.d.ts +9 -0
- package/dist/persistence/plugin-schema.js +38 -0
- package/dist/persistence/repositories.d.ts +20 -0
- package/dist/persistence/repositories.js +106 -0
- package/dist/persistence/resource-schema.d.ts +2 -0
- package/dist/persistence/resource-schema.js +44 -0
- package/dist/persistence/schema.d.ts +6 -0
- package/dist/persistence/schema.js +236 -0
- package/dist/persistence/workflow-schema.d.ts +2 -0
- package/dist/persistence/workflow-schema.js +19 -0
- package/dist/plugin-api.d.ts +10 -0
- package/dist/plugin-api.js +40 -0
- package/dist/plugin-management.d.ts +36 -0
- package/dist/plugin-management.js +112 -0
- package/dist/plugins.d.ts +55 -0
- package/dist/plugins.js +239 -0
- package/dist/project-discovery.d.ts +3 -0
- package/dist/project-discovery.js +57 -0
- package/dist/project-files-api.d.ts +16 -0
- package/dist/project-files-api.js +243 -0
- package/dist/project-files.d.ts +97 -0
- package/dist/project-files.js +557 -0
- package/dist/project-lock.d.ts +4 -0
- package/dist/project-lock.js +35 -0
- package/dist/project-probe-worker.d.ts +1 -0
- package/dist/project-probe-worker.js +17 -0
- package/dist/project-probe.d.ts +22 -0
- package/dist/project-probe.js +67 -0
- package/dist/projects.d.ts +29 -0
- package/dist/projects.js +219 -0
- package/dist/publication.d.ts +31 -0
- package/dist/publication.js +642 -0
- package/dist/resource-api.d.ts +15 -0
- package/dist/resource-api.js +256 -0
- package/dist/resources-orphans.d.ts +41 -0
- package/dist/resources-orphans.js +235 -0
- package/dist/resources.d.ts +246 -0
- package/dist/resources.js +714 -0
- package/dist/run-api.d.ts +10 -0
- package/dist/run-api.js +308 -0
- package/dist/run-context.d.ts +54 -0
- package/dist/run-context.js +137 -0
- package/dist/runs.d.ts +82 -0
- package/dist/runs.js +506 -0
- package/dist/runtime-backend.d.ts +24 -0
- package/dist/runtime-backend.js +214 -0
- package/dist/runtime.d.ts +97 -0
- package/dist/runtime.js +553 -0
- package/dist/scheduler.d.ts +85 -0
- package/dist/scheduler.js +344 -0
- package/dist/service.d.ts +29 -0
- package/dist/service.js +515 -0
- package/dist/skills-api.d.ts +18 -0
- package/dist/skills-api.js +120 -0
- package/dist/storage-api.d.ts +21 -0
- package/dist/storage-api.js +92 -0
- package/dist/transfer-api.d.ts +10 -0
- package/dist/transfer-api.js +60 -0
- package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
- package/dist/vendor/protocol/contracts.typecheck.js +35 -0
- package/dist/vendor/protocol/execution-chain.d.ts +6 -0
- package/dist/vendor/protocol/execution-chain.js +26 -0
- package/dist/vendor/protocol/graph-binary.d.ts +15 -0
- package/dist/vendor/protocol/graph-binary.js +121 -0
- package/dist/vendor/protocol/graph-title.d.ts +2 -0
- package/dist/vendor/protocol/graph-title.js +23 -0
- package/dist/vendor/protocol/index.d.ts +617 -0
- package/dist/vendor/protocol/index.js +20 -0
- package/dist/vendor/protocol/pairing.d.ts +29 -0
- package/dist/vendor/protocol/pairing.js +71 -0
- package/dist/vendor/protocol/preview.d.ts +2 -0
- package/dist/vendor/protocol/preview.js +9 -0
- package/dist/vendor/protocol/resource-mime.d.ts +2 -0
- package/dist/vendor/protocol/resource-mime.js +35 -0
- package/dist/video-thumbnail.d.ts +2 -0
- package/dist/video-thumbnail.js +34 -0
- package/package.json +9 -0
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { ServiceError } from './errors.js';
|
|
2
|
+
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
|
|
3
|
+
const builtins = new Set(['text', 'image', 'document', 'video', 'file', 'preview', 'execution', 'group']);
|
|
4
|
+
function invalid(message) { throw new ServiceError('INVALID_REQUEST', message); }
|
|
5
|
+
/** Bounded pure JSON only: no getters, functions, cycles or exotic prototypes. */
|
|
6
|
+
export function checkedJson(value, maxBytes = 16_777_216, maxDepth = 32) {
|
|
7
|
+
let bytes = 0;
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
function visit(v, depth) {
|
|
10
|
+
if (depth > maxDepth)
|
|
11
|
+
invalid('JSON depth limit exceeded');
|
|
12
|
+
if (v === null || typeof v === 'boolean')
|
|
13
|
+
bytes += 5;
|
|
14
|
+
else if (typeof v === 'number' && Number.isFinite(v))
|
|
15
|
+
bytes += 24;
|
|
16
|
+
else if (typeof v === 'string')
|
|
17
|
+
bytes += Buffer.byteLength(JSON.stringify(v));
|
|
18
|
+
else if (typeof v === 'object' && v !== null) {
|
|
19
|
+
if (seen.has(v))
|
|
20
|
+
invalid('Cyclic JSON');
|
|
21
|
+
if (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null)
|
|
22
|
+
invalid('Only plain JSON objects supported');
|
|
23
|
+
seen.add(v);
|
|
24
|
+
bytes += 2;
|
|
25
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
26
|
+
if (Array.isArray(v) && key === 'length')
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof key !== 'string' || forbidden.has(key))
|
|
29
|
+
invalid('Unsafe JSON key');
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(v, key);
|
|
31
|
+
if (!('value' in descriptor) || !descriptor.enumerable)
|
|
32
|
+
invalid('JSON accessors are not supported');
|
|
33
|
+
bytes += Buffer.byteLength(key) + 4;
|
|
34
|
+
visit(descriptor.value, depth + 1);
|
|
35
|
+
if (bytes > maxBytes)
|
|
36
|
+
invalid('JSON size limit exceeded');
|
|
37
|
+
}
|
|
38
|
+
seen.delete(v);
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
invalid('Value is not JSON');
|
|
42
|
+
if (bytes > maxBytes)
|
|
43
|
+
invalid('JSON size limit exceeded');
|
|
44
|
+
}
|
|
45
|
+
// Clone the validated tree without constructing a single aggregate string.
|
|
46
|
+
// Strings are immutable and can be shared, including large resource payloads.
|
|
47
|
+
function clone(v) {
|
|
48
|
+
if (v === null || typeof v !== 'object')
|
|
49
|
+
return Object.is(v, -0) ? 0 : v;
|
|
50
|
+
if (Array.isArray(v))
|
|
51
|
+
return Array.from(v, item => item === undefined ? null : clone(item));
|
|
52
|
+
return Object.fromEntries(Object.entries(v).map(([key, item]) => [key, clone(item)]));
|
|
53
|
+
}
|
|
54
|
+
visit(value, 0);
|
|
55
|
+
return clone(value);
|
|
56
|
+
}
|
|
57
|
+
function object(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
|
|
58
|
+
function path(field) {
|
|
59
|
+
if (typeof field !== 'string' || field.length > 256)
|
|
60
|
+
invalid('Invalid resource field path');
|
|
61
|
+
const parts = field.split('.');
|
|
62
|
+
if (parts.length > 8 || parts.some(p => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(p) || forbidden.has(p)))
|
|
63
|
+
invalid('Invalid resource field path');
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
export function readField(content, field) {
|
|
67
|
+
let value = content;
|
|
68
|
+
for (const key of path(field))
|
|
69
|
+
value = object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function validateSchema(schema) {
|
|
73
|
+
if (!object(schema) || !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(schema.type))
|
|
74
|
+
invalid('Unsupported content schema');
|
|
75
|
+
for (const key of Object.keys(schema))
|
|
76
|
+
if (!['type', 'properties', 'required', 'items', 'additionalProperties'].includes(key))
|
|
77
|
+
invalid(`Unsupported schema keyword ${key}`);
|
|
78
|
+
if (schema.properties !== undefined) {
|
|
79
|
+
if (schema.type !== 'object' || !object(schema.properties))
|
|
80
|
+
invalid('Invalid schema properties');
|
|
81
|
+
for (const value of Object.values(schema.properties))
|
|
82
|
+
validateSchema(value);
|
|
83
|
+
}
|
|
84
|
+
if (schema.required !== undefined && (schema.type !== 'object' || !Array.isArray(schema.required) || schema.required.some(k => typeof k !== 'string' || forbidden.has(k))))
|
|
85
|
+
invalid('Invalid required fields');
|
|
86
|
+
if (schema.additionalProperties !== undefined && (schema.type !== 'object' || typeof schema.additionalProperties !== 'boolean'))
|
|
87
|
+
invalid('Invalid additionalProperties');
|
|
88
|
+
if (schema.items !== undefined) {
|
|
89
|
+
if (schema.type !== 'array')
|
|
90
|
+
invalid('Invalid array schema');
|
|
91
|
+
validateSchema(schema.items);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function matches(schema, value) {
|
|
95
|
+
if (schema.type === 'null')
|
|
96
|
+
return value === null;
|
|
97
|
+
if (schema.type === 'array')
|
|
98
|
+
return Array.isArray(value) && (!schema.items || value.every(v => matches(schema.items, v)));
|
|
99
|
+
if (schema.type === 'object')
|
|
100
|
+
return object(value) && (schema.required ?? []).every(k => Object.hasOwn(value, k)) && Object.entries(value).every(([k, v]) => schema.properties?.[k] ? matches(schema.properties[k], v) : schema.additionalProperties !== false);
|
|
101
|
+
if (schema.type === 'integer')
|
|
102
|
+
return typeof value === 'number' && Number.isSafeInteger(value);
|
|
103
|
+
return typeof value === schema.type;
|
|
104
|
+
}
|
|
105
|
+
function validateContract(input) {
|
|
106
|
+
const c = checkedJson(input, 65_536, 16);
|
|
107
|
+
if (!object(c) || !/^[a-z][a-z0-9._-]{0,127}$/.test(c.typeId) || typeof c.typeId !== 'string')
|
|
108
|
+
invalid('Invalid typeId');
|
|
109
|
+
if (Object.keys(c).some(k => !['typeId', 'pluginId', 'version', 'apiVersion', 'schemaVersion', 'contentSchema', 'resources', 'migrations'].includes(k)))
|
|
110
|
+
invalid('Unsupported contract field; executable entries are not resource contracts');
|
|
111
|
+
if (typeof c.pluginId !== 'string' || !/^[a-z][a-z0-9._-]{0,127}$/.test(c.pluginId) || typeof c.version !== 'string' || !/^[0-9]+[.][0-9]+[.][0-9]+$/.test(c.version))
|
|
112
|
+
invalid('Invalid plugin identity/version');
|
|
113
|
+
if (c.apiVersion !== '1.0' || !Number.isSafeInteger(c.schemaVersion) || c.schemaVersion < 1)
|
|
114
|
+
invalid('Unsupported API/schema version');
|
|
115
|
+
validateSchema(c.contentSchema);
|
|
116
|
+
if (!Array.isArray(c.resources) || c.resources.length > 32)
|
|
117
|
+
invalid('Invalid resource mappings');
|
|
118
|
+
for (const m of c.resources) {
|
|
119
|
+
if (!object(m) || !['text', 'image', 'document', 'video', 'file'].includes(m.kind))
|
|
120
|
+
invalid('Invalid resource kind');
|
|
121
|
+
if (Object.keys(m).some(k => !['kind', 'textField', 'resourceIdField', 'resourceVersionField'].includes(k)))
|
|
122
|
+
invalid('Unsupported resource mapping');
|
|
123
|
+
for (const field of [m.textField, m.resourceIdField, m.resourceVersionField])
|
|
124
|
+
if (field !== undefined)
|
|
125
|
+
path(field);
|
|
126
|
+
if (!!m.resourceIdField !== !!m.resourceVersionField || (!m.textField && !m.resourceIdField))
|
|
127
|
+
invalid('Incomplete resource mapping');
|
|
128
|
+
if (['image', 'video', 'file'].includes(m.kind) && !m.resourceIdField)
|
|
129
|
+
invalid('Media requires resource identity');
|
|
130
|
+
}
|
|
131
|
+
if (c.migrations !== undefined) {
|
|
132
|
+
if (!Array.isArray(c.migrations) || c.migrations.length > 16)
|
|
133
|
+
invalid('Invalid migrations');
|
|
134
|
+
const versions = new Set();
|
|
135
|
+
for (const m of c.migrations) {
|
|
136
|
+
if (!object(m) || !Number.isSafeInteger(m.fromSchemaVersion) || m.fromSchemaVersion < 1 || m.fromSchemaVersion >= c.schemaVersion || versions.has(m.fromSchemaVersion))
|
|
137
|
+
invalid('Invalid migration source');
|
|
138
|
+
versions.add(m.fromSchemaVersion);
|
|
139
|
+
if (Object.keys(m).some(k => !['fromSchemaVersion', 'rename', 'defaults'].includes(k)))
|
|
140
|
+
invalid('Unsupported migration operation');
|
|
141
|
+
if (m.rename !== undefined) {
|
|
142
|
+
if (!object(m.rename))
|
|
143
|
+
invalid('Invalid rename');
|
|
144
|
+
const targets = new Set();
|
|
145
|
+
for (const [from, to] of Object.entries(m.rename)) {
|
|
146
|
+
if (path(from).length !== 1 || path(to).length !== 1 || targets.has(to))
|
|
147
|
+
invalid('Migration renames must be unique top-level fields');
|
|
148
|
+
targets.add(to);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (m.defaults !== undefined && !object(m.defaults))
|
|
152
|
+
invalid('Invalid migration defaults');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return c;
|
|
156
|
+
}
|
|
157
|
+
export class PluginRegistry {
|
|
158
|
+
db;
|
|
159
|
+
constructor(db) {
|
|
160
|
+
this.db = db;
|
|
161
|
+
}
|
|
162
|
+
/** Local control only; never call for untrusted graph import or from browser routes. */
|
|
163
|
+
registerTrusted(input) {
|
|
164
|
+
const c = validateContract(input);
|
|
165
|
+
if (builtins.has(c.typeId))
|
|
166
|
+
invalid('Built-in type cannot be replaced');
|
|
167
|
+
this.db.exec('SAVEPOINT plugin_registration');
|
|
168
|
+
try {
|
|
169
|
+
const existing = this.db.prepare('SELECT contract FROM plugin_contracts WHERE type=?').all(c.typeId);
|
|
170
|
+
if (existing.some(row => JSON.parse(String(row['contract'])).pluginId !== c.pluginId))
|
|
171
|
+
throw new ServiceError('CONFLICT', 'Type already owned by another plugin');
|
|
172
|
+
const previous = this.db.prepare('SELECT contract FROM plugin_contracts WHERE type=? AND schema_version=?').get(c.typeId, c.schemaVersion);
|
|
173
|
+
if (previous) {
|
|
174
|
+
if (String(previous['contract']) === JSON.stringify(c)) {
|
|
175
|
+
this.db.exec('RELEASE plugin_registration');
|
|
176
|
+
return c;
|
|
177
|
+
}
|
|
178
|
+
throw new ServiceError('CONFLICT', 'Registered schema versions are immutable; register a new schema version');
|
|
179
|
+
}
|
|
180
|
+
this.db.prepare('INSERT INTO plugin_contracts(type,schema_version,api_version,contract) VALUES (?,?,?,?)').run(c.typeId, c.schemaVersion, c.apiVersion, JSON.stringify(c));
|
|
181
|
+
this.db.exec('RELEASE plugin_registration');
|
|
182
|
+
return c;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.db.exec('ROLLBACK TO plugin_registration; RELEASE plugin_registration');
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
get(type, schemaVersion) {
|
|
190
|
+
if (builtins.has(type)) {
|
|
191
|
+
if (schemaVersion !== 1)
|
|
192
|
+
return null;
|
|
193
|
+
if (type === 'group')
|
|
194
|
+
return { typeId: type, pluginId: 'core', version: '1.0.0', apiVersion: '1.0', schemaVersion: 1, contentSchema: { type: 'object', properties: { title: { type: 'string' } }, additionalProperties: false }, resources: [] };
|
|
195
|
+
const resources = ['execution', 'preview'].includes(type) ? [] : [{ kind: type, ...(type === 'text' ? { textField: 'text' } : type === 'file' ? { resourceIdField: 'resourceId', resourceVersionField: 'resourceVersion' } : { textField: 'text', resourceIdField: 'resourceId', resourceVersionField: 'resourceVersion' }) }];
|
|
196
|
+
return { typeId: type, pluginId: 'core', version: '1.0.0', apiVersion: '1.0', schemaVersion: 1, contentSchema: { type: 'object' }, resources };
|
|
197
|
+
}
|
|
198
|
+
const row = this.db.prepare('SELECT contract FROM plugin_contracts WHERE type=? AND schema_version=?').get(type, schemaVersion);
|
|
199
|
+
return row ? validateContract(JSON.parse(String(row['contract']))) : null;
|
|
200
|
+
}
|
|
201
|
+
inspect(type, schemaVersion, input) {
|
|
202
|
+
const content = checkedJson(input);
|
|
203
|
+
let contract;
|
|
204
|
+
try {
|
|
205
|
+
contract = this.get(type, schemaVersion);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { state: 'incompatible', typeId: type, schemaVersion, content, reason: 'Stored contract is incompatible', contract: null };
|
|
209
|
+
}
|
|
210
|
+
if (!contract)
|
|
211
|
+
return { state: 'missing', typeId: type, schemaVersion, content, reason: 'Resource contract is unavailable', contract: null };
|
|
212
|
+
const valid = matches(contract.contentSchema, content);
|
|
213
|
+
return { state: valid ? 'available' : 'invalid', typeId: type, schemaVersion, content, reason: valid ? null : 'Content does not match schema', contract };
|
|
214
|
+
}
|
|
215
|
+
/** Pure copy-on-success migration. Caller persists new node version atomically; old version is never changed. */
|
|
216
|
+
migrate(type, fromSchema, toSchema, input) {
|
|
217
|
+
const original = checkedJson(input);
|
|
218
|
+
const target = this.get(type, toSchema);
|
|
219
|
+
const migration = target?.migrations?.find(m => m.fromSchemaVersion === fromSchema);
|
|
220
|
+
if (!target || !migration || !object(original))
|
|
221
|
+
throw new ServiceError('MIGRATION_FAILED', 'No explicit declarative migration');
|
|
222
|
+
const result = { ...original };
|
|
223
|
+
for (const [from, to] of Object.entries(migration.rename ?? {})) {
|
|
224
|
+
if (!Object.hasOwn(original, from) || from === to)
|
|
225
|
+
continue;
|
|
226
|
+
if (Object.hasOwn(result, to))
|
|
227
|
+
throw new ServiceError('MIGRATION_FAILED', `Migration would overwrite ${to}; original preserved`);
|
|
228
|
+
result[to] = original[from];
|
|
229
|
+
delete result[from];
|
|
230
|
+
}
|
|
231
|
+
for (const [key, value] of Object.entries(migration.defaults ?? {}))
|
|
232
|
+
if (!Object.hasOwn(result, key))
|
|
233
|
+
result[key] = value;
|
|
234
|
+
if (!matches(target.contentSchema, result))
|
|
235
|
+
throw new ServiceError('MIGRATION_FAILED', 'Migrated content is invalid; original preserved');
|
|
236
|
+
return checkedJson(result);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
/** Best-effort private desktop format; not a public Codex RPC or a trusted registration. */
|
|
6
|
+
export function discoverProjects(codexHome = process.env['CODEX_HOME'] || join(homedir(), '.codex')) {
|
|
7
|
+
const unavailable = (reason) => ({ source: 'codex-desktop-saved-roots', status: 'unavailable', reason, candidates: [] });
|
|
8
|
+
let fd;
|
|
9
|
+
let data;
|
|
10
|
+
try {
|
|
11
|
+
fd = openSync(join(codexHome, '.codex-global-state.json'), constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
12
|
+
const stat = fstatSync(fd);
|
|
13
|
+
if (!stat.isFile() || stat.size > MAX_BYTES)
|
|
14
|
+
return unavailable('Codex 桌面目录来源不是常规文件或超过读取上限;请手动输入路径。');
|
|
15
|
+
const raw = Buffer.alloc(MAX_BYTES + 1);
|
|
16
|
+
let length = 0;
|
|
17
|
+
while (length < raw.length) {
|
|
18
|
+
const bytes = readSync(fd, raw, length, raw.length - length, null);
|
|
19
|
+
if (!bytes)
|
|
20
|
+
break;
|
|
21
|
+
length += bytes;
|
|
22
|
+
}
|
|
23
|
+
if (length > MAX_BYTES)
|
|
24
|
+
return unavailable('Codex 桌面目录来源超过读取上限;请手动输入路径。');
|
|
25
|
+
data = JSON.parse(raw.subarray(0, length).toString('utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return unavailable('Codex 桌面已保存目录来源不存在、不可读或格式无效;请手动输入路径。');
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
if (fd !== undefined)
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
35
|
+
return unavailable('Codex 桌面目录来源格式不受支持;请手动输入路径。');
|
|
36
|
+
const roots = data['electron-saved-workspace-roots'];
|
|
37
|
+
if (!Array.isArray(roots) || roots.length > 1000 || roots.some(value => typeof value !== 'string' || !isAbsolute(value) || value.includes('\0') || value.length > 32768))
|
|
38
|
+
return unavailable('Codex 桌面已保存目录字段缺失或格式不受支持;请手动输入路径。');
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const candidates = [];
|
|
41
|
+
for (const root of roots) {
|
|
42
|
+
let path = root;
|
|
43
|
+
let availability = 'unavailable';
|
|
44
|
+
try {
|
|
45
|
+
path = realpathSync.native(root);
|
|
46
|
+
if (statSync(path).isDirectory())
|
|
47
|
+
availability = 'available';
|
|
48
|
+
}
|
|
49
|
+
catch { /* Keep stale paths visible without inventing availability. */ }
|
|
50
|
+
if (!seen.has(path)) {
|
|
51
|
+
seen.add(path);
|
|
52
|
+
candidates.push({ path, availability });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { source: 'codex-desktop-saved-roots', status: 'available', reason: '已读取本机 Codex 桌面私有格式的已保存目录;候选仅供选择,注册时仍须验证。', candidates };
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=project-discovery.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { Auth } from './auth.js';
|
|
4
|
+
import type { ApiResult } from './api.js';
|
|
5
|
+
import { Graphs } from './graphs.js';
|
|
6
|
+
import type { Resources } from './resources.js';
|
|
7
|
+
export declare class ProjectFilesApi {
|
|
8
|
+
private readonly db;
|
|
9
|
+
private readonly auth;
|
|
10
|
+
private readonly graphs;
|
|
11
|
+
private readonly resources;
|
|
12
|
+
private readonly runsDirectory;
|
|
13
|
+
private readonly files;
|
|
14
|
+
constructor(db: DatabaseSync, auth: Auth, graphs: Graphs, resources: Resources, runsDirectory: string);
|
|
15
|
+
handle(request: IncomingMessage, rawPath: string, token: string, origin: string): Promise<ApiResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Auth } from './auth.js';
|
|
2
|
+
import { readJson } from './http.js';
|
|
3
|
+
import { ServiceError } from './errors.js';
|
|
4
|
+
import { ProjectFiles, classifyProjectFile, normalizeProjectPath, PROJECT_IMAGE_THUMBNAIL_LEVELS } from './project-files.js';
|
|
5
|
+
import { Graphs } from './graphs.js';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { Repositories } from './persistence/repositories.js';
|
|
8
|
+
import { atomic } from './persistence/database.js';
|
|
9
|
+
export class ProjectFilesApi {
|
|
10
|
+
db;
|
|
11
|
+
auth;
|
|
12
|
+
graphs;
|
|
13
|
+
resources;
|
|
14
|
+
runsDirectory;
|
|
15
|
+
files;
|
|
16
|
+
constructor(db, auth, graphs, resources, runsDirectory) {
|
|
17
|
+
this.db = db;
|
|
18
|
+
this.auth = auth;
|
|
19
|
+
this.graphs = graphs;
|
|
20
|
+
this.resources = resources;
|
|
21
|
+
this.runsDirectory = runsDirectory;
|
|
22
|
+
this.files = new ProjectFiles(db);
|
|
23
|
+
}
|
|
24
|
+
async handle(request, rawPath, token, origin) {
|
|
25
|
+
const url = new URL(rawPath, 'http://localhost');
|
|
26
|
+
const detach = /^[/]v1[/]projects[/]([a-zA-Z0-9_-]+)[/]graphs[/]([a-zA-Z0-9_-]+)[/]nodes[/]([a-zA-Z0-9_-]+)[/]detach$/.exec(url.pathname);
|
|
27
|
+
if (detach && request.method === 'POST') {
|
|
28
|
+
const body = await readJson(request, 2 * 1024 * 1024);
|
|
29
|
+
if (Object.keys(body).some(key => !['expectedContentVersion', 'expectedExecutionRevision', 'expectedLayoutRevision', 'expectedObservationToken', 'content', 'idempotencyKey'].includes(key))
|
|
30
|
+
|| !Number.isSafeInteger(body['expectedContentVersion']) || !Number.isSafeInteger(body['expectedExecutionRevision']) || !Number.isSafeInteger(body['expectedLayoutRevision'])
|
|
31
|
+
|| typeof body['expectedObservationToken'] !== 'string' || typeof body['idempotencyKey'] !== 'string' || !body['content'] || typeof body['content'] !== 'object' || Array.isArray(body['content']))
|
|
32
|
+
throw new ServiceError('INVALID_REQUEST', '解除引用需要正文、节点版本、观察标记和幂等键。');
|
|
33
|
+
const content = body['content'];
|
|
34
|
+
if (typeof content.text !== 'string')
|
|
35
|
+
throw new ServiceError('INVALID_REQUEST', '首版仅支持文本引用在编辑时解除引用。');
|
|
36
|
+
const principal = this.auth.withSession(token, origin, current => current.id);
|
|
37
|
+
const scope = { serviceId: this.graphs.serviceId, projectId: detach[1], graphId: detach[2] };
|
|
38
|
+
const replayScope = principal + ':project-file.detach:' + scope.graphId;
|
|
39
|
+
const replay = new Repositories(this.db).replay(replayScope, body['idempotencyKey'], body);
|
|
40
|
+
if (replay !== undefined)
|
|
41
|
+
return { handled: true, body: replay };
|
|
42
|
+
const snapshot = this.graphs.snapshot(scope);
|
|
43
|
+
const node = snapshot.nodes.find(item => item.id === detach[3]);
|
|
44
|
+
if (!node || node.type !== 'text' || node.contentVersion !== body['expectedContentVersion'] || !node.content || typeof node.content !== 'object' || Array.isArray(node.content))
|
|
45
|
+
throw new ServiceError('REVISION_CONFLICT', '引用文本节点已变化。');
|
|
46
|
+
const old = node.content, source = old.source;
|
|
47
|
+
if (!source || typeof source !== 'object' || Array.isArray(source) || source.kind !== 'project-file' || source.projectId !== scope.projectId || typeof source.relativePath !== 'string')
|
|
48
|
+
throw new ServiceError('INVALID_REQUEST', '目标节点不是当前项目的文本引用。');
|
|
49
|
+
const observation = (await this.files.stat(scope.projectId, [source.relativePath]))[0];
|
|
50
|
+
if (observation.state !== 'available')
|
|
51
|
+
throw new ServiceError(observation.state === 'missing' ? 'NOT_FOUND' : 'PROJECT_UNAVAILABLE', observation.state === 'missing' ? '项目文件已不存在,不能基于旧内容解除引用。' : '项目文件当前不可用。');
|
|
52
|
+
if (observation.changeToken !== body['expectedObservationToken'])
|
|
53
|
+
throw new ServiceError('CONFLICT', '项目文件自预览后已变化,请重新打开后再编辑。');
|
|
54
|
+
const mime = typeof old.mime === 'string' ? old.mime : observation.mime ?? 'text/plain';
|
|
55
|
+
const title = typeof content.title === 'string' && content.title.trim() ? content.title : typeof old.title === 'string' ? old.title : observation.name;
|
|
56
|
+
const prepared = await this.resources.prepareBytes(Buffer.from(content.text, 'utf8'), mime);
|
|
57
|
+
let consumed = false;
|
|
58
|
+
try {
|
|
59
|
+
const result = new Repositories(this.db).idempotent(replayScope, body['idempotencyKey'], body, () => atomic(this.db, () => {
|
|
60
|
+
const latest = this.graphs.snapshot(scope).nodes.find(item => item.id === detach[3]);
|
|
61
|
+
if (!latest || latest.contentVersion !== body['expectedContentVersion'] || !latest.content || typeof latest.content !== 'object' || Array.isArray(latest.content))
|
|
62
|
+
throw new ServiceError('REVISION_CONFLICT', '引用文本节点已变化。');
|
|
63
|
+
const latestSource = latest.content.source;
|
|
64
|
+
const latestObservation = latest.content.observation;
|
|
65
|
+
if (!latestSource || typeof latestSource !== 'object' || Array.isArray(latestSource) || latestSource.kind !== 'project-file' || latestSource.relativePath !== source.relativePath
|
|
66
|
+
|| !latestObservation || typeof latestObservation !== 'object' || Array.isArray(latestObservation) || latestObservation.changeToken !== body['expectedObservationToken'])
|
|
67
|
+
throw new ServiceError('CONFLICT', '引用来源或观察标记已变化。');
|
|
68
|
+
const created = this.resources.createCanvasFromPrepared(scope, prepared, title);
|
|
69
|
+
consumed = true;
|
|
70
|
+
const independent = { ...old, ...content, title, resourceId: created.resource.id, resourceVersion: created.resource.current.version, mime: created.resource.current.mime, bytes: created.resource.current.bytes };
|
|
71
|
+
delete independent.source;
|
|
72
|
+
delete independent.observation;
|
|
73
|
+
return this.graphs.command({ ...scope, idempotencyKey: 'detach-' + body['idempotencyKey'], expectedExecutionRevision: Number(body['expectedExecutionRevision']), expectedLayoutRevision: Number(body['expectedLayoutRevision']), operations: [{ type: 'node.content', nodeId: detach[3], expectedContentVersion: Number(body['expectedContentVersion']), content: independent }] }, principal);
|
|
74
|
+
}));
|
|
75
|
+
if (!consumed)
|
|
76
|
+
atomic(this.db, () => { this.resources.discardPrepared(prepared); });
|
|
77
|
+
return { handled: true, body: result };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!consumed)
|
|
81
|
+
atomic(this.db, () => { this.resources.discardPrepared(prepared); });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const associate = /^[/]v1[/]projects[/]([a-zA-Z0-9_-]+)[/]graphs[/]([a-zA-Z0-9_-]+)[/]project-files[/]associate$/.exec(url.pathname);
|
|
86
|
+
if (associate && request.method === 'POST') {
|
|
87
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
88
|
+
const body = await readJson(request, 16 * 1024);
|
|
89
|
+
if (Object.keys(body).some(key => !['path', 'nodeId', 'expectedContentVersion', 'expectedExecutionRevision', 'expectedLayoutRevision', 'idempotencyKey'].includes(key)) || typeof body['path'] !== 'string' || typeof body['nodeId'] !== 'string' || typeof body['idempotencyKey'] !== 'string' || !Number.isSafeInteger(body['expectedContentVersion']) || !Number.isSafeInteger(body['expectedExecutionRevision']) || !Number.isSafeInteger(body['expectedLayoutRevision']))
|
|
90
|
+
throw new ServiceError('INVALID_REQUEST', '关联空引用需要路径、节点和版本。');
|
|
91
|
+
const scope = { serviceId: this.graphs.serviceId, projectId: associate[1], graphId: associate[2] };
|
|
92
|
+
const snapshot = this.graphs.snapshot(scope);
|
|
93
|
+
const node = snapshot.nodes.find(item => item.id === body['nodeId']);
|
|
94
|
+
if (!node || node.contentVersion !== body['expectedContentVersion'] || !node.content || typeof node.content !== 'object' || Array.isArray(node.content))
|
|
95
|
+
throw new ServiceError('REVISION_CONFLICT', '空引用节点已变化。');
|
|
96
|
+
const source = node.content.source;
|
|
97
|
+
if (!source || typeof source !== 'object' || Array.isArray(source) || source.kind !== 'project-file-empty')
|
|
98
|
+
throw new ServiceError('INVALID_REQUEST', '目标节点不是空引用节点。');
|
|
99
|
+
const file = await this.files.resolve(associate[1], body['path'], 'file');
|
|
100
|
+
const observation = (await this.files.stat(associate[1], [file.relativePath]))[0];
|
|
101
|
+
if (observation.state !== 'available')
|
|
102
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目文件当前不可读。');
|
|
103
|
+
const name = file.relativePath.split('/').at(-1);
|
|
104
|
+
const classified = classifyProjectFile(name);
|
|
105
|
+
const content = { title: name, text: '', prompt: '', mime: classified.mime, bytes: observation.bytes, source: { kind: 'project-file', serviceId: this.graphs.serviceId, projectId: associate[1], relativePath: file.relativePath }, observation: { state: 'available', name, mime: classified.mime, bytes: observation.bytes, changeToken: observation.changeToken } };
|
|
106
|
+
const result = this.auth.withSession(token, origin, current => this.graphs.command({ ...scope, idempotencyKey: body['idempotencyKey'], expectedExecutionRevision: Number(body['expectedExecutionRevision']), expectedLayoutRevision: Number(body['expectedLayoutRevision']), operations: [{ type: 'node.project-file.associate', nodeId: node.id, expectedContentVersion: node.contentVersion, nodeType: classified.type, content }] }, current.id));
|
|
107
|
+
return { handled: true, body: result };
|
|
108
|
+
}
|
|
109
|
+
const create = /^[/]v1[/]projects[/]([a-zA-Z0-9_-]+)[/]graphs[/]([a-zA-Z0-9_-]+)[/]project-files$/.exec(url.pathname);
|
|
110
|
+
if (create && request.method === 'POST') {
|
|
111
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
112
|
+
const body = await readJson(request, 16 * 1024);
|
|
113
|
+
if (Object.keys(body).some(key => !['path', 'x', 'y', 'expectedExecutionRevision', 'expectedLayoutRevision', 'idempotencyKey'].includes(key)) || typeof body['path'] !== 'string' || typeof body['x'] !== 'number' || typeof body['y'] !== 'number' || typeof body['idempotencyKey'] !== 'string' || !Number.isSafeInteger(body['expectedExecutionRevision']) || !Number.isSafeInteger(body['expectedLayoutRevision']))
|
|
114
|
+
throw new ServiceError('INVALID_REQUEST', '创建项目文件引用需要路径、落点、版本和幂等键。');
|
|
115
|
+
const file = await this.files.resolve(create[1], body['path'], 'file');
|
|
116
|
+
const observation = (await this.files.stat(create[1], [file.relativePath]))[0];
|
|
117
|
+
if (observation.state !== 'available')
|
|
118
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', '项目文件当前不可读。');
|
|
119
|
+
const name = file.relativePath.split('/').at(-1);
|
|
120
|
+
const classified = classifyProjectFile(name);
|
|
121
|
+
const result = this.auth.withSession(token, origin, current => this.graphs.command({ serviceId: this.graphs.serviceId, projectId: create[1], graphId: create[2], idempotencyKey: body['idempotencyKey'], expectedExecutionRevision: Number(body['expectedExecutionRevision']), expectedLayoutRevision: Number(body['expectedLayoutRevision']), operations: [{ type: 'node.create', node: { id: randomUUID(), type: classified.type, schemaVersion: 1, contentVersion: 1, content: { title: name, text: '', prompt: '', mime: classified.mime, bytes: observation.bytes, source: { kind: 'project-file', serviceId: this.graphs.serviceId, projectId: create[1], relativePath: file.relativePath }, observation: { state: 'available', name, mime: classified.mime, bytes: observation.bytes, changeToken: observation.changeToken } }, x: Number(body['x']), y: Number(body['y']), width: 300, height: 220, readOnly: false } }] }, current.id));
|
|
122
|
+
return { handled: true, body: result };
|
|
123
|
+
}
|
|
124
|
+
const match = /^[/]v1[/]projects[/]([a-zA-Z0-9_-]+)[/]files(?:[/](search|stat|content|media|thumbnail|link-preview|link-content))?$/.exec(url.pathname);
|
|
125
|
+
if (!match)
|
|
126
|
+
return { handled: false };
|
|
127
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
128
|
+
const projectId = match[1], action = match[2] ?? 'list';
|
|
129
|
+
if (action === 'link-preview' && request.method === 'POST') {
|
|
130
|
+
const body = await readJson(request, 16 * 1024);
|
|
131
|
+
if (typeof body['path'] !== 'string' || (body['streamVideo'] !== undefined && typeof body['streamVideo'] !== 'boolean') || Object.keys(body).some(key => !['path', 'thumbnailSize', 'streamVideo'].includes(key)) || (body['thumbnailSize'] !== undefined && ![640, 1280].includes(body['thumbnailSize'])))
|
|
132
|
+
throw new ServiceError('INVALID_REQUEST', '需要有效的文件路径和缩略图尺寸。');
|
|
133
|
+
if (body['streamVideo'] === true && body['thumbnailSize'] === undefined) {
|
|
134
|
+
const metadata = await this.files.readLinkedFile(projectId, body['path'], this.runsDirectory, { metadataOnly: true });
|
|
135
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
136
|
+
if (metadata.mime.startsWith('video/'))
|
|
137
|
+
return { handled: true, body: { name: metadata.name, mime: metadata.mime, bytes: metadata.total, scope: metadata.scope, contentPath: '/v1/projects/' + projectId + '/files/link-content?' + new URLSearchParams({ path: body['path'] }).toString() } };
|
|
138
|
+
}
|
|
139
|
+
const result = body['thumbnailSize'] === undefined
|
|
140
|
+
? await this.files.readLinkedFile(projectId, body['path'], this.runsDirectory)
|
|
141
|
+
: await this.files.readLinkedImageThumbnail(projectId, body['path'], this.runsDirectory, body['thumbnailSize']);
|
|
142
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
143
|
+
return { handled: true, body: { name: result.name, mime: result.mime, bytes: result.bytes.length, base64: result.bytes.toString('base64'), scope: result.scope, ...(result.relativePath ? { relativePath: result.relativePath } : {}) } };
|
|
144
|
+
}
|
|
145
|
+
if (action === 'link-content' && request.method === 'GET') {
|
|
146
|
+
if ([...url.searchParams.keys()].some(key => key !== 'path') || url.searchParams.getAll('path').length !== 1)
|
|
147
|
+
throw new ServiceError('INVALID_REQUEST', '需要有效的文件路径。');
|
|
148
|
+
const range = /^bytes=([0-9]+)-([0-9]+)$/.exec(request.headers.range ?? '');
|
|
149
|
+
if (!range)
|
|
150
|
+
throw new ServiceError('INVALID_REQUEST', '链接文件须使用明确的字节范围。');
|
|
151
|
+
const result = await this.files.readLinkedFile(projectId, url.searchParams.get('path'), this.runsDirectory, { range: { start: Number(range[1]), end: Number(range[2]) } });
|
|
152
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
153
|
+
return { handled: true, binary: result.bytes, status: 206, headers: { 'Content-Type': result.mime, 'Content-Length': String(result.bytes.length), 'Accept-Ranges': 'bytes', 'Content-Range': 'bytes ' + result.start + '-' + result.end + '/' + result.total, 'X-Content-Type-Options': 'nosniff' } };
|
|
154
|
+
}
|
|
155
|
+
if (action === 'stat' && request.method === 'POST') {
|
|
156
|
+
const body = await readJson(request, 16 * 1024);
|
|
157
|
+
if (Object.keys(body).length !== 1 || !Array.isArray(body['paths']))
|
|
158
|
+
throw new ServiceError('INVALID_REQUEST', '需要 paths 数组。');
|
|
159
|
+
const result = await this.files.stat(projectId, body['paths']);
|
|
160
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
161
|
+
return { handled: true, body: result };
|
|
162
|
+
}
|
|
163
|
+
if (request.method !== 'GET')
|
|
164
|
+
throw new ServiceError('NOT_FOUND', '项目文件接口不存在。');
|
|
165
|
+
const allowed = action === 'search' ? ['query', 'showHidden', 'cursor', 'limit'] : ['content', 'media', 'thumbnail'].includes(action) ? ['path', 'cacheKey', ...(action === 'thumbnail' ? ['size'] : [])] : ['path', 'showHidden', 'cursor', 'limit'];
|
|
166
|
+
if ([...url.searchParams.keys()].some(key => !allowed.includes(key)) || allowed.some(key => url.searchParams.getAll(key).length > 1))
|
|
167
|
+
throw new ServiceError('INVALID_REQUEST', '项目文件查询参数无效。');
|
|
168
|
+
const showHidden = url.searchParams.get('showHidden') === 'true';
|
|
169
|
+
if (url.searchParams.has('showHidden') && !['true', 'false'].includes(url.searchParams.get('showHidden')))
|
|
170
|
+
throw new ServiceError('INVALID_REQUEST', 'showHidden 必须是布尔值。');
|
|
171
|
+
const limit = url.searchParams.has('limit') ? Number(url.searchParams.get('limit')) : action === 'search' ? 50 : 200;
|
|
172
|
+
const cursor = url.searchParams.get('cursor');
|
|
173
|
+
if (action === 'list') {
|
|
174
|
+
const result = await this.files.list(projectId, url.searchParams.get('path') ?? '', showHidden, limit, cursor);
|
|
175
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
176
|
+
return { handled: true, body: result };
|
|
177
|
+
}
|
|
178
|
+
if (action === 'content') {
|
|
179
|
+
const path = normalizeProjectPath(url.searchParams.get('path'));
|
|
180
|
+
if (!path)
|
|
181
|
+
throw new ServiceError('INVALID_REQUEST', '需要文件路径。');
|
|
182
|
+
const header = request.headers.range;
|
|
183
|
+
let range;
|
|
184
|
+
if (header) {
|
|
185
|
+
const match = /^bytes=([0-9]+)-([0-9]*)$/.exec(header);
|
|
186
|
+
if (!match)
|
|
187
|
+
throw new ServiceError('INVALID_REQUEST', '仅支持单个明确起点的字节范围。');
|
|
188
|
+
range = { start: Number(match[1]), ...(match[2] ? { end: Number(match[2]) } : {}) };
|
|
189
|
+
}
|
|
190
|
+
const content = await this.files.readRange(projectId, path, range);
|
|
191
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
192
|
+
if (range)
|
|
193
|
+
return { handled: true, binary: content.bytes, status: 206, headers: { 'Content-Type': classifyProjectFile(path).mime, 'Content-Length': String(content.bytes.length), 'Accept-Ranges': 'bytes', 'Content-Range': 'bytes ' + content.start + '-' + content.end + '/' + content.total, 'X-Content-Type-Options': 'nosniff' } };
|
|
194
|
+
return { handled: true, body: { path, bytes: content.bytes.length, base64: content.bytes.toString('base64') } };
|
|
195
|
+
}
|
|
196
|
+
if (action === 'media') {
|
|
197
|
+
if (request.headers.range)
|
|
198
|
+
throw new ServiceError('INVALID_REQUEST', '图片原图不接受 Range。');
|
|
199
|
+
const path = normalizeProjectPath(url.searchParams.get('path'));
|
|
200
|
+
if (!path)
|
|
201
|
+
throw new ServiceError('INVALID_REQUEST', '需要文件路径。');
|
|
202
|
+
const classified = classifyProjectFile(path);
|
|
203
|
+
if (!classified.mime.startsWith('image/'))
|
|
204
|
+
throw new ServiceError('INVALID_REQUEST', '仅支持图片项目文件原图。');
|
|
205
|
+
const content = await this.files.readRange(projectId, path);
|
|
206
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
207
|
+
return { handled: true, binary: content.bytes, status: 200, headers: { 'Content-Type': classified.mime, 'Content-Length': String(content.bytes.length), 'Cache-Control': 'private, max-age=31536000, immutable', 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff' } };
|
|
208
|
+
}
|
|
209
|
+
if (action === 'thumbnail') {
|
|
210
|
+
if (request.headers.range)
|
|
211
|
+
throw new ServiceError('INVALID_REQUEST', '缩略图不接受 Range。');
|
|
212
|
+
const path = normalizeProjectPath(url.searchParams.get('path'));
|
|
213
|
+
if (!path)
|
|
214
|
+
throw new ServiceError('INVALID_REQUEST', '需要文件路径。');
|
|
215
|
+
const requestedSize = url.searchParams.get('size');
|
|
216
|
+
const size = requestedSize === null ? 320 : Number(requestedSize);
|
|
217
|
+
if (!PROJECT_IMAGE_THUMBNAIL_LEVELS.includes(size))
|
|
218
|
+
throw new ServiceError('INVALID_REQUEST', '无效的缩略图尺寸。');
|
|
219
|
+
const thumbnail = await this.files.thumbnail(projectId, path, size);
|
|
220
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
221
|
+
// Video covers may be replaced independently of the video's change token.
|
|
222
|
+
const video = /[.](mp4|m4v|mov|webm|mkv|avi|mpg|mpeg|ogv)$/i.test(path);
|
|
223
|
+
return { handled: true, binary: thumbnail.bytes, status: 200, headers: { 'Content-Type': thumbnail.mime, 'Content-Length': String(thumbnail.bytes.length), 'Cache-Control': video ? 'private, no-cache' : 'private, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff' } };
|
|
224
|
+
}
|
|
225
|
+
if (action === 'search') {
|
|
226
|
+
const query = url.searchParams.get('query')?.trim() ?? '';
|
|
227
|
+
const controller = new AbortController();
|
|
228
|
+
const cancel = () => controller.abort();
|
|
229
|
+
request.once('aborted', cancel);
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = await this.files.search(projectId, query, showHidden, limit, cursor, controller.signal);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
request.off('aborted', cancel);
|
|
236
|
+
}
|
|
237
|
+
this.auth.withSession(token, origin, () => undefined);
|
|
238
|
+
return { handled: true, body: result };
|
|
239
|
+
}
|
|
240
|
+
return { handled: false };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=project-files-api.js.map
|