@airoom/nextmin-node 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -0
- package/dist/api/apiRouter.d.ts +20 -0
- package/dist/api/apiRouter.js +122 -7
- package/dist/api/router/mountCrudRoutes.js +69 -18
- package/dist/api/router/setupAuthRoutes.js +475 -38
- package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
- package/dist/api/router/setupChatWidgetRoutes.js +207 -0
- package/dist/api/router/setupFileRoutes.js +221 -16
- package/dist/api/router/utils.d.ts +2 -1
- package/dist/api/router/utils.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +145 -64
- package/dist/database/DatabaseAdapter.d.ts +1 -0
- package/dist/database/NMAdapter.d.ts +6 -0
- package/dist/database/NMAdapter.js +437 -86
- package/dist/database/QueryEngine.js +7 -4
- package/dist/files/FileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.js +25 -3
- package/dist/files/S3FileStorageAdapter.d.ts +3 -1
- package/dist/files/S3FileStorageAdapter.js +72 -11
- package/dist/files/filename.js +6 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/models/BaseModel.d.ts +4 -0
- package/dist/policy/authorize.d.ts +1 -1
- package/dist/policy/authorize.js +64 -13
- package/dist/schemas/Users.json +20 -10
- package/dist/services/IndexingService.d.ts +24 -0
- package/dist/services/IndexingService.js +555 -0
- package/dist/services/LocalAIService.d.ts +34 -0
- package/dist/services/LocalAIService.js +471 -0
- package/dist/services/OpenAIService.d.ts +16 -0
- package/dist/services/OpenAIService.js +586 -0
- package/dist/utils/SchemaLoader.d.ts +1 -1
- package/dist/utils/SchemaLoader.js +40 -4
- package/package.json +12 -4
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setupChatWidgetRoutes = setupChatWidgetRoutes;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const LocalAIService_1 = require("../../services/LocalAIService");
|
|
10
|
+
const OpenAIService_1 = require("../../services/OpenAIService");
|
|
11
|
+
const Logger_1 = __importDefault(require("../../utils/Logger"));
|
|
12
|
+
function setupChatWidgetRoutes(ctx, chatWidget) {
|
|
13
|
+
if (!chatWidget || !chatWidget.enabled) {
|
|
14
|
+
Logger_1.default.info('APIRouter', 'Chat widget is disabled. Skipping route setup.');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const router = ctx.router;
|
|
18
|
+
const localAI = new LocalAIService_1.LocalAIService();
|
|
19
|
+
const openAI = new OpenAIService_1.OpenAIService();
|
|
20
|
+
// Prewarm the local LLM model in the background during server boot
|
|
21
|
+
localAI.prewarm(chatWidget.localModelPath).catch(err => {
|
|
22
|
+
Logger_1.default.warn('ChatWidget', 'Failed to prewarm local AI model on startup', err);
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* POST /_chatwidget/message
|
|
26
|
+
* Handle user messages and generate reply via Cloudflare AI Search.
|
|
27
|
+
*/
|
|
28
|
+
router.post('/_chatwidget/message', async (req, res) => {
|
|
29
|
+
const { message, history, chatbotName, headerText, threadId, pagePath } = req.body;
|
|
30
|
+
if (!message || typeof message !== 'string') {
|
|
31
|
+
return res.status(400).json({
|
|
32
|
+
success: false,
|
|
33
|
+
error: 'Message parameter is required and must be a string.'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// Validate conversation message limit to protect token usage
|
|
37
|
+
const maxMessages = chatWidget?.maxMessagesPerThread !== undefined ? chatWidget.maxMessagesPerThread : 15;
|
|
38
|
+
if (maxMessages > 0) {
|
|
39
|
+
const userMessageCount = Array.isArray(history)
|
|
40
|
+
? history.filter((m) => m.role === 'user').length
|
|
41
|
+
: 0;
|
|
42
|
+
if (userMessageCount >= maxMessages) {
|
|
43
|
+
return res.status(400).json({
|
|
44
|
+
success: false,
|
|
45
|
+
error: 'To keep our answers highly focused and accurate, this session has completed. Please start a fresh chat to continue!'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const chatHistory = Array.isArray(history) ? history.slice(-6) : [];
|
|
50
|
+
// Fallback default system prompt if not specified in server parameters
|
|
51
|
+
const botName = chatbotName || headerText || 'AI Site Assistant';
|
|
52
|
+
const baseUrl = chatWidget?.siteUrl ? chatWidget.siteUrl.replace(/\/$/, '') : '';
|
|
53
|
+
const urlInstruction = baseUrl
|
|
54
|
+
? `If you find a related URL or path in the website context (like an admin panel, docs, or other page), you MUST strictly tell the user "I found this URL. Did you mean this? ${baseUrl}/path" (e.g. "I found this URL. Did you mean this? ${baseUrl}/admin"). Never say the URL/path is "not explicitly mentioned" or give a long, verbose robotic explanation; keep it extremely direct and present the URL.`
|
|
55
|
+
: `If you find a related URL or path in the website context (like an admin panel, docs, or other page), you MUST strictly tell the user "I found this URL. Did you mean this? [label](/path)" (e.g. "I found this URL. Did you mean this? [Admin](/admin)"). Never say the URL/path is "not explicitly mentioned" or give a long, verbose robotic explanation; keep it extremely direct and present the URL.`;
|
|
56
|
+
const FRIENDLY_FALLBACK = "Thanks for asking! 😊 I don't have enough information on that just yet, but we're working on it. Please check back soon or visit our website for the latest details!";
|
|
57
|
+
const defaultSystemPrompt = `You are a friendly, professional live support agent named ${botName} for our website. Your goal is to help users naturally and warmly.
|
|
58
|
+
|
|
59
|
+
STRICT RULES — follow these exactly:
|
|
60
|
+
1. Use the website context provided below to answer the user's questions.
|
|
61
|
+
2. Talk like a real human support team member. Use first-person plural pronouns ("we", "our", "us") when referring to the website, company, or project (e.g. say "We support MongoDB" or "our packages" instead of "NextMin supports" or "the project has").
|
|
62
|
+
3. NEVER use robotic phrases like "Based on the provided website content", "according to the text", "the context states", "the documentation says", or "is not explicitly mentioned in the context". Speak naturally as if you know all the facts yourself.
|
|
63
|
+
4. If you cannot find the answer in the website context (for example: custom pricing, specific enterprise options, or details not covered in the context), respond with EXACTLY this message and nothing else: "${FRIENDLY_FALLBACK}"
|
|
64
|
+
5. Keep your answers concise, clear, and friendly. Use friendly emojis naturally (e.g. 😊, 🚀, 👍).
|
|
65
|
+
6. NEVER mention file names, database keys, ".txt", ".json" file extensions, or internal source names.
|
|
66
|
+
7. ${urlInstruction}
|
|
67
|
+
8. For greetings (hi, hello, hey) or chit-chat, respond warmly and briefly, welcome the user, and ask how you can help them today.`;
|
|
68
|
+
const defaultOpenaiSystemPrompt = `You are a friendly, professional live support agent named ${botName} for our website. Your goal is to help users naturally and warmly based ONLY on the website context provided through File Search.
|
|
69
|
+
|
|
70
|
+
STRICT RULES — follow these exactly:
|
|
71
|
+
1. Base your answers strictly on the website context. If you cannot find the answer in the context, respond with EXACTLY this message and nothing else: "${FRIENDLY_FALLBACK}"
|
|
72
|
+
2. Talk like a real human support team member. Use first-person plural pronouns ("we", "our", "us") when referring to the website, company, or project (e.g. say "We support MongoDB" or "our packages" instead of "NextMin supports" or "the project has").
|
|
73
|
+
3. NEVER use robotic phrases like "Based on the provided website content", "according to the text", "the context states", "the documentation says", or "is not explicitly mentioned in the context". Speak naturally as if you know all the facts yourself.
|
|
74
|
+
4. Keep your answers concise, clear, and friendly. Use friendly emojis naturally (e.g. 😊, 🚀, 👍).
|
|
75
|
+
5. NEVER mention file names, database keys, ".txt", ".json" file extensions, or internal source names.
|
|
76
|
+
6. ${urlInstruction}
|
|
77
|
+
7. For greetings (hi, hello, hey) or chit-chat, respond warmly and briefly, welcome the user, and ask how you can help them today.
|
|
78
|
+
8. LANGUAGE RULES: Detect the language of the user's input. You MUST respond in the exact same language (e.g., if the user asks in Bengali, reply in Bengali; if in Spanish, reply in Spanish; if in English, reply in English). Translate all relevant facts from the website context into the user's language accurately.`;
|
|
79
|
+
let pageContext = '';
|
|
80
|
+
let effectiveThreadId = threadId || 'default-thread';
|
|
81
|
+
let isPageSpecificChat = false;
|
|
82
|
+
if (pagePath && typeof pagePath === 'string') {
|
|
83
|
+
let cleanPath = pagePath;
|
|
84
|
+
if (cleanPath.includes('?'))
|
|
85
|
+
cleanPath = cleanPath.split('?')[0];
|
|
86
|
+
if (cleanPath.includes('#'))
|
|
87
|
+
cleanPath = cleanPath.split('#')[0];
|
|
88
|
+
const cleanPathname = cleanPath.replace(/[^a-zA-Z0-9_\-]/g, '_') || 'index';
|
|
89
|
+
const key = `page-${cleanPathname}`;
|
|
90
|
+
effectiveThreadId = `${effectiveThreadId}_${cleanPathname}`;
|
|
91
|
+
isPageSpecificChat = true;
|
|
92
|
+
// Check strictly in .nextmin/scraped-pages/
|
|
93
|
+
const candidates = [
|
|
94
|
+
path_1.default.join(process.cwd(), '.nextmin', 'scraped-pages', `${key}.txt`),
|
|
95
|
+
path_1.default.join(process.cwd(), '.nextmin', 'scraped-pages', `${cleanPathname}.txt`),
|
|
96
|
+
];
|
|
97
|
+
for (const candidatePath of candidates) {
|
|
98
|
+
if (fs_1.default.existsSync(candidatePath)) {
|
|
99
|
+
pageContext = fs_1.default.readFileSync(candidatePath, 'utf-8');
|
|
100
|
+
Logger_1.default.info('ChatWidget', `Loaded exact page context for pagePath: ${pagePath} from ${candidatePath} (${pageContext.length} chars)`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const pageTitleName = chatbotName || headerText || (pagePath ? pagePath : 'this page');
|
|
106
|
+
let systemPromptText;
|
|
107
|
+
if (isPageSpecificChat) {
|
|
108
|
+
systemPromptText = `You are a friendly, professional live support agent for our website, acting as the dedicated support assistant for "${pageTitleName}".
|
|
109
|
+
The user is currently viewing the page "${pagePath}" ("${pageTitleName}").
|
|
110
|
+
${pageContext ? `
|
|
111
|
+
EXACT ACTIVE PAGE CONTEXT:
|
|
112
|
+
${pageContext}
|
|
113
|
+
` : ''}
|
|
114
|
+
|
|
115
|
+
CRITICAL DIRECTIVE FOR PAGE-SPECIFIC CHAT:
|
|
116
|
+
The user is currently viewing "${pageTitleName}" (${pagePath}).
|
|
117
|
+
When the user asks questions such as "Why choose this doctor?", "What are their qualifications?", "Where is their chamber?", "Visiting hours?", "Contact info?", "How to book an appointment?", or refers to "this page", "this doctor", "this hospital", "this item", "them", "him", "her", they are strictly referring to "${pageTitleName}".
|
|
118
|
+
${pageContext ? 'You MUST answer questions about this page using the EXACT ACTIVE PAGE CONTEXT provided above.' : `You must search the vector store specifically for "${pageTitleName}" or canonical URL "${pagePath}".`} Do NOT introduce, discuss, or substitute any other entity or person with a similar name.
|
|
119
|
+
|
|
120
|
+
STRICT RULES — follow these exactly:
|
|
121
|
+
1. Base your answers strictly on the context for "${pageTitleName}". Do NOT introduce, discuss, or substitute any other page, item, person, or subject.
|
|
122
|
+
2. Talk like a real human support team member. Use first-person plural pronouns ("we", "our", "us") when referring to the website, company, or project.
|
|
123
|
+
3. NEVER use robotic phrases like "Based on the provided website content", "according to the text", or "is not explicitly mentioned". Speak naturally as if you know all the facts yourself.
|
|
124
|
+
4. Keep your answers concise, clear, and friendly. Use friendly emojis naturally.
|
|
125
|
+
5. NEVER mention file names, database keys, ".txt", ".json" file extensions, or internal source names.
|
|
126
|
+
6. ${urlInstruction}
|
|
127
|
+
7. LANGUAGE RULES: Detect the language of the user's input. You MUST respond in the exact same language (e.g., if the user asks in Bengali, reply in Bengali; if in English, reply in English). Translate the facts accurately.`;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
systemPromptText = chatWidget?.systemPrompt || (chatWidget?.enableOpenai !== false ? defaultOpenaiSystemPrompt : defaultSystemPrompt);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
Logger_1.default.info('ChatWidget', `Received question: "${message}" (threadId: ${effectiveThreadId}, pagePath: ${pagePath || 'none'})`);
|
|
134
|
+
// Set headers for streaming response
|
|
135
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
136
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
137
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
138
|
+
res.setHeader('Connection', 'keep-alive');
|
|
139
|
+
let chunkWritten = false;
|
|
140
|
+
let usedOpenAI = false;
|
|
141
|
+
// Try OpenAI first if enabled and API Key is configured
|
|
142
|
+
if (chatWidget?.enableOpenai !== false) {
|
|
143
|
+
if (process.env.OPENAI_API_KEY) {
|
|
144
|
+
try {
|
|
145
|
+
usedOpenAI = true;
|
|
146
|
+
await openAI.generateAnswerStream(effectiveThreadId, message, systemPromptText, (chunk) => {
|
|
147
|
+
res.write(chunk);
|
|
148
|
+
chunkWritten = true;
|
|
149
|
+
}, false // Always enable vector store search (single source of truth)
|
|
150
|
+
);
|
|
151
|
+
if (!chunkWritten) {
|
|
152
|
+
Logger_1.default.warn('ChatWidget', `OpenAI stream finished but no chunks were written.`);
|
|
153
|
+
usedOpenAI = false; // Trigger fallback
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (openaiErr) {
|
|
157
|
+
Logger_1.default.warn('ChatWidget', `OpenAI Responses API call failed. Falling back to local model. Reason: ${openaiErr.message || openaiErr}`);
|
|
158
|
+
usedOpenAI = false;
|
|
159
|
+
chunkWritten = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
Logger_1.default.warn('ChatWidget', 'enableOpenai is true but OPENAI_API_KEY is not defined in environment variables. Falling back to local model.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// If OpenAI was not used, or if it failed and wrote no chunks, run the local model as a fallback
|
|
167
|
+
if (!usedOpenAI && !chunkWritten) {
|
|
168
|
+
Logger_1.default.info('ChatWidget', 'Running Local model (offline fallback)...');
|
|
169
|
+
const rawReply = await localAI.generateAnswer(chatHistory, message, systemPromptText, chatWidget?.localModelPath, chatWidget?.threads, effectiveThreadId, (chunk) => {
|
|
170
|
+
res.write(chunk);
|
|
171
|
+
chunkWritten = true;
|
|
172
|
+
});
|
|
173
|
+
if (!chunkWritten) {
|
|
174
|
+
const uncertaintyPhrases = [
|
|
175
|
+
/i (don'?t|do not) have (that|this|enough|any|much) (information|info|data|detail)/i,
|
|
176
|
+
/i (can'?t|cannot|could not|couldn'?t) (find|answer|provide|tell)/i,
|
|
177
|
+
/not (available|found|mentioned|covered|included|specified|provided) in (the )?(context|content|information|docs|documentation)/i,
|
|
178
|
+
/the (context|content|provided information|documentation) does(n'?t| not) (contain|include|mention|have|cover)/i,
|
|
179
|
+
/there('?s| is) no (information|data|detail|mention) (about|on|regarding)/i,
|
|
180
|
+
/i'?m (unable|not able) to (answer|find|provide|tell)/i,
|
|
181
|
+
/based on (the )?(available |provided |given )?(context|information|content), i (cannot|can'?t|am unable)/i,
|
|
182
|
+
];
|
|
183
|
+
const isUncertainResponse = uncertaintyPhrases.some(pattern => pattern.test(rawReply));
|
|
184
|
+
const pageFallback = `I am here to help you with information about ${pageTitleName} only. If you need other general information about the site, please start a new chat session! 😊`;
|
|
185
|
+
const reply = isUncertainResponse ? (isPageSpecificChat ? pageFallback : FRIENDLY_FALLBACK) : rawReply;
|
|
186
|
+
if (isUncertainResponse) {
|
|
187
|
+
Logger_1.default.info('ChatWidget', `LLM expressed uncertainty — replaced with friendly fallback.`);
|
|
188
|
+
}
|
|
189
|
+
res.write(reply);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
res.end();
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
Logger_1.default.error('ChatWidget', 'Chat response generation failed', err);
|
|
196
|
+
if (!res.headersSent) {
|
|
197
|
+
return res.status(500).json({
|
|
198
|
+
success: false,
|
|
199
|
+
error: err.message || 'Internal server error while generating chat response.'
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
res.end();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -6,6 +6,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.setupFileRoutes = setupFileRoutes;
|
|
7
7
|
const multer_1 = __importDefault(require("multer"));
|
|
8
8
|
const Logger_1 = __importDefault(require("../../utils/Logger"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
const CACHE_DIR = path_1.default.join(os_1.default.tmpdir(), 'nextmin_img_cache');
|
|
14
|
+
try {
|
|
15
|
+
if (!fs_1.default.existsSync(CACHE_DIR)) {
|
|
16
|
+
fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (_) { }
|
|
20
|
+
const WIN1252_MAP = {
|
|
21
|
+
0x20AC: 0x80, 0x201A: 0x82, 0x0192: 0x83, 0x201E: 0x84,
|
|
22
|
+
0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02C6: 0x88,
|
|
23
|
+
0x2030: 0x89, 0x0160: 0x8A, 0x2039: 0x8B, 0x0152: 0x8C,
|
|
24
|
+
0x017D: 0x8E, 0x2018: 0x91, 0x2019: 0x92, 0x201C: 0x93,
|
|
25
|
+
0x201D: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97,
|
|
26
|
+
0x02DC: 0x98, 0x2122: 0x99, 0x0161: 0x9A, 0x203A: 0x9B,
|
|
27
|
+
0x0153: 0x9C, 0x017E: 0x9E, 0x0178: 0x9F,
|
|
28
|
+
};
|
|
29
|
+
function fixUtf8Filename(name) {
|
|
30
|
+
if (!name)
|
|
31
|
+
return '';
|
|
32
|
+
if (/[\u0980-\u09FF]/.test(name)) {
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const bytes = [];
|
|
37
|
+
for (let i = 0; i < name.length; i++) {
|
|
38
|
+
const code = name.charCodeAt(i);
|
|
39
|
+
if (WIN1252_MAP[code] !== undefined) {
|
|
40
|
+
bytes.push(WIN1252_MAP[code]);
|
|
41
|
+
}
|
|
42
|
+
else if (code <= 0xFF) {
|
|
43
|
+
bytes.push(code);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return name;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const decoded = Buffer.from(bytes).toString('utf-8');
|
|
50
|
+
if (!decoded.includes('\ufffd')) {
|
|
51
|
+
return decoded;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
9
57
|
function setupFileRoutes(ctx) {
|
|
10
58
|
if (!ctx.fileStorage)
|
|
11
59
|
return;
|
|
@@ -23,23 +71,46 @@ function setupFileRoutes(ctx) {
|
|
|
23
71
|
.status(400)
|
|
24
72
|
.json({ error: true, message: 'No files uploaded' });
|
|
25
73
|
}
|
|
74
|
+
// Fix Multer's default latin1 header decoding to proper UTF-8 for non-ASCII filenames
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
if (f.originalname) {
|
|
77
|
+
f.originalname = fixUtf8Filename(f.originalname);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Validate that every uploaded file has a valid, non-empty name before extension
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const rawName = (f.originalname || '').trim();
|
|
83
|
+
const baseName = rawName.replace(/\.[^/.]+$/, '').trim();
|
|
84
|
+
if (!baseName) {
|
|
85
|
+
return res.status(400).json({
|
|
86
|
+
error: true,
|
|
87
|
+
message: `Uploaded file '${f.originalname || 'unknown'}' does not have a valid filename. Please give the file a descriptive name before uploading.`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
26
91
|
const results = await Promise.all(files.map(async (f) => {
|
|
92
|
+
const rawOriginalName = (req.body?.originalName && typeof req.body.originalName === 'string' ? req.body.originalName : null) ||
|
|
93
|
+
f.originalname;
|
|
27
94
|
const out = await ctx.fileStorage.upload({
|
|
28
|
-
originalFilename:
|
|
95
|
+
originalFilename: rawOriginalName,
|
|
29
96
|
body: f.buffer,
|
|
30
97
|
contentType: f.mimetype,
|
|
31
|
-
metadata: { originalName:
|
|
98
|
+
metadata: { originalName: rawOriginalName || '' },
|
|
32
99
|
});
|
|
100
|
+
let url = out.url;
|
|
101
|
+
if (ctx.dbAdapter && typeof ctx.dbAdapter.formatUrlForRead === 'function') {
|
|
102
|
+
url = ctx.dbAdapter.formatUrlForRead(url);
|
|
103
|
+
}
|
|
33
104
|
return {
|
|
34
105
|
provider: out.provider,
|
|
35
106
|
bucket: out.bucket,
|
|
36
107
|
key: out.key,
|
|
37
|
-
url
|
|
108
|
+
url,
|
|
38
109
|
etag: out.etag,
|
|
39
110
|
contentType: out.contentType,
|
|
40
111
|
size: out.size,
|
|
41
112
|
metadata: out.metadata,
|
|
42
|
-
originalName:
|
|
113
|
+
originalName: rawOriginalName,
|
|
43
114
|
};
|
|
44
115
|
}));
|
|
45
116
|
return res.json({
|
|
@@ -49,9 +120,10 @@ function setupFileRoutes(ctx) {
|
|
|
49
120
|
});
|
|
50
121
|
}
|
|
51
122
|
catch (err) {
|
|
123
|
+
const statusCode = err?.statusCode === 409 || err?.code === 'FILE_EXISTS' ? 409 : 400;
|
|
52
124
|
return res
|
|
53
|
-
.status(
|
|
54
|
-
.json({ error: true, message: err?.message ?? 'Upload failed' });
|
|
125
|
+
.status(statusCode)
|
|
126
|
+
.json({ error: true, message: err?.message ?? 'Upload failed', code: err?.code });
|
|
55
127
|
}
|
|
56
128
|
});
|
|
57
129
|
ctx.router.get('/files/:key(*)', async (req, res) => {
|
|
@@ -62,20 +134,115 @@ function setupFileRoutes(ctx) {
|
|
|
62
134
|
}
|
|
63
135
|
// Ensure no leading slash for storage providers
|
|
64
136
|
key = key.replace(/^\/+/, '');
|
|
137
|
+
const width = req.query.w ? parseInt(String(req.query.w), 10) : undefined;
|
|
138
|
+
const height = req.query.h ? parseInt(String(req.query.h), 10) : undefined;
|
|
139
|
+
const quality = req.query.q ? Math.min(100, Math.max(10, parseInt(String(req.query.q), 10))) : 80;
|
|
140
|
+
const raw = req.query.raw === 'true' || req.query.fmt === 'original' || req.query.fmt === 'raw';
|
|
141
|
+
const targetFormat = raw ? undefined : (req.query.fmt ? String(req.query.fmt).toLowerCase() : 'webp');
|
|
142
|
+
const isImageByKey = /\.(jpg|jpeg|png|webp|avif|gif)$/i.test(key) || Boolean(req.query.w || req.query.h || req.query.q || req.query.fmt);
|
|
143
|
+
let cacheFilePath = null;
|
|
144
|
+
if (isImageByKey && !raw) {
|
|
145
|
+
const hash = crypto_1.default.createHash('md5')
|
|
146
|
+
.update(`${key}_w${width || 0}_h${height || 0}_q${quality}_fmt${targetFormat || 'webp'}`)
|
|
147
|
+
.digest('hex');
|
|
148
|
+
const ext = targetFormat || 'webp';
|
|
149
|
+
cacheFilePath = path_1.default.join(CACHE_DIR, `${hash}.${ext}`);
|
|
150
|
+
if (fs_1.default.existsSync(cacheFilePath)) {
|
|
151
|
+
try {
|
|
152
|
+
const cachedBuf = fs_1.default.readFileSync(cacheFilePath);
|
|
153
|
+
const mimeType = (ext === 'jpeg' || ext === 'jpg') ? 'image/jpeg' : ((ext === 'png') ? 'image/png' : ((ext === 'avif') ? 'image/avif' : 'image/webp'));
|
|
154
|
+
res.setHeader('Content-Type', mimeType);
|
|
155
|
+
res.setHeader('Content-Length', cachedBuf.length);
|
|
156
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
157
|
+
res.setHeader('X-Cache', 'HIT');
|
|
158
|
+
return res.send(cachedBuf);
|
|
159
|
+
}
|
|
160
|
+
catch (_) { }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
65
163
|
Logger_1.default.info('FileProxy', `Fetching file from storage: ${key}`);
|
|
66
|
-
|
|
164
|
+
let { body, contentType, size } = await ctx.fileStorage.download(key);
|
|
165
|
+
const isImage = isImageByKey || contentType?.startsWith('image/');
|
|
166
|
+
// If file is an image and raw format is not explicitly requested, serve converted WebP (with optional resizing)
|
|
167
|
+
if (isImage && !raw) {
|
|
168
|
+
try {
|
|
169
|
+
let buffer = null;
|
|
170
|
+
if (body && typeof body.transformToByteArray === 'function') {
|
|
171
|
+
const data = await body.transformToByteArray();
|
|
172
|
+
buffer = Buffer.from(data);
|
|
173
|
+
}
|
|
174
|
+
else if (Buffer.isBuffer(body)) {
|
|
175
|
+
buffer = body;
|
|
176
|
+
}
|
|
177
|
+
else if (body && typeof body.pipe === 'function') {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
for await (const chunk of body) {
|
|
180
|
+
chunks.push(chunk);
|
|
181
|
+
}
|
|
182
|
+
buffer = Buffer.concat(chunks);
|
|
183
|
+
}
|
|
184
|
+
if (buffer) {
|
|
185
|
+
let sharp;
|
|
186
|
+
try {
|
|
187
|
+
sharp = require('sharp');
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
if (sharp) {
|
|
191
|
+
let pipeline = sharp(buffer);
|
|
192
|
+
if (width || height) {
|
|
193
|
+
pipeline = pipeline.resize({
|
|
194
|
+
width: width || undefined,
|
|
195
|
+
height: height || undefined,
|
|
196
|
+
fit: 'inside',
|
|
197
|
+
withoutEnlargement: true,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (targetFormat === 'jpeg' || targetFormat === 'jpg') {
|
|
201
|
+
pipeline = pipeline.jpeg({ quality });
|
|
202
|
+
contentType = 'image/jpeg';
|
|
203
|
+
}
|
|
204
|
+
else if (targetFormat === 'png') {
|
|
205
|
+
pipeline = pipeline.png({ quality });
|
|
206
|
+
contentType = 'image/png';
|
|
207
|
+
}
|
|
208
|
+
else if (targetFormat === 'avif') {
|
|
209
|
+
pipeline = pipeline.avif({ quality });
|
|
210
|
+
contentType = 'image/avif';
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Default to WebP format for fast delivery & small file size
|
|
214
|
+
pipeline = pipeline.webp({ quality });
|
|
215
|
+
contentType = 'image/webp';
|
|
216
|
+
}
|
|
217
|
+
const resizedBuf = await pipeline.toBuffer();
|
|
218
|
+
buffer = resizedBuf;
|
|
219
|
+
size = resizedBuf.length;
|
|
220
|
+
body = resizedBuf;
|
|
221
|
+
if (cacheFilePath) {
|
|
222
|
+
fs_1.default.writeFile(cacheFilePath, resizedBuf, () => { });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (imgErr) {
|
|
228
|
+
Logger_1.default.warn('FileProxy', `Image processing failed: ${imgErr.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
67
231
|
if (contentType)
|
|
68
232
|
res.setHeader('Content-Type', contentType);
|
|
69
233
|
if (size)
|
|
70
234
|
res.setHeader('Content-Length', size);
|
|
71
235
|
// Cache for 1 year if it's an image
|
|
72
|
-
if (
|
|
236
|
+
if (isImage) {
|
|
73
237
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
74
238
|
}
|
|
75
239
|
else {
|
|
76
240
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
77
241
|
}
|
|
78
|
-
if (body
|
|
242
|
+
if (Buffer.isBuffer(body)) {
|
|
243
|
+
res.send(body);
|
|
244
|
+
}
|
|
245
|
+
else if (body && typeof body.pipe === 'function') {
|
|
79
246
|
body.pipe(res);
|
|
80
247
|
}
|
|
81
248
|
else if (body && typeof body.transformToByteArray === 'function') {
|
|
@@ -88,7 +255,21 @@ function setupFileRoutes(ctx) {
|
|
|
88
255
|
}
|
|
89
256
|
catch (err) {
|
|
90
257
|
Logger_1.default.warn('FileProxy', `Failed to fetch file ${req.params.key}: ${err.message}`);
|
|
91
|
-
|
|
258
|
+
const isMissing = err.name === 'NoSuchKey' || err.code === 'NoSuchKey' || err.name === 'NotFound' || err.code === 'NotFound' || err.statusCode === 404;
|
|
259
|
+
const keyStr = String(req.params.key || '');
|
|
260
|
+
const isImageRequest = /\.(png|jpg|jpeg|webp|avif|gif|svg)$/i.test(keyStr) || !!req.query.w || !!req.query.h;
|
|
261
|
+
if (isMissing && isImageRequest) {
|
|
262
|
+
const FALLBACK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300" fill="none">
|
|
263
|
+
<rect width="300" height="300" fill="#F1F5F9" rx="12"/>
|
|
264
|
+
<rect x="0.5" y="0.5" width="299" height="299" rx="11.5" stroke="#CBD5E1"/>
|
|
265
|
+
<path d="M150 125C158.284 125 165 118.284 165 110C165 101.716 158.284 95 150 95C141.716 95 135 101.716 141.716 125 150 125Z" fill="#94A3B8"/>
|
|
266
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M90 205C87.2386 205 85 202.761 85 200V175C85 168.37 90.37 163 97 163H108.172C110.824 163 113.368 164.054 115.243 165.929L135 185.686L158.757 161.929C160.632 160.054 163.176 159 165.828 159H203C209.63 159 215 164.37 215 171V200C215 202.761 212.761 205 210 205H90Z" fill="#94A3B8"/>
|
|
267
|
+
</svg>`;
|
|
268
|
+
res.setHeader('Content-Type', 'image/svg+xml');
|
|
269
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
270
|
+
return res.status(200).send(FALLBACK_SVG);
|
|
271
|
+
}
|
|
272
|
+
if (isMissing) {
|
|
92
273
|
return res.status(404).json({ error: true, message: 'File not found in storage' });
|
|
93
274
|
}
|
|
94
275
|
return res.status(500).json({ error: true, message: err?.message ?? 'Download failed' });
|
|
@@ -102,23 +283,46 @@ function setupFileRoutes(ctx) {
|
|
|
102
283
|
.status(400)
|
|
103
284
|
.json({ error: true, message: 'No files uploaded' });
|
|
104
285
|
}
|
|
286
|
+
// Fix Multer's default latin1/win1252 header decoding to proper UTF-8 for non-ASCII filenames
|
|
287
|
+
for (const f of files) {
|
|
288
|
+
if (f.originalname) {
|
|
289
|
+
f.originalname = fixUtf8Filename(f.originalname);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Validate that every uploaded file has a valid, non-empty name before extension
|
|
293
|
+
for (const f of files) {
|
|
294
|
+
const rawName = (f.originalname || '').trim();
|
|
295
|
+
const baseName = rawName.replace(/\.[^/.]+$/, '').trim();
|
|
296
|
+
if (!baseName) {
|
|
297
|
+
return res.status(400).json({
|
|
298
|
+
error: true,
|
|
299
|
+
message: `Uploaded file '${f.originalname || 'unknown'}' does not have a valid filename. Please give the file a descriptive name before uploading.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
105
303
|
const results = await Promise.all(files.map(async (f) => {
|
|
304
|
+
const rawOriginalName = (req.body?.originalName && typeof req.body.originalName === 'string' ? req.body.originalName : null) ||
|
|
305
|
+
f.originalname;
|
|
106
306
|
const out = await ctx.fileStorage.upload({
|
|
107
|
-
originalFilename:
|
|
307
|
+
originalFilename: rawOriginalName,
|
|
108
308
|
body: f.buffer,
|
|
109
309
|
contentType: f.mimetype,
|
|
110
|
-
metadata: { originalName:
|
|
310
|
+
metadata: { originalName: rawOriginalName || '' },
|
|
111
311
|
});
|
|
312
|
+
let url = out.url;
|
|
313
|
+
if (ctx.dbAdapter && typeof ctx.dbAdapter.formatUrlForRead === 'function') {
|
|
314
|
+
url = ctx.dbAdapter.formatUrlForRead(url);
|
|
315
|
+
}
|
|
112
316
|
return {
|
|
113
317
|
provider: out.provider,
|
|
114
318
|
bucket: out.bucket,
|
|
115
319
|
key: out.key,
|
|
116
|
-
url
|
|
320
|
+
url,
|
|
117
321
|
etag: out.etag,
|
|
118
322
|
contentType: out.contentType,
|
|
119
323
|
size: out.size,
|
|
120
324
|
metadata: out.metadata,
|
|
121
|
-
originalName:
|
|
325
|
+
originalName: rawOriginalName,
|
|
122
326
|
};
|
|
123
327
|
}));
|
|
124
328
|
return res.json({
|
|
@@ -128,9 +332,10 @@ function setupFileRoutes(ctx) {
|
|
|
128
332
|
});
|
|
129
333
|
}
|
|
130
334
|
catch (err) {
|
|
335
|
+
const statusCode = err?.statusCode === 409 || err?.code === 'FILE_EXISTS' ? 409 : 400;
|
|
131
336
|
return res
|
|
132
|
-
.status(
|
|
133
|
-
.json({ error: true, message: err?.message ?? 'Upload failed' });
|
|
337
|
+
.status(statusCode)
|
|
338
|
+
.json({ error: true, message: err?.message ?? 'Upload failed', code: err?.code });
|
|
134
339
|
}
|
|
135
340
|
});
|
|
136
341
|
ctx.router.delete('/files/:key(*)', fileAuthMiddleware, async (req, res) => {
|
|
@@ -61,7 +61,8 @@ export type ParsedQuery = {
|
|
|
61
61
|
export declare function parseQuery(req: Request, allowedKeys?: Set<string>): ParsedQuery;
|
|
62
62
|
export declare function extractIds(val: unknown): string[];
|
|
63
63
|
export declare function refInfoFromAttr(attr: any): {
|
|
64
|
-
ref
|
|
64
|
+
ref?: string;
|
|
65
|
+
refPath?: string;
|
|
65
66
|
isArray: boolean;
|
|
66
67
|
} | null;
|
|
67
68
|
export declare const dedupeBy: <T, K extends string | number>(rows: T[], key: (r: T) => K) => T[];
|
package/dist/api/router/utils.js
CHANGED
|
@@ -331,13 +331,15 @@ function extractIds(val) {
|
|
|
331
331
|
function refInfoFromAttr(attr) {
|
|
332
332
|
if (!attr)
|
|
333
333
|
return null;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
const isArray = Array.isArray(attr);
|
|
335
|
+
const a = isArray ? attr?.[0] : attr;
|
|
336
|
+
if (!a)
|
|
337
|
+
return null;
|
|
338
338
|
const t = (typeof a?.type === 'string' ? a.type : String(a?.type || '')).toLowerCase();
|
|
339
|
-
|
|
340
|
-
|
|
339
|
+
const refPath = a.refPath ? String(a.refPath) : undefined;
|
|
340
|
+
const ref = a.ref ? String(a.ref) : undefined;
|
|
341
|
+
if ((refPath || ref) && (t === 'objectid' || t === 'ref' || isArray)) {
|
|
342
|
+
return { ref, refPath, isArray };
|
|
341
343
|
}
|
|
342
344
|
return null;
|
|
343
345
|
}
|
package/dist/cli.d.ts
CHANGED