@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.
Files changed (37) hide show
  1. package/README.md +151 -0
  2. package/dist/api/apiRouter.d.ts +20 -0
  3. package/dist/api/apiRouter.js +122 -7
  4. package/dist/api/router/mountCrudRoutes.js +69 -18
  5. package/dist/api/router/setupAuthRoutes.js +475 -38
  6. package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
  7. package/dist/api/router/setupChatWidgetRoutes.js +207 -0
  8. package/dist/api/router/setupFileRoutes.js +221 -16
  9. package/dist/api/router/utils.d.ts +2 -1
  10. package/dist/api/router/utils.js +8 -6
  11. package/dist/cli.d.ts +1 -0
  12. package/dist/cli.js +145 -64
  13. package/dist/database/DatabaseAdapter.d.ts +1 -0
  14. package/dist/database/NMAdapter.d.ts +6 -0
  15. package/dist/database/NMAdapter.js +437 -86
  16. package/dist/database/QueryEngine.js +7 -4
  17. package/dist/files/FileStorageAdapter.d.ts +1 -0
  18. package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
  19. package/dist/files/LocalFileStorageAdapter.js +25 -3
  20. package/dist/files/S3FileStorageAdapter.d.ts +3 -1
  21. package/dist/files/S3FileStorageAdapter.js +72 -11
  22. package/dist/files/filename.js +6 -4
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +3 -1
  25. package/dist/models/BaseModel.d.ts +4 -0
  26. package/dist/policy/authorize.d.ts +1 -1
  27. package/dist/policy/authorize.js +64 -13
  28. package/dist/schemas/Users.json +20 -10
  29. package/dist/services/IndexingService.d.ts +24 -0
  30. package/dist/services/IndexingService.js +555 -0
  31. package/dist/services/LocalAIService.d.ts +34 -0
  32. package/dist/services/LocalAIService.js +471 -0
  33. package/dist/services/OpenAIService.d.ts +16 -0
  34. package/dist/services/OpenAIService.js +586 -0
  35. package/dist/utils/SchemaLoader.d.ts +1 -1
  36. package/dist/utils/SchemaLoader.js +40 -4
  37. 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: f.originalname,
95
+ originalFilename: rawOriginalName,
29
96
  body: f.buffer,
30
97
  contentType: f.mimetype,
31
- metadata: { originalName: f.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: out.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: f.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(400)
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
- const { body, contentType, size } = await ctx.fileStorage.download(key);
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 (contentType?.startsWith('image/')) {
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 && typeof body.pipe === 'function') {
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
- if (err.name === 'NoSuchKey' || err.code === 'NoSuchKey' || err.name === 'NotFound' || err.code === 'NotFound') {
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: f.originalname,
307
+ originalFilename: rawOriginalName,
108
308
  body: f.buffer,
109
309
  contentType: f.mimetype,
110
- metadata: { originalName: f.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: out.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: f.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(400)
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: string;
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[];
@@ -331,13 +331,15 @@ function extractIds(val) {
331
331
  function refInfoFromAttr(attr) {
332
332
  if (!attr)
333
333
  return null;
334
- if (Array.isArray(attr) && attr[0] && attr[0].ref) {
335
- return { ref: String(attr[0].ref), isArray: true };
336
- }
337
- const a = Array.isArray(attr) ? attr?.[0] : attr;
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
- if (a?.ref && (t === 'objectid' || t === 'ref')) {
340
- return { ref: String(a.ref), isArray: false };
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
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  export {};