plugin-file-preview-auth 1.3.5 → 1.3.6
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/client-v2.d.ts +2 -0
- package/client-v2.js +1 -0
- package/dist/client/713.79a55458f5b67f39.js +30 -0
- package/dist/client/823.8b0ab22c181d4523.js +10 -0
- package/dist/client/828.ae8e47a2e7a3bc9e.js +49 -0
- package/dist/client/892.a568eb42fd6f0047.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/index.js +10 -0
- package/dist/externalVersion.js +8 -7
- package/dist/node_modules/@aws-sdk/client-s3/dist-cjs/index.js +3086 -3725
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser +16 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser.cmd +17 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/.bin/fxparser.ps1 +28 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js +110 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/SignatureV4MultiRegion.js +66 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/index.js +2 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-es/signature-v4-crt-container.js +3 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/SignatureV4MultiRegion.d.ts +30 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/index.d.ts +5 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/signature-v4-crt-container.d.ts +28 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/SignatureV4MultiRegion.d.ts +40 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/index.d.ts +2 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/dist-types/ts3.4/signature-v4-crt-container.d.ts +20 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region/package.json +57 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/AdaptiveRetryStrategy.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/ConfiguredRetryStrategy.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/DefaultRateLimiter.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/StandardRetryStrategy.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/config.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/constants.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/defaultRetryBackoffStrategy.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/defaultRetryToken.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/index.js +358 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-cjs/types.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/AdaptiveRetryStrategy.js +24 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/ConfiguredRetryStrategy.js +18 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/DefaultRateLimiter.js +100 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/StandardRetryStrategy.js +65 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/config.js +7 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/constants.js +9 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/defaultRetryBackoffStrategy.js +14 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/defaultRetryToken.js +11 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/index.js +7 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-es/types.js +1 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/AdaptiveRetryStrategy.d.ts +33 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ConfiguredRetryStrategy.d.ts +32 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/DefaultRateLimiter.d.ts +49 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/StandardRetryStrategy.d.ts +26 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/config.d.ts +20 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/constants.d.ts +59 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/defaultRetryBackoffStrategy.d.ts +5 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/defaultRetryToken.d.ts +9 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/index.d.ts +7 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/AdaptiveRetryStrategy.d.ts +33 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/ConfiguredRetryStrategy.d.ts +32 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/DefaultRateLimiter.d.ts +49 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/StandardRetryStrategy.d.ts +26 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/config.d.ts +20 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/constants.d.ts +59 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/defaultRetryBackoffStrategy.d.ts +5 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/defaultRetryToken.d.ts +9 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/index.d.ts +7 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/ts3.4/types.d.ts +19 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/dist-types/types.d.ts +19 -0
- package/dist/node_modules/@aws-sdk/client-s3/node_modules/@smithy/util-retry/package.json +68 -0
- package/dist/node_modules/@aws-sdk/client-s3/package.json +1 -1
- package/dist/node_modules/xlsx/package.json +1 -1
- package/dist/server/ocr/tesseract-runner.js +3 -1
- package/dist/server/plugin.js +22 -4
- package/package.json +57 -45
- package/src/client/AIFilePreviewAction.tsx +282 -0
- package/src/client/__tests__/ocr-utils.test.ts +85 -0
- package/src/client/client.d.ts +258 -0
- package/src/client/index.tsx +1807 -0
- package/src/client/locale.ts +21 -0
- package/src/client-v2/index.tsx +1 -0
- package/src/client-v2/plugin.tsx +7 -0
- package/{dist/index.d.ts → src/index.ts} +11 -10
- package/src/locale/en-US.json +14 -0
- package/src/locale/vi-VN.json +14 -0
- package/src/locale/zh-CN.json +14 -0
- package/src/server/__tests__/smoke.test.ts +17 -0
- package/src/server/collections/attachment-ocr-results.ts +40 -0
- package/{dist/server/collections/file-preview-auth.d.ts → src/server/collections/file-preview-auth.ts} +15 -14
- package/src/server/excel-parser-handler.ts +128 -0
- package/{dist/server/index.d.ts → src/server/index.ts} +10 -9
- package/src/server/migrations/20260528000000-move-ocr-fields-out-of-attachments.ts +39 -0
- package/src/server/ocr/tesseract-runner.ts +389 -0
- package/src/server/ocr/tesseract-worker.ts +235 -0
- package/src/server/plugin.ts +1470 -0
- package/dist/client/166.17caa11c2ba40313.js +0 -10
- package/dist/client/351.0f0ce45c92425c8f.js +0 -10
- package/dist/client/374.96762d13b15e7467.js +0 -30
- package/dist/client/514.2a8b6aa0d2fcd4b2.js +0 -49
- package/dist/client/AIFilePreviewAction.d.ts +0 -42
- package/dist/client/index.d.ts +0 -14
- package/dist/client/locale.d.ts +0 -10
- package/dist/node_modules/xlsx/node_modules/.bin/crc32 +0 -15
- package/dist/node_modules/xlsx/node_modules/.bin/crc32.cmd +0 -7
- package/dist/server/collections/attachment-ocr-results.d.ts +0 -2
- package/dist/server/excel-parser-handler.d.ts +0 -60
- package/dist/server/migrations/20260528000000-move-ocr-fields-out-of-attachments.d.ts +0 -5
- package/dist/server/ocr/tesseract-runner.d.ts +0 -34
- package/dist/server/ocr/tesseract-worker.d.ts +0 -27
- package/dist/server/plugin.d.ts +0 -54
|
@@ -0,0 +1,1470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Plugin } from '@nocobase/server';
|
|
11
|
+
import { koaMulter as multer } from '@nocobase/utils';
|
|
12
|
+
import { ExcelParserHandler } from './excel-parser-handler';
|
|
13
|
+
import { readFile, unlink } from 'fs/promises';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { col } from 'sequelize';
|
|
17
|
+
import { TesseractWorker } from './ocr/tesseract-worker';
|
|
18
|
+
|
|
19
|
+
const FILE_PREVIEW_WORK_CONTEXT_TYPE = 'file-preview';
|
|
20
|
+
const MAX_AI_CONTEXT_CHARS = 50000;
|
|
21
|
+
const MAX_RAW_PARSE_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
22
|
+
const OFFICE_PREVIEWER_PLUGIN_NAMES = ['file-previewer-office', '@nocobase/plugin-file-previewer-office'];
|
|
23
|
+
|
|
24
|
+
export class PluginFilePreviewAuthServer extends Plugin {
|
|
25
|
+
private cache: any;
|
|
26
|
+
private ocrWorker: TesseractWorker;
|
|
27
|
+
|
|
28
|
+
async beforeLoad() {
|
|
29
|
+
await this.db.import({ directory: path.resolve(__dirname, 'collections') });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async load() {
|
|
33
|
+
await this.syncOcrResultCollection();
|
|
34
|
+
this.cache = await this.app.cacheManager.createCache({ name: 'file-preview-auth' });
|
|
35
|
+
this.ocrWorker = new TesseractWorker(this.app);
|
|
36
|
+
this.registerExcelParser();
|
|
37
|
+
this.registerAIWorkContext();
|
|
38
|
+
this.registerDownloadApi();
|
|
39
|
+
|
|
40
|
+
this.app.on('afterStart', async () => {
|
|
41
|
+
await this.disableBuiltinOfficePreviewer();
|
|
42
|
+
if (this.ocrWorker) {
|
|
43
|
+
await this.ocrWorker.start();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async afterEnable() {
|
|
49
|
+
await this.disableBuiltinOfficePreviewer();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async beforeDisable() {
|
|
53
|
+
if (this.ocrWorker) {
|
|
54
|
+
this.ocrWorker.stop();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async beforeDestroy() {
|
|
59
|
+
if (this.ocrWorker) {
|
|
60
|
+
this.ocrWorker.stop();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async syncOcrResultCollection() {
|
|
65
|
+
const collection = this.db.getCollection('attachmentOcrResults');
|
|
66
|
+
if (collection) {
|
|
67
|
+
await collection.sync();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Disable NocoBase's built-in Office previewer after plugins have loaded.
|
|
73
|
+
* This keeps this authenticated previewer as the active Office handler without causing a restart loop.
|
|
74
|
+
*/
|
|
75
|
+
private async disableBuiltinOfficePreviewer() {
|
|
76
|
+
try {
|
|
77
|
+
const pluginRepo = this.db.getRepository('applicationPlugins');
|
|
78
|
+
if (!pluginRepo) return;
|
|
79
|
+
|
|
80
|
+
for (const name of OFFICE_PREVIEWER_PLUGIN_NAMES) {
|
|
81
|
+
const record = await pluginRepo.findOne({ filter: { name } });
|
|
82
|
+
if (record && record.get('enabled')) {
|
|
83
|
+
await pluginRepo.update({
|
|
84
|
+
filter: { name },
|
|
85
|
+
values: { enabled: false },
|
|
86
|
+
});
|
|
87
|
+
this.log.info(`[FilePreviewAuth] Disabled built-in plugin "${name}" to avoid previewer conflicts.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
this.log.debug(`[FilePreviewAuth] Could not check file-previewer-office status: ${err?.message || err}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private registerDownloadApi() {
|
|
96
|
+
this.app.resourcer.define({
|
|
97
|
+
name: 'filePreviewAuth',
|
|
98
|
+
actions: {
|
|
99
|
+
getContent: async (ctx: any, next: any) => {
|
|
100
|
+
const uploaded = await this.consumeUploadedParseFile(ctx);
|
|
101
|
+
const params = ctx.action.params || {};
|
|
102
|
+
const values = params.values || {};
|
|
103
|
+
// NocoBase strips non-standard query parameters from ctx.action.params, so we check ctx.request.query and ctx.request.body
|
|
104
|
+
const reqQuery = ctx.request.query || {};
|
|
105
|
+
const reqBody = ctx.request.body || {};
|
|
106
|
+
|
|
107
|
+
const fileInput =
|
|
108
|
+
uploaded?.attachment || values.file || params.file || reqQuery.file || reqBody.file || params;
|
|
109
|
+
const attachment = uploaded
|
|
110
|
+
? await this.resolveAttachment(ctx, fileInput).catch(() => fileInput)
|
|
111
|
+
: await this.resolveAttachment(ctx, fileInput);
|
|
112
|
+
this.assertAuthenticated(ctx);
|
|
113
|
+
|
|
114
|
+
const cacheKey = `markitdown_parsed_text:${
|
|
115
|
+
attachment.id || attachment.key || attachment.url || attachment.path || uploaded?.cacheKey
|
|
116
|
+
}`;
|
|
117
|
+
let text = await this.cache.get(cacheKey);
|
|
118
|
+
|
|
119
|
+
if (text == null) {
|
|
120
|
+
text = uploaded
|
|
121
|
+
? await this.extractUploadedFileText(uploaded.buffer, attachment)
|
|
122
|
+
: await this.extractAttachmentText(ctx, attachment);
|
|
123
|
+
// Cache the extracted text for 1 day (86400000 ms)
|
|
124
|
+
// Even if empty string, we cache it so it doesn't repeatedly fail
|
|
125
|
+
await this.cache.set(cacheKey, text, 86400000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
ctx.body = {
|
|
129
|
+
filename: getAttachmentDisplayName(attachment),
|
|
130
|
+
mimetype: getAttachmentValue(attachment, 'mimetype') || '',
|
|
131
|
+
content: this.formatAttachmentWorkContext(attachment, text),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await next();
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
download: async (ctx: any, next: any) => {
|
|
138
|
+
const params = ctx.action.params || {};
|
|
139
|
+
const values = params.values || {};
|
|
140
|
+
const reqQuery = ctx.request.query || {};
|
|
141
|
+
const reqBody = ctx.request.body || {};
|
|
142
|
+
|
|
143
|
+
let fileInput = values.file || params.file || reqQuery.file || reqBody.file || {};
|
|
144
|
+
if (typeof fileInput === 'string') {
|
|
145
|
+
try {
|
|
146
|
+
fileInput = JSON.parse(fileInput);
|
|
147
|
+
} catch {
|
|
148
|
+
fileInput = { url: fileInput };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const rawUrl =
|
|
153
|
+
values.url ||
|
|
154
|
+
params.url ||
|
|
155
|
+
reqQuery.url ||
|
|
156
|
+
reqBody.url ||
|
|
157
|
+
fileInput.url ||
|
|
158
|
+
fileInput.preview ||
|
|
159
|
+
fileInput.path;
|
|
160
|
+
const requestedId = values.id || params.id || reqQuery.id || reqBody.id || fileInput.id || fileInput.uid;
|
|
161
|
+
if (!rawUrl && !requestedId) {
|
|
162
|
+
ctx.throw(400, 'url or id is required');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let url = rawUrl || '';
|
|
166
|
+
if (rawUrl) {
|
|
167
|
+
try {
|
|
168
|
+
url = decodeURIComponent(rawUrl);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// ignore
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const collection =
|
|
175
|
+
values.collection ||
|
|
176
|
+
values.collectionName ||
|
|
177
|
+
params.collection ||
|
|
178
|
+
params.collectionName ||
|
|
179
|
+
reqQuery.collection ||
|
|
180
|
+
reqQuery.collectionName ||
|
|
181
|
+
reqBody.collection ||
|
|
182
|
+
reqBody.collectionName ||
|
|
183
|
+
fileInput.collectionName;
|
|
184
|
+
const storageIdInput =
|
|
185
|
+
values.storageId || params.storageId || reqQuery.storageId || reqBody.storageId || fileInput.storageId;
|
|
186
|
+
let storageId = storageIdInput;
|
|
187
|
+
|
|
188
|
+
const fileManager = (this.pm.get('@nocobase/plugin-file-manager') || this.pm.get('file-manager')) as any;
|
|
189
|
+
if (!fileManager) {
|
|
190
|
+
ctx.throw(500, 'File manager plugin not found');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let filterByTk = null;
|
|
194
|
+
try {
|
|
195
|
+
// Parse the decoded URL to extract inner parameters
|
|
196
|
+
const parsedUrl = new URL(url, 'http://local');
|
|
197
|
+
filterByTk = parsedUrl.searchParams.get('filterByTk');
|
|
198
|
+
if (!storageId) {
|
|
199
|
+
storageId = parsedUrl.searchParams.get('storageId') || parsedUrl.searchParams.get('storage_id');
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// ignore
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let attachment = null;
|
|
206
|
+
let attachmentCollection = collection;
|
|
207
|
+
let isVirtual = false;
|
|
208
|
+
// Prioritize aiFiles since chat attachments are mostly aiFiles
|
|
209
|
+
const collectionsToTry = Array.from(new Set([collection, 'aiFiles', 'attachments'].filter(Boolean)));
|
|
210
|
+
|
|
211
|
+
this.log.debug(
|
|
212
|
+
`[FilePreviewAuth] Download input ${safeDebugJson({
|
|
213
|
+
requestedId: toDebugValue(requestedId),
|
|
214
|
+
collection,
|
|
215
|
+
hasRawUrl: Boolean(rawUrl),
|
|
216
|
+
urlPath: getDebugUrlPath(url),
|
|
217
|
+
filterByTk: toDebugValue(filterByTk),
|
|
218
|
+
storageIdInput: toDebugValue(storageIdInput),
|
|
219
|
+
parsedStorageId: toDebugValue(storageId),
|
|
220
|
+
fileInputKeys: getObjectKeys(fileInput),
|
|
221
|
+
storageCache: summarizeStorageCache(fileManager.storagesCache),
|
|
222
|
+
})}`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (requestedId || fileInput?.id || fileInput?.uid) {
|
|
226
|
+
try {
|
|
227
|
+
attachment = await this.resolveAttachment(ctx, {
|
|
228
|
+
...fileInput,
|
|
229
|
+
id: requestedId || fileInput.id,
|
|
230
|
+
uid: fileInput.uid,
|
|
231
|
+
url,
|
|
232
|
+
preview: fileInput.preview,
|
|
233
|
+
path: fileInput.path,
|
|
234
|
+
storageId,
|
|
235
|
+
collectionName: collection,
|
|
236
|
+
filename:
|
|
237
|
+
values.filename || params.filename || reqQuery.filename || reqBody.filename || fileInput.filename,
|
|
238
|
+
mimetype:
|
|
239
|
+
values.mimetype || params.mimetype || reqQuery.mimetype || reqBody.mimetype || fileInput.mimetype,
|
|
240
|
+
});
|
|
241
|
+
attachmentCollection =
|
|
242
|
+
attachment?.constructor?.collection?.name ||
|
|
243
|
+
collection ||
|
|
244
|
+
fileInput.collectionName ||
|
|
245
|
+
attachmentCollection;
|
|
246
|
+
} catch {
|
|
247
|
+
attachment = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!attachment && url) {
|
|
252
|
+
try {
|
|
253
|
+
attachment = await this.resolveAttachment(ctx, {
|
|
254
|
+
...fileInput,
|
|
255
|
+
url,
|
|
256
|
+
preview: fileInput.preview,
|
|
257
|
+
path: fileInput.path,
|
|
258
|
+
storageId,
|
|
259
|
+
collectionName: collection,
|
|
260
|
+
filename:
|
|
261
|
+
values.filename || params.filename || reqQuery.filename || reqBody.filename || fileInput.filename,
|
|
262
|
+
mimetype:
|
|
263
|
+
values.mimetype || params.mimetype || reqQuery.mimetype || reqBody.mimetype || fileInput.mimetype,
|
|
264
|
+
});
|
|
265
|
+
attachmentCollection =
|
|
266
|
+
attachment?.constructor?.collection?.name ||
|
|
267
|
+
collection ||
|
|
268
|
+
fileInput.collectionName ||
|
|
269
|
+
attachmentCollection;
|
|
270
|
+
} catch {
|
|
271
|
+
attachment = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!attachment) {
|
|
276
|
+
for (const colName of collectionsToTry) {
|
|
277
|
+
const repo = this.db.getRepository(colName);
|
|
278
|
+
if (repo) {
|
|
279
|
+
if (filterByTk) {
|
|
280
|
+
attachment = await repo.findOne({ filterByTk });
|
|
281
|
+
}
|
|
282
|
+
if (!attachment && requestedId) {
|
|
283
|
+
attachment = await repo.findOne({ filterByTk: requestedId });
|
|
284
|
+
}
|
|
285
|
+
if (!attachment && url) {
|
|
286
|
+
attachment = await repo.findOne({ filter: { url } });
|
|
287
|
+
}
|
|
288
|
+
if (attachment) {
|
|
289
|
+
attachmentCollection = colName;
|
|
290
|
+
// To prevent finding a record in 'attachments' when it actually belongs to 'aiFiles'
|
|
291
|
+
// and having permissions fail, if we found it in the wrong collection, we shouldn't break yet if url doesn't match?
|
|
292
|
+
// Actually, just break.
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Fallback: If DB query fails but we have storageId, construct virtual attachment
|
|
300
|
+
if (!attachment && storageId) {
|
|
301
|
+
attachment = {
|
|
302
|
+
...fileInput,
|
|
303
|
+
url,
|
|
304
|
+
storageId,
|
|
305
|
+
collectionName: collection || fileInput.collectionName,
|
|
306
|
+
filename:
|
|
307
|
+
values.filename ||
|
|
308
|
+
params.filename ||
|
|
309
|
+
reqQuery.filename ||
|
|
310
|
+
reqBody.filename ||
|
|
311
|
+
fileInput.filename ||
|
|
312
|
+
'file',
|
|
313
|
+
mimetype:
|
|
314
|
+
values.mimetype || params.mimetype || reqQuery.mimetype || reqBody.mimetype || fileInput.mimetype,
|
|
315
|
+
};
|
|
316
|
+
isVirtual = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!attachment) {
|
|
320
|
+
ctx.throw(404, 'Attachment not found for this URL');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.log.debug(
|
|
324
|
+
`[FilePreviewAuth] Attachment resolved ${safeDebugJson({
|
|
325
|
+
...summarizeAttachmentForLog(attachment, attachmentCollection),
|
|
326
|
+
isVirtual,
|
|
327
|
+
requestedCollection: collection,
|
|
328
|
+
})}`,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (!isVirtual) {
|
|
332
|
+
await this.assertCanAccessAttachment(ctx, attachment);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const attachmentObj = await this.prepareAttachmentForFileManager(
|
|
337
|
+
attachment,
|
|
338
|
+
fileManager,
|
|
339
|
+
attachmentCollection,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const storageModel = getStorageFromCache(fileManager.storagesCache, attachmentObj.storageId);
|
|
343
|
+
this.log.debug(
|
|
344
|
+
`[FilePreviewAuth] Attachment prepared for stream ${safeDebugJson({
|
|
345
|
+
...summarizeAttachmentForLog(attachmentObj, attachmentCollection),
|
|
346
|
+
storageModel: summarizeStorageForLog(storageModel),
|
|
347
|
+
storageCache: summarizeStorageCache(fileManager.storagesCache),
|
|
348
|
+
})}`,
|
|
349
|
+
);
|
|
350
|
+
// S3 Private Bucket Handler
|
|
351
|
+
if (
|
|
352
|
+
storageModel &&
|
|
353
|
+
(storageModel.type === 's3' || storageModel.type === 'aws-s3' || storageModel.type === 's3-private')
|
|
354
|
+
) {
|
|
355
|
+
const StorageTypeClass = fileManager.storageTypes.get(storageModel.type);
|
|
356
|
+
const storageInstance = new StorageTypeClass(storageModel);
|
|
357
|
+
const s3Client =
|
|
358
|
+
storageInstance.client ||
|
|
359
|
+
(typeof storageInstance.getS3Client === 'function' ? storageInstance.getS3Client() : null);
|
|
360
|
+
if (s3Client) {
|
|
361
|
+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
362
|
+
const key = storageInstance.getFileKey(attachmentObj);
|
|
363
|
+
const getCommand = new GetObjectCommand({
|
|
364
|
+
Bucket: storageModel.options.bucket,
|
|
365
|
+
Key: key,
|
|
366
|
+
});
|
|
367
|
+
const response = await s3Client.send(getCommand);
|
|
368
|
+
ctx.type = response.ContentType || attachmentObj.mimetype || 'application/octet-stream';
|
|
369
|
+
ctx.attachment(attachmentObj.filename);
|
|
370
|
+
// The AWS SDK Body is a readable stream in Node.js
|
|
371
|
+
ctx.body = response.Body;
|
|
372
|
+
await next();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Local storage / Other storage fallback
|
|
378
|
+
const { stream, contentType } = await fileManager.getFileStream(attachmentObj);
|
|
379
|
+
ctx.type = contentType || attachmentObj.mimetype || 'application/octet-stream';
|
|
380
|
+
ctx.attachment(attachmentObj.filename);
|
|
381
|
+
ctx.body = stream;
|
|
382
|
+
// S3 Private Bucket Handler
|
|
383
|
+
} catch (err) {
|
|
384
|
+
this.log.error(`[FilePreviewAuth] Error fetching stream for URL ${url}: ${err.message}`);
|
|
385
|
+
ctx.throw(500, 'Failed to fetch the file from storage');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await next();
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
runOcr: async (ctx: any, next: any) => {
|
|
392
|
+
const params = ctx.action.params || {};
|
|
393
|
+
const reqBody = ctx.request.body || {};
|
|
394
|
+
const values = params.values || {};
|
|
395
|
+
const rawAttachmentId = values.attachmentId || reqBody.attachmentId;
|
|
396
|
+
|
|
397
|
+
if (!rawAttachmentId) {
|
|
398
|
+
ctx.throw(400, 'attachmentId is required');
|
|
399
|
+
}
|
|
400
|
+
const attachmentId = normalizeOcrAttachmentId(rawAttachmentId);
|
|
401
|
+
if (!attachmentId) {
|
|
402
|
+
ctx.throw(400, 'attachmentId must be a numeric attachment record id');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.assertAuthenticated(ctx);
|
|
406
|
+
|
|
407
|
+
const repo = ctx.db.getRepository('attachments');
|
|
408
|
+
const attachment = await repo.findOne({ filterByTk: attachmentId });
|
|
409
|
+
if (!attachment) {
|
|
410
|
+
ctx.throw(404, 'Attachment not found');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await this.assertCanAccessAttachment(ctx, attachment);
|
|
414
|
+
|
|
415
|
+
// Cập nhật trạng thái sang pending-ocr
|
|
416
|
+
const ocrRecord = await this.upsertOcrResult(attachmentId, {
|
|
417
|
+
status: 'pending-ocr',
|
|
418
|
+
error: null,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Đẩy job vào worker xử lý nền
|
|
422
|
+
await this.ocrWorker.enqueue(attachmentId);
|
|
423
|
+
|
|
424
|
+
ctx.body = {
|
|
425
|
+
ok: true,
|
|
426
|
+
data: this.serializeOcrResult(ocrRecord, attachmentId),
|
|
427
|
+
};
|
|
428
|
+
await next();
|
|
429
|
+
},
|
|
430
|
+
getOcrStatus: async (ctx: any, next: any) => {
|
|
431
|
+
const params = ctx.action.params || {};
|
|
432
|
+
const reqQuery = ctx.request.query || {};
|
|
433
|
+
const reqBody = ctx.request.body || {};
|
|
434
|
+
const values = params.values || {};
|
|
435
|
+
const rawAttachmentId =
|
|
436
|
+
values.attachmentId || params.attachmentId || reqQuery.attachmentId || reqBody.attachmentId;
|
|
437
|
+
|
|
438
|
+
if (!rawAttachmentId) {
|
|
439
|
+
ctx.throw(400, 'attachmentId is required');
|
|
440
|
+
}
|
|
441
|
+
const attachmentId = normalizeOcrAttachmentId(rawAttachmentId);
|
|
442
|
+
if (!attachmentId) {
|
|
443
|
+
ctx.throw(400, 'attachmentId must be a numeric attachment record id');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
this.assertAuthenticated(ctx);
|
|
447
|
+
|
|
448
|
+
const repo = ctx.db.getRepository('attachments');
|
|
449
|
+
const attachment = await repo.findOne({ filterByTk: attachmentId });
|
|
450
|
+
if (!attachment) {
|
|
451
|
+
ctx.throw(404, 'Attachment not found');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await this.assertCanAccessAttachment(ctx, attachment);
|
|
455
|
+
|
|
456
|
+
const ocrRecord = await this.getOcrResultByAttachmentId(attachmentId);
|
|
457
|
+
ctx.body = {
|
|
458
|
+
data: this.serializeOcrResult(ocrRecord, attachmentId),
|
|
459
|
+
};
|
|
460
|
+
await next();
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
this.app.acl.allow('filePreviewAuth', ['download', 'getContent', 'runOcr', 'getOcrStatus'], 'loggedIn');
|
|
465
|
+
this.app.acl.allow('attachmentOcrResults', ['get', 'list', 'create', 'update'], 'loggedIn');
|
|
466
|
+
this.log.info('[FilePreviewAuth] Registered /api/filePreviewAuth:download & runOcr endpoints');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async getOcrResultByAttachmentId(attachmentId: number | string) {
|
|
470
|
+
const repo = this.db.getRepository('attachmentOcrResults');
|
|
471
|
+
if (!repo) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return repo.findOne({
|
|
476
|
+
filter: {
|
|
477
|
+
attachmentId,
|
|
478
|
+
},
|
|
479
|
+
appends: ['attachment'],
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async upsertOcrResult(attachmentId: number | string, values: Record<string, any>) {
|
|
484
|
+
const repo = this.db.getRepository('attachmentOcrResults');
|
|
485
|
+
if (!repo) {
|
|
486
|
+
throw new Error('attachmentOcrResults repository not found');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const existing = await repo.findOne({
|
|
490
|
+
filter: {
|
|
491
|
+
attachmentId,
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const nextValues = {
|
|
495
|
+
attachmentId,
|
|
496
|
+
...values,
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
if (existing) {
|
|
500
|
+
await repo.update({
|
|
501
|
+
filterByTk: existing.get('id'),
|
|
502
|
+
values: nextValues,
|
|
503
|
+
});
|
|
504
|
+
return repo.findOne({
|
|
505
|
+
filterByTk: existing.get('id'),
|
|
506
|
+
appends: ['attachment'],
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const created = await repo.create({
|
|
511
|
+
values: nextValues,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return repo.findOne({
|
|
515
|
+
filterByTk: created.get('id'),
|
|
516
|
+
appends: ['attachment'],
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private serializeOcrResult(record: any, attachmentId: number | string) {
|
|
521
|
+
if (!record) {
|
|
522
|
+
return {
|
|
523
|
+
attachmentId,
|
|
524
|
+
status: 'no-ocr',
|
|
525
|
+
data: null,
|
|
526
|
+
error: null,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const json = typeof record.toJSON === 'function' ? record.toJSON() : record;
|
|
531
|
+
return {
|
|
532
|
+
id: json.id,
|
|
533
|
+
attachmentId: json.attachmentId ?? attachmentId,
|
|
534
|
+
status: json.status || 'no-ocr',
|
|
535
|
+
data: json.data || null,
|
|
536
|
+
error: json.error || null,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private registerAIWorkContext() {
|
|
541
|
+
let aiPlugin: any;
|
|
542
|
+
try {
|
|
543
|
+
aiPlugin = this.pm.get('ai') || this.pm.get('@nocobase/plugin-ai');
|
|
544
|
+
} catch {
|
|
545
|
+
aiPlugin = null;
|
|
546
|
+
}
|
|
547
|
+
if (!aiPlugin?.workContextHandler?.registerStrategy) {
|
|
548
|
+
this.log.debug('[FilePreviewAuth] plugin-ai not found - file preview AI context skipped');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
aiPlugin.workContextHandler.registerStrategy(FILE_PREVIEW_WORK_CONTEXT_TYPE, {
|
|
554
|
+
resolve: async (ctx: any, contextItem: any) => {
|
|
555
|
+
const file = contextItem?.content?.file || contextItem?.content || contextItem;
|
|
556
|
+
const attachment = await this.resolveAttachment(ctx, file);
|
|
557
|
+
this.assertAuthenticated(ctx);
|
|
558
|
+
const text = await this.extractAttachmentText(ctx, attachment);
|
|
559
|
+
return this.formatAttachmentWorkContext(attachment, text);
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
this.log.info('[FilePreviewAuth] AI file-preview work context registered');
|
|
563
|
+
} catch (err) {
|
|
564
|
+
this.log.warn(`[FilePreviewAuth] AI file-preview work context registration skipped: ${err}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private async resolveAttachment(ctx: any, input: any) {
|
|
569
|
+
const file = input?.file || input || {};
|
|
570
|
+
const collectionNames = [file.collectionName, 'attachments', 'aiFiles'].filter(Boolean);
|
|
571
|
+
|
|
572
|
+
const ids = [file.id, file.uid].filter((value) => isLikelyRecordId(value));
|
|
573
|
+
for (const collectionName of collectionNames) {
|
|
574
|
+
if (!ctx.db.getCollection(collectionName)) continue;
|
|
575
|
+
const repo = ctx.db.getRepository(collectionName);
|
|
576
|
+
for (const id of ids) {
|
|
577
|
+
const record = await repo.findOne({ filter: { id } });
|
|
578
|
+
if (record) return record;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const urlCandidates = getUrlCandidates(file.url || file.preview || file.path);
|
|
583
|
+
for (const collectionName of collectionNames) {
|
|
584
|
+
if (!ctx.db.getCollection(collectionName)) continue;
|
|
585
|
+
const repo = ctx.db.getRepository(collectionName);
|
|
586
|
+
for (const url of urlCandidates) {
|
|
587
|
+
const record = await repo.findOne({ filter: { url } });
|
|
588
|
+
if (record) return record;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Try finding by filename since dynamic virtual url columns may not be searchable in DB
|
|
593
|
+
for (const collectionName of collectionNames) {
|
|
594
|
+
if (!ctx.db.getCollection(collectionName)) continue;
|
|
595
|
+
const repo = ctx.db.getRepository(collectionName);
|
|
596
|
+
for (const urlCandidate of urlCandidates) {
|
|
597
|
+
const cleanUrl = urlCandidate.split('?')[0];
|
|
598
|
+
const filename = path.basename(cleanUrl);
|
|
599
|
+
if (filename && filename !== 'file' && filename.includes('.')) {
|
|
600
|
+
const filter: any = { filename };
|
|
601
|
+
if (file.storageId) {
|
|
602
|
+
filter.storageId = file.storageId;
|
|
603
|
+
}
|
|
604
|
+
const record = await repo.findOne({ filter });
|
|
605
|
+
if (record) return record;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Fallback for files from plugin-external-storage (which might not exist in attachments collection)
|
|
611
|
+
if ((file.storageId || file.url?.includes('extStorage:download')) && (file.url || file.path || file.key)) {
|
|
612
|
+
return file;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
ctx.throw(404, 'Attachment not found for this preview file');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private getParentCollections(fileCollectionName: string) {
|
|
619
|
+
const parents: Array<{
|
|
620
|
+
collectionName: string;
|
|
621
|
+
throughTable: string;
|
|
622
|
+
otherKey: string;
|
|
623
|
+
foreignKey?: string;
|
|
624
|
+
}> = [];
|
|
625
|
+
|
|
626
|
+
for (const collection of this.db.collections.values()) {
|
|
627
|
+
if (collection.name === fileCollectionName) continue;
|
|
628
|
+
|
|
629
|
+
for (const field of collection.fields.values()) {
|
|
630
|
+
const options = field.options || {};
|
|
631
|
+
if (field.type === 'belongsToMany' && options.target === fileCollectionName && options.through) {
|
|
632
|
+
parents.push({
|
|
633
|
+
collectionName: collection.name,
|
|
634
|
+
throughTable: options.through,
|
|
635
|
+
otherKey: options.otherKey || 'attachmentId',
|
|
636
|
+
foreignKey: options.foreignKey || `${collection.model.name.toLowerCase()}Id`,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return parents;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async checkParentCollectionAccess(
|
|
646
|
+
attachmentId: number | string,
|
|
647
|
+
fileCollectionName: string,
|
|
648
|
+
currentRoles: string[],
|
|
649
|
+
ctx?: any,
|
|
650
|
+
): Promise<boolean> {
|
|
651
|
+
const parents = this.getParentCollections(fileCollectionName);
|
|
652
|
+
|
|
653
|
+
if (parents.length === 0) {
|
|
654
|
+
const canView = this.app.acl.can({
|
|
655
|
+
roles: currentRoles,
|
|
656
|
+
resource: fileCollectionName,
|
|
657
|
+
action: 'view',
|
|
658
|
+
});
|
|
659
|
+
return !!canView;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const parent of parents) {
|
|
663
|
+
const canView = this.app.acl.can({
|
|
664
|
+
roles: currentRoles,
|
|
665
|
+
resource: parent.collectionName,
|
|
666
|
+
action: 'view',
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
if (!canView) continue;
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const throughCollection = this.db.getCollection(parent.throughTable);
|
|
673
|
+
if (throughCollection) {
|
|
674
|
+
const links = await throughCollection.repository.find({
|
|
675
|
+
filter: { [parent.otherKey]: attachmentId },
|
|
676
|
+
});
|
|
677
|
+
if (links.length > 0) {
|
|
678
|
+
const parentIds = links.map((l) => l.get(parent['foreignKey'])).filter(Boolean);
|
|
679
|
+
if (parentIds.length > 0) {
|
|
680
|
+
let dataScopeFilter = canView.params?.filter || {};
|
|
681
|
+
if (ctx && ctx.app.environment) {
|
|
682
|
+
dataScopeFilter = ctx.app.environment.renderJsonTemplate(dataScopeFilter, {
|
|
683
|
+
$user: ctx.state.currentUser?.toJSON ? ctx.state.currentUser.toJSON() : ctx.state.currentUser,
|
|
684
|
+
$nRole: ctx.state.currentRole,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const parentCollection = this.db.getCollection(parent.collectionName);
|
|
689
|
+
const pk = parentCollection?.model?.primaryKeyAttribute || 'id';
|
|
690
|
+
const count = await parentCollection.repository.count({
|
|
691
|
+
filter: {
|
|
692
|
+
$and: [{ [pk]: { $in: parentIds } }, dataScopeFilter],
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
if (count > 0) return true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch (error) {
|
|
700
|
+
this.log.warn(`[FilePreviewAuth] Failed to query through table "${parent.throughTable}":`, error.message);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async assertCanAccessAttachment(ctx: any, attachment: any) {
|
|
708
|
+
const currentUser = this.assertAuthenticated(ctx);
|
|
709
|
+
|
|
710
|
+
const createdById = getAttachmentValue(attachment, 'createdById');
|
|
711
|
+
const currentRoles = ctx.state.currentRoles || [];
|
|
712
|
+
const userRoles = currentUser.roles || [];
|
|
713
|
+
const isOwner = createdById != null && String(createdById) === String(currentUser.id);
|
|
714
|
+
const isAdmin =
|
|
715
|
+
currentRoles.includes('root') ||
|
|
716
|
+
currentRoles.includes('admin') ||
|
|
717
|
+
userRoles.some(
|
|
718
|
+
(role: any) => role === 'root' || role === 'admin' || role?.name === 'root' || role?.name === 'admin',
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
if (isOwner || isAdmin) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Delegate to parent collection ACL checks
|
|
726
|
+
const attachmentId = getAttachmentValue(attachment, 'id');
|
|
727
|
+
if (!attachmentId) {
|
|
728
|
+
ctx.throw(403, 'Permission denied: virtual attachment cannot be accessed');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const collectionName =
|
|
732
|
+
attachment.constructor?.collection?.name || getAttachmentValue(attachment, 'collectionName') || 'attachments';
|
|
733
|
+
const hasParentAccess = await this.checkParentCollectionAccess(attachmentId, collectionName, currentRoles, ctx);
|
|
734
|
+
if (!hasParentAccess) {
|
|
735
|
+
ctx.throw(403, 'Permission denied: you do not have permission to access this attachment');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private assertAuthenticated(ctx: any) {
|
|
740
|
+
const currentUser = ctx.state.currentUser;
|
|
741
|
+
if (!currentUser) {
|
|
742
|
+
ctx.throw(401, 'Unauthorized');
|
|
743
|
+
}
|
|
744
|
+
return currentUser;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private async consumeUploadedParseFile(
|
|
748
|
+
ctx: any,
|
|
749
|
+
): Promise<{ buffer: Buffer; attachment: any; cacheKey: string } | null> {
|
|
750
|
+
if (!ctx.request?.is?.('multipart/*')) {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const upload = (multer as any)({
|
|
755
|
+
dest: os.tmpdir(),
|
|
756
|
+
limits: { fileSize: MAX_RAW_PARSE_UPLOAD_BYTES },
|
|
757
|
+
}).single('file');
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
await upload(ctx, () => {});
|
|
761
|
+
} catch (err: any) {
|
|
762
|
+
ctx.throw(400, err?.message || 'Upload parsing error');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const file = ctx.file || ctx.request?.file;
|
|
766
|
+
if (!file?.path) {
|
|
767
|
+
ctx.throw(400, 'file is required');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let buffer: Buffer;
|
|
771
|
+
try {
|
|
772
|
+
buffer = await readFile(file.path);
|
|
773
|
+
} finally {
|
|
774
|
+
unlink(file.path).catch(() => {});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const body = ctx.request.body || {};
|
|
778
|
+
let attachment: any = {};
|
|
779
|
+
if (body.attachment) {
|
|
780
|
+
try {
|
|
781
|
+
attachment = JSON.parse(body.attachment);
|
|
782
|
+
} catch {
|
|
783
|
+
attachment = {};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
attachment = {
|
|
788
|
+
...attachment,
|
|
789
|
+
filename: attachment.filename || file.originalname,
|
|
790
|
+
name: attachment.name || attachment.filename || file.originalname,
|
|
791
|
+
mimetype: attachment.mimetype || file.mimetype,
|
|
792
|
+
size: attachment.size || file.size,
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
buffer,
|
|
797
|
+
attachment,
|
|
798
|
+
cacheKey: [
|
|
799
|
+
attachment.id ||
|
|
800
|
+
attachment.uid ||
|
|
801
|
+
attachment.url ||
|
|
802
|
+
attachment.path ||
|
|
803
|
+
file.originalname ||
|
|
804
|
+
attachment.filename ||
|
|
805
|
+
'file',
|
|
806
|
+
attachment.lastModified || '',
|
|
807
|
+
file.size || buffer.length,
|
|
808
|
+
].join(':'),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private async extractUploadedFileText(buffer: Buffer, attachment: any): Promise<string> {
|
|
813
|
+
const markitdownPlugin = this.getMarkItDownParserPlugin();
|
|
814
|
+
const service = markitdownPlugin?.service;
|
|
815
|
+
if (service?.convertBuffer) {
|
|
816
|
+
try {
|
|
817
|
+
if (!service.supports || service.supports(attachment)) {
|
|
818
|
+
const text = await service.convertBuffer(buffer, attachment);
|
|
819
|
+
if (text?.trim()) {
|
|
820
|
+
return text;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} catch (err) {
|
|
824
|
+
this.log.warn(`[FilePreviewAuth] MarkItDown parser failed: ${err}`);
|
|
825
|
+
}
|
|
826
|
+
} else {
|
|
827
|
+
this.log.warn(
|
|
828
|
+
'[FilePreviewAuth] plugin-markitdown-parser not found; uploaded raw text parsing fallback will be used',
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (isPlainTextAttachment(attachment)) {
|
|
833
|
+
return buffer.toString('utf-8');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return '';
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private getMarkItDownParserPlugin(): any | null {
|
|
840
|
+
const candidates = ['@nocobase/plugin-markitdown-parser', 'plugin-markitdown-parser', 'markitdown-parser'];
|
|
841
|
+
for (const name of candidates) {
|
|
842
|
+
try {
|
|
843
|
+
const plugin = this.pm.get(name) as any;
|
|
844
|
+
if (plugin?.service?.convertBuffer) {
|
|
845
|
+
return plugin;
|
|
846
|
+
}
|
|
847
|
+
} catch {
|
|
848
|
+
// Try the next known plugin name.
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private async extractAttachmentText(ctx: any, attachment: any): Promise<string> {
|
|
855
|
+
const docParserPlugin = (this.pm.get('@nocobase/plugin-document-parser') ||
|
|
856
|
+
this.pm.get('plugin-document-parser')) as any;
|
|
857
|
+
if (docParserPlugin?.internalParserRegistry) {
|
|
858
|
+
try {
|
|
859
|
+
const result = await docParserPlugin.internalParserRegistry.parse(attachment, ctx);
|
|
860
|
+
if (result?.handled && result.text?.trim()) {
|
|
861
|
+
return result.text;
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
this.log.warn(`[FilePreviewAuth] Document parser failed: ${err}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (isPlainTextAttachment(attachment)) {
|
|
869
|
+
return await this.readAttachmentAsText(attachment);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return '';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async readAttachmentAsText(attachment: any): Promise<string> {
|
|
876
|
+
const fileManager = (this.pm.get('@nocobase/plugin-file-manager') || this.pm.get('file-manager')) as any;
|
|
877
|
+
if (!fileManager?.getFileStream) {
|
|
878
|
+
return '';
|
|
879
|
+
}
|
|
880
|
+
const attachmentObj = await this.prepareAttachmentForFileManager(attachment, fileManager);
|
|
881
|
+
|
|
882
|
+
const { stream } = await fileManager.getFileStream(attachmentObj);
|
|
883
|
+
const chunks: Buffer[] = [];
|
|
884
|
+
for await (const chunk of stream) {
|
|
885
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
886
|
+
}
|
|
887
|
+
return Buffer.concat(chunks as any[]).toString('utf-8');
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private async prepareAttachmentForFileManager(attachment: any, fileManager: any, recordCollection?: any) {
|
|
891
|
+
const attachmentObj = typeof attachment.toJSON === 'function' ? attachment.toJSON() : { ...attachment };
|
|
892
|
+
const collection = this.resolveCollection(
|
|
893
|
+
recordCollection || attachmentObj.collectionName || attachment.collectionName,
|
|
894
|
+
);
|
|
895
|
+
const storageId = await this.resolveStorageId(attachment, attachmentObj, fileManager, collection);
|
|
896
|
+
if (!isMissingFileValue(storageId)) {
|
|
897
|
+
attachmentObj.storageId = storageId;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
this.copyFileFieldsFromRecord(attachment, attachmentObj);
|
|
901
|
+
await this.ensureFileFields(attachment, attachmentObj, collection);
|
|
902
|
+
this.copyFileFieldsFromRecord(attachment, attachmentObj);
|
|
903
|
+
|
|
904
|
+
// Extract filename and path relative to storage.baseUrl if they are missing/invalid in the attachment object
|
|
905
|
+
if (attachmentObj.storageId && attachmentObj.url) {
|
|
906
|
+
const storageModel = getStorageFromCache(fileManager.storagesCache, attachmentObj.storageId);
|
|
907
|
+
if (storageModel) {
|
|
908
|
+
const baseUrl = storageModel.baseUrl || '';
|
|
909
|
+
let relativeUrl = attachmentObj.url.split('?')[0];
|
|
910
|
+
if (baseUrl && relativeUrl.includes(baseUrl)) {
|
|
911
|
+
relativeUrl = relativeUrl.substring(relativeUrl.indexOf(baseUrl) + baseUrl.length);
|
|
912
|
+
}
|
|
913
|
+
relativeUrl = relativeUrl.replace(/^\/|\/$/g, '');
|
|
914
|
+
if (relativeUrl) {
|
|
915
|
+
const parts = relativeUrl.split('/');
|
|
916
|
+
const filename = parts.pop();
|
|
917
|
+
const filePath = parts.join('/');
|
|
918
|
+
if (filename && (!attachmentObj.filename || attachmentObj.filename === 'file')) {
|
|
919
|
+
attachmentObj.filename = filename;
|
|
920
|
+
}
|
|
921
|
+
if (filePath && !attachmentObj.path) {
|
|
922
|
+
attachmentObj.path = filePath;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return attachmentObj;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private resolveCollection(recordCollection: any) {
|
|
932
|
+
if (!recordCollection) {
|
|
933
|
+
return undefined;
|
|
934
|
+
}
|
|
935
|
+
if (typeof recordCollection === 'string') {
|
|
936
|
+
return this.db.getCollection(recordCollection);
|
|
937
|
+
}
|
|
938
|
+
return recordCollection;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private async resolveStorageId(record: any, recordObj: any, fileManager: any, recordCollection?: any) {
|
|
942
|
+
const collectionName = recordCollection?.name || recordObj?.collectionName || record?.collectionName;
|
|
943
|
+
const rawStorageId =
|
|
944
|
+
getRecordStorageId(record) ??
|
|
945
|
+
recordObj?.storageId ??
|
|
946
|
+
recordObj?.storage_id ??
|
|
947
|
+
recordObj?.storage?.id ??
|
|
948
|
+
recordObj?.storage?.filterByTk;
|
|
949
|
+
|
|
950
|
+
const matchedCacheKey = findStorageCacheKey(fileManager?.storagesCache, rawStorageId);
|
|
951
|
+
if (!isMissingFileValue(matchedCacheKey)) {
|
|
952
|
+
this.log.debug(
|
|
953
|
+
`[FilePreviewAuth] storageId resolved from cache ${safeDebugJson({
|
|
954
|
+
collection: collectionName,
|
|
955
|
+
record: summarizeAttachmentForLog(recordObj, collectionName),
|
|
956
|
+
rawStorageId: toDebugValue(rawStorageId),
|
|
957
|
+
matchedCacheKey: toDebugValue(matchedCacheKey),
|
|
958
|
+
})}`,
|
|
959
|
+
);
|
|
960
|
+
return matchedCacheKey;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const storageName = recordObj?.storage?.name || recordObj?.storageName;
|
|
964
|
+
if (storageName && fileManager?.storagesCache) {
|
|
965
|
+
for (const [key, storage] of fileManager.storagesCache.entries()) {
|
|
966
|
+
if (storage?.name === storageName) {
|
|
967
|
+
this.log.debug(
|
|
968
|
+
`[FilePreviewAuth] storageId resolved by storage name ${safeDebugJson({
|
|
969
|
+
collection: collectionName,
|
|
970
|
+
storageName,
|
|
971
|
+
matchedCacheKey: toDebugValue(key),
|
|
972
|
+
storage: summarizeStorageForLog(storage),
|
|
973
|
+
})}`,
|
|
974
|
+
);
|
|
975
|
+
return key;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const dbStorageId = await this.resolveStorageIdFromRecordTable(record, recordCollection);
|
|
981
|
+
const matchedDbCacheKey = findStorageCacheKey(fileManager?.storagesCache, dbStorageId);
|
|
982
|
+
if (!isMissingFileValue(matchedDbCacheKey)) {
|
|
983
|
+
this.log.debug(
|
|
984
|
+
`[FilePreviewAuth] storageId resolved from DB column and cache ${safeDebugJson({
|
|
985
|
+
collection: collectionName,
|
|
986
|
+
dbStorageId: toDebugValue(dbStorageId),
|
|
987
|
+
matchedCacheKey: toDebugValue(matchedDbCacheKey),
|
|
988
|
+
})}`,
|
|
989
|
+
);
|
|
990
|
+
return matchedDbCacheKey;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const storageId = !isMissingFileValue(dbStorageId) ? dbStorageId : rawStorageId;
|
|
994
|
+
if (isMissingFileValue(storageId)) {
|
|
995
|
+
const defaultStorageKey = findDefaultStorageCacheKey(fileManager?.storagesCache);
|
|
996
|
+
this.log.debug(
|
|
997
|
+
`[FilePreviewAuth] storageId missing; using default storage fallback ${safeDebugJson({
|
|
998
|
+
collection: collectionName,
|
|
999
|
+
defaultStorageKey: toDebugValue(defaultStorageKey),
|
|
1000
|
+
storageCache: summarizeStorageCache(fileManager?.storagesCache),
|
|
1001
|
+
})}`,
|
|
1002
|
+
);
|
|
1003
|
+
return defaultStorageKey;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const storageRepo = this.db.getRepository('storages');
|
|
1007
|
+
const storage = await storageRepo.findOne({ filterByTk: storageId });
|
|
1008
|
+
if (!storage) {
|
|
1009
|
+
this.log.debug(
|
|
1010
|
+
`[FilePreviewAuth] storageId not found in storages table; returning raw value ${safeDebugJson({
|
|
1011
|
+
collection: collectionName,
|
|
1012
|
+
storageId: toDebugValue(storageId),
|
|
1013
|
+
})}`,
|
|
1014
|
+
);
|
|
1015
|
+
return storageId;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const parsedStorage =
|
|
1019
|
+
typeof fileManager?.parseStorage === 'function' ? fileManager.parseStorage(storage) : storage.toJSON();
|
|
1020
|
+
fileManager?.storagesCache?.set?.(storage.get('id'), parsedStorage);
|
|
1021
|
+
this.log.debug(
|
|
1022
|
+
`[FilePreviewAuth] storageId resolved from storages table ${safeDebugJson({
|
|
1023
|
+
collection: collectionName,
|
|
1024
|
+
requestedStorageId: toDebugValue(storageId),
|
|
1025
|
+
resolvedStorageId: toDebugValue(storage.get('id')),
|
|
1026
|
+
storage: summarizeStorageForLog(parsedStorage),
|
|
1027
|
+
})}`,
|
|
1028
|
+
);
|
|
1029
|
+
return storage.get('id');
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private async resolveStorageIdFromRecordTable(record: any, recordCollection?: any) {
|
|
1033
|
+
const collection = recordCollection || record?.constructor?.collection;
|
|
1034
|
+
if (!collection?.model) {
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const primaryKey = collection.model.primaryKeyAttribute || 'id';
|
|
1039
|
+
const recordId = record.get?.(primaryKey) ?? record.get?.('id') ?? record[primaryKey] ?? record.id;
|
|
1040
|
+
if (isMissingFileValue(recordId)) {
|
|
1041
|
+
return undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
let columns: Record<string, unknown>;
|
|
1045
|
+
try {
|
|
1046
|
+
columns = await this.db.sequelize.getQueryInterface().describeTable(collection.getTableNameWithSchema());
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
this.log.warn(`[FilePreviewAuth] Failed to inspect table "${collection.name}" for storageId:`, error.message);
|
|
1049
|
+
return undefined;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const rawAttributes = collection.model.rawAttributes || {};
|
|
1053
|
+
const storageColumn = findExistingColumn(columns, [
|
|
1054
|
+
rawAttributes.storageId?.field,
|
|
1055
|
+
rawAttributes.storage_id?.field,
|
|
1056
|
+
'storageId',
|
|
1057
|
+
'storage_id',
|
|
1058
|
+
'storageid',
|
|
1059
|
+
]);
|
|
1060
|
+
if (!storageColumn) {
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const result = await collection.model.findOne({
|
|
1065
|
+
attributes: [[col(storageColumn), 'storageId']],
|
|
1066
|
+
where: { [primaryKey]: recordId },
|
|
1067
|
+
raw: true,
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
this.log.debug(
|
|
1071
|
+
`[FilePreviewAuth] storageId DB lookup ${safeDebugJson({
|
|
1072
|
+
collection: collection.name,
|
|
1073
|
+
table: String(collection.getTableNameWithSchema()),
|
|
1074
|
+
primaryKey,
|
|
1075
|
+
recordId: toDebugValue(recordId),
|
|
1076
|
+
storageColumn,
|
|
1077
|
+
dbStorageId: toDebugValue(result?.['storageId']),
|
|
1078
|
+
})}`,
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
return result?.['storageId'];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private async ensureFileFields(record: any, recordObj: any, recordCollection?: any) {
|
|
1085
|
+
const fileFields = ['key', 'filename', 'path', 'mimetype', 'title', 'extname', 'url'];
|
|
1086
|
+
const needsFileKey = !hasText(recordObj.key) && !hasText(recordObj.filename) && !hasText(recordObj.url);
|
|
1087
|
+
const missingFields = fileFields.filter((field) => isMissingFileValue(recordObj[field]));
|
|
1088
|
+
if (!needsFileKey && missingFields.length === 0) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const fileData = await this.resolveFileFieldsFromRecordTable(record, fileFields, recordCollection);
|
|
1093
|
+
for (const field of fileFields) {
|
|
1094
|
+
if (!isMissingFileValue(fileData[field])) {
|
|
1095
|
+
recordObj[field] = fileData[field];
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private copyFileFieldsFromRecord(record: any, recordObj: any) {
|
|
1101
|
+
for (const field of ['key', 'filename', 'path', 'mimetype', 'title', 'extname', 'url']) {
|
|
1102
|
+
if (!isMissingFileValue(recordObj[field])) {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const value = record.get?.(field) ?? record.getDataValue?.(field) ?? record[field];
|
|
1107
|
+
if (!isMissingFileValue(value)) {
|
|
1108
|
+
recordObj[field] = value;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private async resolveFileFieldsFromRecordTable(record: any, fields: string[], recordCollection?: any) {
|
|
1114
|
+
const collection = recordCollection || record?.constructor?.collection;
|
|
1115
|
+
if (!collection?.model) {
|
|
1116
|
+
return {};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const primaryKey = collection.model.primaryKeyAttribute || 'id';
|
|
1120
|
+
const recordId = record.get?.(primaryKey) ?? record.get?.('id') ?? record[primaryKey] ?? record.id;
|
|
1121
|
+
if (isMissingFileValue(recordId)) {
|
|
1122
|
+
return {};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const rawAttributes = collection.model.rawAttributes || {};
|
|
1126
|
+
const attributes = fields
|
|
1127
|
+
.filter((field) => rawAttributes[field])
|
|
1128
|
+
.map((field) => [col(rawAttributes[field].field || field), field]);
|
|
1129
|
+
|
|
1130
|
+
if (attributes.length === 0) {
|
|
1131
|
+
return {};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const result = await collection.model.findOne({
|
|
1135
|
+
attributes,
|
|
1136
|
+
where: { [primaryKey]: recordId },
|
|
1137
|
+
raw: true,
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
return result || {};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private formatAttachmentWorkContext(attachment: any, text: string): string {
|
|
1144
|
+
const filename = sanitizeXmlAttr(getAttachmentDisplayName(attachment));
|
|
1145
|
+
const mimetype = sanitizeXmlAttr(getAttachmentValue(attachment, 'mimetype') || '');
|
|
1146
|
+
const extname = sanitizeXmlAttr(getAttachmentValue(attachment, 'extname') || '');
|
|
1147
|
+
const size = sanitizeXmlAttr(getAttachmentValue(attachment, 'size') || '');
|
|
1148
|
+
const content = truncateForContext(text?.trim() || '');
|
|
1149
|
+
|
|
1150
|
+
if (!content) {
|
|
1151
|
+
return [
|
|
1152
|
+
`<file_preview filename="${filename}" type="${mimetype}" extname="${extname}" size="${size}">`,
|
|
1153
|
+
'The user is previewing this file, but no extractable text content was found by the server parser.',
|
|
1154
|
+
'</file_preview>',
|
|
1155
|
+
].join('\n');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return [
|
|
1159
|
+
`<file_preview filename="${filename}" type="${mimetype}" extname="${extname}" size="${size}">`,
|
|
1160
|
+
content,
|
|
1161
|
+
'</file_preview>',
|
|
1162
|
+
].join('\n');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Register Excel handler into plugin-document-parser's InternalParserRegistry.
|
|
1167
|
+
* Uses prepend:true so SheetJS takes priority over the AI-loader fallback.
|
|
1168
|
+
* Silent no-op when plugin-document-parser is not loaded.
|
|
1169
|
+
*/
|
|
1170
|
+
private registerExcelParser() {
|
|
1171
|
+
const docParserPlugin = (this.pm.get('@nocobase/plugin-document-parser') ||
|
|
1172
|
+
this.pm.get('plugin-document-parser')) as any;
|
|
1173
|
+
if (!docParserPlugin?.internalParserRegistry) {
|
|
1174
|
+
this.log.debug('[FilePreviewAuth] plugin-document-parser not found — Excel parser registration skipped');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
try {
|
|
1179
|
+
docParserPlugin.internalParserRegistry.register(new ExcelParserHandler(), { prepend: true });
|
|
1180
|
+
this.log.info('[FilePreviewAuth] Excel parser handler registered into plugin-document-parser');
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
// Duplicate registration (e.g. hot-reload) — safe to ignore
|
|
1183
|
+
this.log.warn(`[FilePreviewAuth] Excel parser registration skipped: ${err}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export default PluginFilePreviewAuthServer;
|
|
1189
|
+
|
|
1190
|
+
function safeDebugJson(value: unknown): string {
|
|
1191
|
+
try {
|
|
1192
|
+
return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
return JSON.stringify({ error: 'failed_to_serialize_debug_payload' });
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function toDebugValue(value: unknown) {
|
|
1199
|
+
if (value === undefined || value === null || value === '') {
|
|
1200
|
+
return value;
|
|
1201
|
+
}
|
|
1202
|
+
return String(value);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function getObjectKeys(value: unknown): string[] {
|
|
1206
|
+
if (!value || typeof value !== 'object') {
|
|
1207
|
+
return [];
|
|
1208
|
+
}
|
|
1209
|
+
return Object.keys(value);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function getDebugUrlPath(url: string) {
|
|
1213
|
+
if (!url) {
|
|
1214
|
+
return '';
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
return new URL(url, 'http://local').pathname;
|
|
1218
|
+
} catch {
|
|
1219
|
+
return 'unparseable';
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function summarizeAttachmentForLog(attachment: any, collectionName?: any) {
|
|
1224
|
+
return {
|
|
1225
|
+
id: toDebugValue(getAttachmentValue(attachment, 'id')),
|
|
1226
|
+
uid: toDebugValue(getAttachmentValue(attachment, 'uid')),
|
|
1227
|
+
collectionName: String(collectionName || getAttachmentValue(attachment, 'collectionName') || ''),
|
|
1228
|
+
storageId: toDebugValue(getAttachmentValue(attachment, 'storageId')),
|
|
1229
|
+
storageIdColumn: toDebugValue(getAttachmentValue(attachment, 'storage_id')),
|
|
1230
|
+
storageName: getAttachmentValue(attachment, 'storage')?.name || getAttachmentValue(attachment, 'storageName'),
|
|
1231
|
+
fieldsPresent: {
|
|
1232
|
+
key: hasText(getAttachmentValue(attachment, 'key')),
|
|
1233
|
+
filename: hasText(getAttachmentValue(attachment, 'filename')),
|
|
1234
|
+
path: hasText(getAttachmentValue(attachment, 'path')),
|
|
1235
|
+
url: hasText(getAttachmentValue(attachment, 'url')),
|
|
1236
|
+
preview: hasText(getAttachmentValue(attachment, 'preview')),
|
|
1237
|
+
mimetype: hasText(getAttachmentValue(attachment, 'mimetype')),
|
|
1238
|
+
title: hasText(getAttachmentValue(attachment, 'title')),
|
|
1239
|
+
extname: hasText(getAttachmentValue(attachment, 'extname')),
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function summarizeStorageForLog(storage: any) {
|
|
1245
|
+
if (!storage) {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
id: toDebugValue(storage.id),
|
|
1250
|
+
name: storage.name,
|
|
1251
|
+
type: storage.type,
|
|
1252
|
+
default: Boolean(storage.default),
|
|
1253
|
+
public: Boolean(storage.options?.public),
|
|
1254
|
+
paranoid: Boolean(storage.paranoid),
|
|
1255
|
+
hasBucket: hasText(storage.options?.bucket),
|
|
1256
|
+
hasEndpoint: hasText(storage.options?.endpoint),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function summarizeStorageCache(cache: Map<any, any> | undefined) {
|
|
1261
|
+
if (!cache) {
|
|
1262
|
+
return { size: 0, storages: [] };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
size: cache.size,
|
|
1267
|
+
storages: Array.from(cache.entries())
|
|
1268
|
+
.slice(0, 20)
|
|
1269
|
+
.map(([key, storage]) => ({
|
|
1270
|
+
cacheKey: toDebugValue(key),
|
|
1271
|
+
...summarizeStorageForLog(storage),
|
|
1272
|
+
})),
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function getStorageFromCache(cache: Map<any, any>, storageId: any) {
|
|
1277
|
+
if (storageId === undefined || storageId === null) return undefined;
|
|
1278
|
+
let res = cache.get(storageId);
|
|
1279
|
+
if (res) return res;
|
|
1280
|
+
const strId = String(storageId);
|
|
1281
|
+
res = cache.get(strId);
|
|
1282
|
+
if (res) return res;
|
|
1283
|
+
const numId = Number(storageId);
|
|
1284
|
+
if (!isNaN(numId)) {
|
|
1285
|
+
res = cache.get(numId);
|
|
1286
|
+
if (res) return res;
|
|
1287
|
+
}
|
|
1288
|
+
for (const [k, v] of cache.entries()) {
|
|
1289
|
+
if (String(k) === strId) {
|
|
1290
|
+
return v;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function getAttachmentValue(attachment: any, key: string) {
|
|
1297
|
+
if (!attachment) return undefined;
|
|
1298
|
+
if (typeof attachment.get === 'function') return attachment.get(key);
|
|
1299
|
+
return attachment[key];
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function getRecordStorageId(record: any) {
|
|
1303
|
+
if (!record) return undefined;
|
|
1304
|
+
return (
|
|
1305
|
+
record.get?.('storageId') ??
|
|
1306
|
+
record.get?.('storage_id') ??
|
|
1307
|
+
record.getDataValue?.('storageId') ??
|
|
1308
|
+
record.getDataValue?.('storage_id') ??
|
|
1309
|
+
record.storageId ??
|
|
1310
|
+
record.storage_id ??
|
|
1311
|
+
record.get?.('storage')?.id ??
|
|
1312
|
+
record.storage?.id
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function findStorageCacheKey(cache: Map<any, any> | undefined, storageId: any) {
|
|
1317
|
+
if (!cache || isMissingFileValue(storageId)) {
|
|
1318
|
+
return undefined;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (cache.has(storageId)) {
|
|
1322
|
+
return storageId;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const strId = String(storageId);
|
|
1326
|
+
if (cache.has(strId)) {
|
|
1327
|
+
return strId;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const numericId = Number(storageId);
|
|
1331
|
+
if (Number.isFinite(numericId) && cache.has(numericId)) {
|
|
1332
|
+
return numericId;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
for (const key of cache.keys()) {
|
|
1336
|
+
if (String(key) === strId) {
|
|
1337
|
+
return key;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function findDefaultStorageCacheKey(cache: Map<any, any> | undefined) {
|
|
1345
|
+
if (!cache) {
|
|
1346
|
+
return undefined;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
for (const [key, storage] of cache.entries()) {
|
|
1350
|
+
if (storage?.default) {
|
|
1351
|
+
return key;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (cache.size === 1) {
|
|
1356
|
+
return cache.keys().next().value;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
return undefined;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function findExistingColumn(columns: Record<string, unknown>, candidates: Array<string | undefined>) {
|
|
1363
|
+
const columnNames = Object.keys(columns || {});
|
|
1364
|
+
|
|
1365
|
+
for (const candidate of candidates) {
|
|
1366
|
+
if (!candidate) continue;
|
|
1367
|
+
if (Object.prototype.hasOwnProperty.call(columns, candidate)) {
|
|
1368
|
+
return candidate;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const matchedColumn = columnNames.find((columnName) => columnName.toLowerCase() === candidate.toLowerCase());
|
|
1372
|
+
if (matchedColumn) {
|
|
1373
|
+
return matchedColumn;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function hasText(value: unknown) {
|
|
1381
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function isMissingFileValue(value: unknown) {
|
|
1385
|
+
return value === undefined || value === null || value === '';
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function getAttachmentDisplayName(attachment: any): string {
|
|
1389
|
+
const title = getAttachmentValue(attachment, 'title');
|
|
1390
|
+
const extname = getAttachmentValue(attachment, 'extname');
|
|
1391
|
+
if (title && extname) return `${title}${extname}`;
|
|
1392
|
+
return getAttachmentValue(attachment, 'filename') || getAttachmentValue(attachment, 'name') || 'file';
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function isLikelyRecordId(value: any): boolean {
|
|
1396
|
+
if (value === undefined || value === null || value === '') return false;
|
|
1397
|
+
const text = String(value);
|
|
1398
|
+
return !text.includes('/') && !text.startsWith('http://') && !text.startsWith('https://');
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function normalizeOcrAttachmentId(value: unknown): string | number | null {
|
|
1402
|
+
if (typeof value === 'number') {
|
|
1403
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (typeof value === 'string') {
|
|
1407
|
+
const trimmed = value.trim();
|
|
1408
|
+
return /^\d+$/.test(trimmed) ? trimmed : null;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function getUrlCandidates(value: any): string[] {
|
|
1415
|
+
if (!value) return [];
|
|
1416
|
+
const original = decodePossiblyEncodedUrl(String(value));
|
|
1417
|
+
const candidates = new Set<string>();
|
|
1418
|
+
const add = (url: string) => {
|
|
1419
|
+
if (!url) return;
|
|
1420
|
+
candidates.add(url);
|
|
1421
|
+
candidates.add(url.split('?')[0]);
|
|
1422
|
+
candidates.add(url.replace(/^\//, ''));
|
|
1423
|
+
candidates.add(`/${url.replace(/^\//, '')}`);
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
add(original);
|
|
1427
|
+
try {
|
|
1428
|
+
const parsed = new URL(original, 'http://local');
|
|
1429
|
+
if (parsed.pathname.includes('/api/filePreviewAuth:download')) {
|
|
1430
|
+
add(decodePossiblyEncodedUrl(parsed.searchParams.get('url') || ''));
|
|
1431
|
+
} else if (parsed.origin !== 'http://local') {
|
|
1432
|
+
add(`${parsed.pathname}${parsed.search}`);
|
|
1433
|
+
}
|
|
1434
|
+
} catch {
|
|
1435
|
+
// Keep the original candidates only.
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return [...candidates].filter(Boolean);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function decodePossiblyEncodedUrl(value: string): string {
|
|
1442
|
+
try {
|
|
1443
|
+
return decodeURIComponent(value);
|
|
1444
|
+
} catch {
|
|
1445
|
+
return value;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function isPlainTextAttachment(attachment: any): boolean {
|
|
1450
|
+
const mimetype = String(getAttachmentValue(attachment, 'mimetype') || '').toLowerCase();
|
|
1451
|
+
const extname = String(getAttachmentValue(attachment, 'extname') || '').toLowerCase();
|
|
1452
|
+
return (
|
|
1453
|
+
mimetype.startsWith('text/') ||
|
|
1454
|
+
['application/json', 'application/xml', 'application/x-yaml'].includes(mimetype) ||
|
|
1455
|
+
['.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.log'].includes(extname)
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function truncateForContext(text: string): string {
|
|
1460
|
+
if (!text || text.length <= MAX_AI_CONTEXT_CHARS) return text;
|
|
1461
|
+
return `${text.slice(0, MAX_AI_CONTEXT_CHARS)}\n...[truncated]`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function sanitizeXmlAttr(value: any): string {
|
|
1465
|
+
return String(value ?? '')
|
|
1466
|
+
.replace(/&/g, '&')
|
|
1467
|
+
.replace(/"/g, '"')
|
|
1468
|
+
.replace(/</g, '<')
|
|
1469
|
+
.replace(/>/g, '>');
|
|
1470
|
+
}
|