plugin-ai-api 1.0.24 → 1.0.28
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/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/757.6568d3504ad29352.js +10 -0
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +48 -10
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +382 -5
- package/src/server/__tests__/usage.test.ts +57 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +36 -39
- package/src/server/collections/ai-api-config.ts +9 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- package/src/server/resource/ai-api-user-permissions.ts +0 -76
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
FileProcessorService,
|
|
4
|
+
base64FileForwarder,
|
|
5
|
+
httpFileUrlFetcher,
|
|
6
|
+
fetchFileAsBase64,
|
|
7
|
+
pdfFileProcessor,
|
|
8
|
+
type PdfToImageRenderer,
|
|
9
|
+
} from '../file-processor';
|
|
10
|
+
|
|
11
|
+
describe('FileProcessorService', () => {
|
|
12
|
+
it('forwards file blocks with base64 data unchanged', async () => {
|
|
13
|
+
const service = new FileProcessorService();
|
|
14
|
+
service.register(base64FileForwarder);
|
|
15
|
+
|
|
16
|
+
const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
|
|
17
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
18
|
+
|
|
19
|
+
expect(result).toEqual(block);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns the block unchanged when no processor can handle it', async () => {
|
|
23
|
+
const service = new FileProcessorService();
|
|
24
|
+
const block = { type: 'text', text: 'hello' };
|
|
25
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual(block);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('allows custom processors to override default behavior', async () => {
|
|
31
|
+
const service = new FileProcessorService();
|
|
32
|
+
service.register(base64FileForwarder);
|
|
33
|
+
|
|
34
|
+
const customProcessor = {
|
|
35
|
+
name: 'customFileProcessor',
|
|
36
|
+
canHandle: (block: { type: string }) => block.type === 'file',
|
|
37
|
+
process: vi.fn().mockResolvedValue({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } }),
|
|
38
|
+
};
|
|
39
|
+
service.register(customProcessor);
|
|
40
|
+
|
|
41
|
+
const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
|
|
42
|
+
const result = await service.process(block, { ctx: {} as any });
|
|
43
|
+
|
|
44
|
+
expect(customProcessor.process).toHaveBeenCalledWith(block, { ctx: {} as any });
|
|
45
|
+
expect(result).toEqual({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('unregister removes a processor by name', () => {
|
|
49
|
+
const service = new FileProcessorService();
|
|
50
|
+
service.register(base64FileForwarder);
|
|
51
|
+
expect(service.list()).toHaveLength(1);
|
|
52
|
+
|
|
53
|
+
service.unregister(base64FileForwarder.name);
|
|
54
|
+
expect(service.list()).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('httpFileUrlFetcher', () => {
|
|
59
|
+
it('fetches a file from an http(s) URL and converts it to a file block', async () => {
|
|
60
|
+
const originalFetch = globalThis.fetch;
|
|
61
|
+
const fileBuffer = Buffer.from('hello world');
|
|
62
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: new Headers({
|
|
66
|
+
'content-type': 'text/plain',
|
|
67
|
+
'content-length': String(fileBuffer.length),
|
|
68
|
+
}),
|
|
69
|
+
arrayBuffer: () =>
|
|
70
|
+
Promise.resolve(fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.length)),
|
|
71
|
+
} as unknown as Response);
|
|
72
|
+
|
|
73
|
+
const result = await httpFileUrlFetcher.process(
|
|
74
|
+
{ type: 'file_url', file_url: { url: 'https://example.com/hello.txt' } },
|
|
75
|
+
{ ctx: {} as any },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(result).toMatchObject({
|
|
79
|
+
type: 'file',
|
|
80
|
+
file: {
|
|
81
|
+
file_data: expect.stringContaining('data:text/plain;base64,'),
|
|
82
|
+
mime_type: 'text/plain',
|
|
83
|
+
filename: 'hello.txt',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects unsupported protocols', async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
httpFileUrlFetcher.process(
|
|
93
|
+
{ type: 'file_url', file_url: { url: 'ftp://example.com/file.txt' } },
|
|
94
|
+
{ ctx: {} as any },
|
|
95
|
+
),
|
|
96
|
+
).rejects.toThrow('protocol');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('fetchFileAsBase64', () => {
|
|
101
|
+
it('validates URL protocols', async () => {
|
|
102
|
+
await expect(fetchFileAsBase64('ftp://example.com/file.txt')).rejects.toThrow('protocol');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects malformed URLs', async () => {
|
|
106
|
+
await expect(fetchFileAsBase64('not a url')).rejects.toThrow('valid URL');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('pdfFileProcessor', () => {
|
|
111
|
+
const pdfBuffer = Buffer.from('%PDF-1.4\n1 0 obj\n<<\n>>\nendobj\n', 'binary');
|
|
112
|
+
const pdfDataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
|
|
113
|
+
|
|
114
|
+
function createContext(config: { pdfRenderPagesAsImages?: boolean }, renderer?: PdfToImageRenderer | null) {
|
|
115
|
+
const service = new FileProcessorService();
|
|
116
|
+
if (renderer) {
|
|
117
|
+
service.registerPdfRenderer(renderer);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ctx: {
|
|
121
|
+
db: {
|
|
122
|
+
getRepository: vi.fn((name: string) => {
|
|
123
|
+
if (name === 'aiApiConfig') {
|
|
124
|
+
return {
|
|
125
|
+
findOne: vi.fn().mockResolvedValue({
|
|
126
|
+
get: (key: string) => (key === 'pdfRenderPagesAsImages' ? config.pdfRenderPagesAsImages : undefined),
|
|
127
|
+
pdfRenderPagesAsImages: config.pdfRenderPagesAsImages,
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { findOne: vi.fn() };
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
app: {
|
|
135
|
+
pm: {
|
|
136
|
+
get: vi.fn().mockReturnValue({
|
|
137
|
+
fileProcessorService: service,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
log: { warn: vi.fn() },
|
|
142
|
+
},
|
|
143
|
+
} as any;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it('forwards a PDF file block when pdfRenderPagesAsImages is disabled', async () => {
|
|
147
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
148
|
+
const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: false }));
|
|
149
|
+
expect(result).toBe(block);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('forwards a PDF and warns when rendering is enabled but no renderer is registered', async () => {
|
|
153
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
154
|
+
const context = createContext({ pdfRenderPagesAsImages: true });
|
|
155
|
+
const result = await pdfFileProcessor.process(block, context);
|
|
156
|
+
|
|
157
|
+
expect(result).toBe(block);
|
|
158
|
+
expect(context.ctx.log.warn).toHaveBeenCalledWith(
|
|
159
|
+
expect.stringContaining('pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered'),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('converts a PDF file block into image_url blocks when a renderer is registered', async () => {
|
|
164
|
+
const renderer: PdfToImageRenderer = {
|
|
165
|
+
name: 'mockRenderer',
|
|
166
|
+
render: vi.fn().mockResolvedValue([Buffer.from('page1'), Buffer.from('page2')]),
|
|
167
|
+
};
|
|
168
|
+
const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
|
|
169
|
+
const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: true }, renderer));
|
|
170
|
+
|
|
171
|
+
expect(Array.isArray(result)).toBe(true);
|
|
172
|
+
expect(result).toHaveLength(2);
|
|
173
|
+
expect((result as any[])[0]).toMatchObject({ type: 'image_url' });
|
|
174
|
+
expect((result as any[])[0].image_url.url).toMatch(/^data:image\/png;base64,/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('does not convert non-PDF file blocks', async () => {
|
|
178
|
+
const block = {
|
|
179
|
+
type: 'file',
|
|
180
|
+
file: { file_data: 'data:text/plain;base64,SGVsbG8=', filename: 'hello.txt' },
|
|
181
|
+
} as any;
|
|
182
|
+
expect(pdfFileProcessor.canHandle(block)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import dns from 'dns';
|
|
3
|
+
import { isIP } from 'net';
|
|
4
|
+
import { basename } from 'path';
|
|
5
|
+
import { getAiApiConfig } from '../utils/request-cache';
|
|
6
|
+
|
|
7
|
+
export interface FileContentBlock {
|
|
8
|
+
type: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FileProcessorContext {
|
|
13
|
+
ctx: Context;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FileProcessor {
|
|
17
|
+
name: string;
|
|
18
|
+
canHandle(block: FileContentBlock): boolean;
|
|
19
|
+
process(block: FileContentBlock, context: FileProcessorContext): Promise<FileContentBlock | FileContentBlock[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PdfToImageRenderer {
|
|
23
|
+
name: string;
|
|
24
|
+
/**
|
|
25
|
+
* Render each page of a PDF into a PNG image buffer.
|
|
26
|
+
* @param buffer Raw PDF bytes.
|
|
27
|
+
* @returns Array of PNG buffers, one per page.
|
|
28
|
+
*/
|
|
29
|
+
render(buffer: Buffer): Promise<Buffer[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FileProcessorErrorCode =
|
|
33
|
+
| 'invalid_url'
|
|
34
|
+
| 'unsupported_protocol'
|
|
35
|
+
| 'blocked_host'
|
|
36
|
+
| 'fetch_failed'
|
|
37
|
+
| 'file_too_large'
|
|
38
|
+
| 'content_type_not_allowed'
|
|
39
|
+
| 'too_many_redirects'
|
|
40
|
+
| 'missing_url';
|
|
41
|
+
|
|
42
|
+
export class FileProcessorError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly code: FileProcessorErrorCode,
|
|
45
|
+
message: string,
|
|
46
|
+
) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'FileProcessorError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FetchFileOptions {
|
|
53
|
+
maxSizeBytes?: number;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
maxRedirects?: number;
|
|
56
|
+
allowedProtocols?: string[];
|
|
57
|
+
allowedContentTypes?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
61
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
62
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
63
|
+
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
|
64
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
65
|
+
|
|
66
|
+
export class FileProcessorService {
|
|
67
|
+
private processors: FileProcessor[] = [];
|
|
68
|
+
private pdfRenderer: PdfToImageRenderer | null = null;
|
|
69
|
+
|
|
70
|
+
register(processor: FileProcessor): void {
|
|
71
|
+
this.unregister(processor.name);
|
|
72
|
+
this.processors.push(processor);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
unregister(name: string): void {
|
|
76
|
+
this.processors = this.processors.filter((p) => p.name !== name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async process(
|
|
80
|
+
block: FileContentBlock,
|
|
81
|
+
context: FileProcessorContext,
|
|
82
|
+
): Promise<FileContentBlock | FileContentBlock[]> {
|
|
83
|
+
// Search from the end so the most recently registered processor wins.
|
|
84
|
+
// This makes it easy for a custom plugin to override a default processor.
|
|
85
|
+
const processor = [...this.processors].reverse().find((p) => p.canHandle(block));
|
|
86
|
+
if (!processor) {
|
|
87
|
+
return block;
|
|
88
|
+
}
|
|
89
|
+
return processor.process(block, context);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
list(): ReadonlyArray<FileProcessor> {
|
|
93
|
+
return [...this.processors];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Register a renderer used to convert PDF pages into PNG images when
|
|
98
|
+
* `pdfRenderPagesAsImages` is enabled in the AI API config.
|
|
99
|
+
*/
|
|
100
|
+
registerPdfRenderer(renderer: PdfToImageRenderer): void {
|
|
101
|
+
this.pdfRenderer = renderer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
unregisterPdfRenderer(): void {
|
|
105
|
+
this.pdfRenderer = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getPdfRenderer(): PdfToImageRenderer | null {
|
|
109
|
+
return this.pdfRenderer;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractFilename(url: string, contentDisposition: string | null): string | undefined {
|
|
118
|
+
if (contentDisposition) {
|
|
119
|
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
120
|
+
if (match) return match[1];
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const pathname = new URL(url).pathname;
|
|
124
|
+
if (pathname) return basename(pathname);
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore malformed URL
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseIpv4Octets(ip: string): number[] | undefined {
|
|
132
|
+
const parts = ip.split('.');
|
|
133
|
+
if (parts.length !== 4) return undefined;
|
|
134
|
+
const octets: number[] = [];
|
|
135
|
+
for (const part of parts) {
|
|
136
|
+
if (!/^\d{1,3}$/.test(part)) return undefined;
|
|
137
|
+
const value = Number(part);
|
|
138
|
+
if (value > 255) return undefined;
|
|
139
|
+
octets.push(value);
|
|
140
|
+
}
|
|
141
|
+
return octets;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isBlockedIpv4(ip: string): boolean {
|
|
145
|
+
const octets = parseIpv4Octets(ip);
|
|
146
|
+
if (!octets) return true;
|
|
147
|
+
const [a, b] = octets;
|
|
148
|
+
if (a === 0) return true; // 0.0.0.0/8 "this" network
|
|
149
|
+
if (a === 10) return true; // 10.0.0.0/8 private
|
|
150
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 carrier-grade NAT
|
|
151
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
152
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata)
|
|
153
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
|
154
|
+
if (a === 192 && b === 0) return true; // 192.0.0.0/24 IETF protocol assignments
|
|
155
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
|
156
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
|
|
157
|
+
if (a >= 224) return true; // multicast, reserved, broadcast
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseHexGroup(group: string): number | undefined {
|
|
162
|
+
if (group.length < 1 || group.length > 4 || !/^[0-9a-fA-F]+$/.test(group)) return undefined;
|
|
163
|
+
return parseInt(group, 16);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function expandIpv6(input: string): number[] | undefined {
|
|
167
|
+
let address = input;
|
|
168
|
+
const zoneIndex = address.indexOf('%');
|
|
169
|
+
if (zoneIndex !== -1) address = address.slice(0, zoneIndex);
|
|
170
|
+
if (!address) return undefined;
|
|
171
|
+
|
|
172
|
+
// Rewrite an embedded IPv4 suffix (e.g. ::ffff:127.0.0.1) as two hex groups.
|
|
173
|
+
const lastColon = address.lastIndexOf(':');
|
|
174
|
+
if (lastColon !== -1 && address.includes('.', lastColon)) {
|
|
175
|
+
const octets = parseIpv4Octets(address.slice(lastColon + 1));
|
|
176
|
+
if (!octets) return undefined;
|
|
177
|
+
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
|
178
|
+
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
|
179
|
+
address = `${address.slice(0, lastColon + 1)}${high}:${low}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const groups: number[] = [];
|
|
183
|
+
const doubleColonIndex = address.indexOf('::');
|
|
184
|
+
if (doubleColonIndex !== -1) {
|
|
185
|
+
if (address.indexOf('::', doubleColonIndex + 1) !== -1) return undefined;
|
|
186
|
+
const head = address.slice(0, doubleColonIndex);
|
|
187
|
+
const tail = address.slice(doubleColonIndex + 2);
|
|
188
|
+
const headGroups = head === '' ? [] : head.split(':');
|
|
189
|
+
const tailGroups = tail === '' ? [] : tail.split(':');
|
|
190
|
+
const fillCount = 8 - headGroups.length - tailGroups.length;
|
|
191
|
+
if (fillCount < 1) return undefined;
|
|
192
|
+
const allGroups = [...headGroups];
|
|
193
|
+
for (let i = 0; i < fillCount; i += 1) allGroups.push('0');
|
|
194
|
+
allGroups.push(...tailGroups);
|
|
195
|
+
for (const group of allGroups) {
|
|
196
|
+
const parsed = parseHexGroup(group);
|
|
197
|
+
if (parsed === undefined) return undefined;
|
|
198
|
+
groups.push(parsed);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
for (const group of address.split(':')) {
|
|
202
|
+
const parsed = parseHexGroup(group);
|
|
203
|
+
if (parsed === undefined) return undefined;
|
|
204
|
+
groups.push(parsed);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return groups.length === 8 ? groups : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isBlockedIpv6(ip: string): boolean {
|
|
212
|
+
const groups = expandIpv6(ip);
|
|
213
|
+
if (!groups) return true;
|
|
214
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
|
|
215
|
+
|
|
216
|
+
if (groups.every((group) => group === 0)) return true; // :: unspecified
|
|
217
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
|
|
218
|
+
return true; // ::1 loopback
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Embedded IPv4 forms: IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d),
|
|
222
|
+
// and NAT64 (64:ff9b::a.b.c.d) — judge by the embedded IPv4 address.
|
|
223
|
+
const isIpv4Mapped = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff;
|
|
224
|
+
const isIpv4Compatible = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0;
|
|
225
|
+
const isNat64 = g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0;
|
|
226
|
+
if (isIpv4Mapped || isIpv4Compatible || isNat64) {
|
|
227
|
+
const embedded = `${(g6 >> 8) & 0xff}.${g6 & 0xff}.${(g7 >> 8) & 0xff}.${g7 & 0xff}`;
|
|
228
|
+
return isBlockedIpv4(embedded);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
|
|
232
|
+
if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
233
|
+
if ((g0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Returns true when an IP literal must not be fetched: private, loopback,
|
|
239
|
+
* link-local, and other reserved ranges for both IPv4 and IPv6 (including IPv6
|
|
240
|
+
* forms that embed an IPv4 address). Unparseable input is blocked defensively.
|
|
241
|
+
*/
|
|
242
|
+
export function isBlockedAddress(ip: string): boolean {
|
|
243
|
+
const version = isIP(ip);
|
|
244
|
+
if (version === 4) return isBlockedIpv4(ip);
|
|
245
|
+
if (version === 6) return isBlockedIpv6(ip);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function assertHostAllowed(hostname: string): Promise<void> {
|
|
250
|
+
if (isIP(hostname) !== 0) {
|
|
251
|
+
if (isBlockedAddress(hostname)) {
|
|
252
|
+
throw new FileProcessorError('blocked_host', `Host '${hostname}' is a blocked address.`);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let addresses: Array<{ address: string }>;
|
|
258
|
+
try {
|
|
259
|
+
addresses = await dns.promises.lookup(hostname, { all: true });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const message = error instanceof Error && error.message ? error.message : String(error);
|
|
262
|
+
throw new FileProcessorError('fetch_failed', `Could not resolve host '${hostname}': ${message}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!addresses.length) {
|
|
266
|
+
throw new FileProcessorError('fetch_failed', `Could not resolve host '${hostname}'.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const { address } of addresses) {
|
|
270
|
+
if (isBlockedAddress(address)) {
|
|
271
|
+
throw new FileProcessorError('blocked_host', `Host '${hostname}' resolves to blocked address '${address}'.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function fetchWithSsrfGuard(
|
|
277
|
+
initialUrl: string,
|
|
278
|
+
options: { allowedProtocols: Set<string>; maxRedirects: number; signal: AbortSignal },
|
|
279
|
+
): Promise<{ response: Response; finalUrl: string }> {
|
|
280
|
+
let currentUrl = initialUrl;
|
|
281
|
+
for (let hop = 0; ; hop += 1) {
|
|
282
|
+
let parsed: URL;
|
|
283
|
+
try {
|
|
284
|
+
parsed = new URL(currentUrl);
|
|
285
|
+
} catch {
|
|
286
|
+
throw new FileProcessorError('invalid_url', `File URL '${currentUrl}' is not a valid URL.`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!options.allowedProtocols.has(parsed.protocol)) {
|
|
290
|
+
throw new FileProcessorError('unsupported_protocol', `File URL protocol '${parsed.protocol}' is not allowed.`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// SSRF guard: resolve and validate the host before connecting. A malicious DNS
|
|
294
|
+
// server could still rebind between this check and the fetch; closing that gap
|
|
295
|
+
// fully would require pinning the resolved address into the connection.
|
|
296
|
+
const hostname =
|
|
297
|
+
parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']') ? parsed.hostname.slice(1, -1) : parsed.hostname;
|
|
298
|
+
await assertHostAllowed(hostname);
|
|
299
|
+
|
|
300
|
+
const response = await fetch(currentUrl, { signal: options.signal, redirect: 'manual' });
|
|
301
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
302
|
+
return { response, finalUrl: currentUrl };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (hop >= options.maxRedirects) {
|
|
306
|
+
throw new FileProcessorError(
|
|
307
|
+
'too_many_redirects',
|
|
308
|
+
`File URL '${initialUrl}' exceeded the limit of ${options.maxRedirects} redirects.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const location = response.headers.get('location');
|
|
313
|
+
if (!location) {
|
|
314
|
+
throw new FileProcessorError('fetch_failed', `Redirect from '${currentUrl}' is missing a Location header.`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
319
|
+
} catch {
|
|
320
|
+
throw new FileProcessorError(
|
|
321
|
+
'invalid_url',
|
|
322
|
+
`Redirect Location '${location}' from '${currentUrl}' is not a valid URL.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function fetchFileAsBase64(
|
|
329
|
+
url: string,
|
|
330
|
+
options: FetchFileOptions = {},
|
|
331
|
+
): Promise<{ fileData: string; mimeType: string | undefined; filename: string | undefined }> {
|
|
332
|
+
const maxSize = options.maxSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
|
|
333
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
334
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
335
|
+
const allowedProtocols = options.allowedProtocols ? new Set(options.allowedProtocols) : ALLOWED_PROTOCOLS;
|
|
336
|
+
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const { response, finalUrl } = await fetchWithSsrfGuard(url, {
|
|
342
|
+
allowedProtocols,
|
|
343
|
+
maxRedirects,
|
|
344
|
+
signal: controller.signal,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
throw new FileProcessorError(
|
|
349
|
+
'fetch_failed',
|
|
350
|
+
`Failed to fetch file from '${finalUrl}': ${response.status} ${response.statusText}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const contentLength = response.headers.get('content-length');
|
|
355
|
+
if (contentLength && Number(contentLength) > maxSize) {
|
|
356
|
+
throw new FileProcessorError('file_too_large', `File at '${finalUrl}' exceeds maximum allowed size.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const contentType = response.headers.get('content-type') || undefined;
|
|
360
|
+
if (
|
|
361
|
+
options.allowedContentTypes &&
|
|
362
|
+
contentType &&
|
|
363
|
+
!options.allowedContentTypes.some((type) => contentType.includes(type))
|
|
364
|
+
) {
|
|
365
|
+
throw new FileProcessorError('content_type_not_allowed', `File content type '${contentType}' is not allowed.`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
369
|
+
if (buffer.length > maxSize) {
|
|
370
|
+
throw new FileProcessorError('file_too_large', `File at '${finalUrl}' exceeds maximum allowed size.`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const mimeType = contentType?.split(';')[0].trim() ?? 'application/octet-stream';
|
|
374
|
+
const contentDisposition = response.headers.get('content-disposition');
|
|
375
|
+
const filename = extractFilename(finalUrl, contentDisposition) ?? 'file';
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
fileData: `data:${mimeType};base64,${buffer.toString('base64')}`,
|
|
379
|
+
mimeType,
|
|
380
|
+
filename,
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
clearTimeout(timeout);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Default processor: forwards base64 `file` blocks unchanged.
|
|
389
|
+
* Handles `{ type: 'file', file: { file_data: 'data:...;base64,...' } }`.
|
|
390
|
+
*/
|
|
391
|
+
export const base64FileForwarder: FileProcessor = {
|
|
392
|
+
name: 'base64FileForwarder',
|
|
393
|
+
canHandle(block: FileContentBlock): boolean {
|
|
394
|
+
if (block.type !== 'file') return false;
|
|
395
|
+
const file = isRecord(block.file) ? block.file : undefined;
|
|
396
|
+
if (!file) return false;
|
|
397
|
+
const fileData = String(file.file_data ?? '');
|
|
398
|
+
return fileData.startsWith('data:') && fileData.includes(';base64,');
|
|
399
|
+
},
|
|
400
|
+
async process(block: FileContentBlock): Promise<FileContentBlock> {
|
|
401
|
+
return block;
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Default processor: downloads an http(s) `file_url` and converts it into a `file` block.
|
|
407
|
+
* Handles `{ type: 'file_url', file_url: { url: 'https://...' } }`.
|
|
408
|
+
*/
|
|
409
|
+
export const httpFileUrlFetcher: FileProcessor = {
|
|
410
|
+
name: 'httpFileUrlFetcher',
|
|
411
|
+
canHandle(block: FileContentBlock): boolean {
|
|
412
|
+
if (block.type !== 'file_url') return false;
|
|
413
|
+
const fileUrl = isRecord(block.file_url) ? block.file_url : undefined;
|
|
414
|
+
if (!fileUrl) return false;
|
|
415
|
+
const url = String(fileUrl.url ?? '');
|
|
416
|
+
return url.startsWith('http://') || url.startsWith('https://');
|
|
417
|
+
},
|
|
418
|
+
async process(block: FileContentBlock): Promise<FileContentBlock> {
|
|
419
|
+
const fileUrl = isRecord(block.file_url) ? block.file_url : undefined;
|
|
420
|
+
const url = String(fileUrl?.url ?? '');
|
|
421
|
+
if (!url) {
|
|
422
|
+
throw new FileProcessorError('missing_url', "file_url block requires a 'url' property.");
|
|
423
|
+
}
|
|
424
|
+
const { fileData, mimeType, filename } = await fetchFileAsBase64(url);
|
|
425
|
+
return {
|
|
426
|
+
type: 'file',
|
|
427
|
+
file: {
|
|
428
|
+
file_data: fileData,
|
|
429
|
+
mime_type: mimeType,
|
|
430
|
+
filename: filename || 'file',
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
function decodeBase64DataUrl(url: string): { mimeType: string; buffer: Buffer } | undefined {
|
|
437
|
+
const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/.exec(url);
|
|
438
|
+
if (!match) return undefined;
|
|
439
|
+
try {
|
|
440
|
+
const buffer = Buffer.from(match[2], 'base64');
|
|
441
|
+
return { mimeType: match[1].toLowerCase(), buffer };
|
|
442
|
+
} catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isPdfBuffer(buffer: Buffer): boolean {
|
|
448
|
+
return buffer.length >= 4 && buffer.toString('binary', 0, 4) === '%PDF';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isPdfFileBlock(block: FileContentBlock): boolean {
|
|
452
|
+
if (block.type !== 'file') return false;
|
|
453
|
+
const file = isRecord(block.file) ? block.file : undefined;
|
|
454
|
+
if (!file) return false;
|
|
455
|
+
const fileData = String(file.file_data ?? '');
|
|
456
|
+
if (!fileData.startsWith('data:')) return false;
|
|
457
|
+
const mimeType = String(file.mime_type ?? '').toLowerCase();
|
|
458
|
+
if (mimeType === 'application/pdf') return true;
|
|
459
|
+
if (fileData.startsWith('data:application/pdf')) return true;
|
|
460
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
461
|
+
if (decoded && isPdfBuffer(decoded.buffer)) return true;
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function getPluginFromContext(context: FileProcessorContext): any | undefined {
|
|
466
|
+
// @ts-expect-error app may not be typed on Context in test mocks.
|
|
467
|
+
return context.ctx.app?.pm?.get?.('plugin-ai-api');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Optional processor: converts a PDF `file` block into a series of `image_url`
|
|
472
|
+
* blocks when `pdfRenderPagesAsImages` is enabled and a PdfToImageRenderer is
|
|
473
|
+
* registered. When disabled, unavailable, or the block is not a PDF, the block
|
|
474
|
+
* is forwarded unchanged.
|
|
475
|
+
*/
|
|
476
|
+
export const pdfFileProcessor: FileProcessor = {
|
|
477
|
+
name: 'pdfFileProcessor',
|
|
478
|
+
canHandle(block: FileContentBlock): boolean {
|
|
479
|
+
return isPdfFileBlock(block);
|
|
480
|
+
},
|
|
481
|
+
async process(
|
|
482
|
+
block: FileContentBlock,
|
|
483
|
+
context: FileProcessorContext,
|
|
484
|
+
): Promise<FileContentBlock | FileContentBlock[]> {
|
|
485
|
+
const config = await getAiApiConfig(context.ctx);
|
|
486
|
+
if (!config?.pdfRenderPagesAsImages) {
|
|
487
|
+
return block;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const plugin = getPluginFromContext(context);
|
|
491
|
+
const renderer = plugin?.fileProcessorService?.getPdfRenderer?.();
|
|
492
|
+
if (!renderer) {
|
|
493
|
+
context.ctx.log?.warn?.(
|
|
494
|
+
'[pdfFileProcessor] pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered. ' +
|
|
495
|
+
'Forwarding PDF as a file block.',
|
|
496
|
+
);
|
|
497
|
+
return block;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const file = isRecord(block.file) ? block.file : undefined;
|
|
501
|
+
const fileData = String(file?.file_data ?? '');
|
|
502
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
503
|
+
if (!decoded) {
|
|
504
|
+
return block;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const pages = await renderer.render(decoded.buffer);
|
|
508
|
+
return pages.map((buffer) => ({
|
|
509
|
+
type: 'image_url',
|
|
510
|
+
image_url: { url: `data:image/png;base64,${buffer.toString('base64')}` },
|
|
511
|
+
}));
|
|
512
|
+
},
|
|
513
|
+
};
|