diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/__tests__/generateSkillManifest.test.ts b/Composer/packages/client/src/pages/design/exportSkillModal/__tests__/generateSkillManifest.test.ts index ce0aa923dc..47a857c5e7 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/__tests__/generateSkillManifest.test.ts +++ b/Composer/packages/client/src/pages/design/exportSkillModal/__tests__/generateSkillManifest.test.ts @@ -187,16 +187,21 @@ describe('generateDispatchModels', () => { const dialogs: any = [{ id: 'test', content: { recognizer: 'test.lu' } }]; const selectedTriggers = []; const luFiles = []; - const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles); + const qnaFiles = []; + const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles, qnaFiles); expect(result).toEqual({}); }); it("should return empty object if the schema doesn't include dispatchModels", () => { const schema = { properties: {} }; - const dialogs: any = [{ id: 'test', content: { recognizer: 'test.lu' } }]; + const dialogs: any = [{ id: 'test', content: { recognizer: 'test.lu.qna' } }]; const selectedTriggers = [{ $kind: SDKKinds.OnIntent, intent: 'testIntent' }]; - const luFiles: any = [{ id: 'test.en-us' }, { id: 'test.fr-FR' }]; - const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles); + const luFiles: any = [ + { id: 'test.en-us', empty: false }, + { id: 'test.fr-FR', empty: false }, + ]; + const qnaFiles = []; + const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles, qnaFiles); expect(result).toEqual({}); }); @@ -204,17 +209,25 @@ describe('generateDispatchModels', () => { const schema = { properties: {} }; const dialogs: any = [{ id: 'test', content: {} }]; const selectedTriggers = [{ $kind: SDKKinds.OnIntent, intent: 'testIntent' }]; - const luFiles: any = [{ id: 'test.en-us' }, { id: 'test.fr-FR' }]; - const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles); + const luFiles: any = [ + { id: 'test.en-us', empty: false }, + { id: 'test.fr-FR', empty: false }, + ]; + const qnaFiles = []; + const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles, qnaFiles); expect(result).toEqual({}); }); it('should return dispatch models', () => { const schema = { properties: { dispatchModels: {} } }; - const dialogs: any = [{ id: 'test', content: { recognizer: 'test.lu' }, isRoot: true }]; + const dialogs: any = [{ id: 'test', content: { recognizer: 'test.lu.qna' }, isRoot: true }]; const selectedTriggers = [{ $kind: SDKKinds.OnIntent, intent: 'testIntent' }]; - const luFiles: any = [{ id: 'test.en-us' }, { id: 'test.fr-FR' }]; - const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles); + const luFiles: any = [ + { id: 'test.en-us', empty: false }, + { id: 'test.fr-FR', empty: false }, + ]; + const qnaFiles: any = [{ id: 'test.es-es', empty: false }]; + const result = generateDispatchModels(schema, dialogs, selectedTriggers, luFiles, qnaFiles); expect(result).toEqual( expect.objectContaining({ dispatchModels: { @@ -223,7 +236,7 @@ describe('generateDispatchModels', () => { { name: 'test', contentType: 'application/lu', - url: ``, + url: ``, description: '', }, ], @@ -231,7 +244,15 @@ describe('generateDispatchModels', () => { { name: 'test', contentType: 'application/lu', - url: ``, + url: ``, + description: '', + }, + ], + 'es-es': [ + { + name: 'test', + contentType: 'application/qna', + url: ``, description: '', }, ], diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts index 7880a97f62..767006135c 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts +++ b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import get from 'lodash/get'; -import { DialogInfo, DialogSchemaFile, ITrigger, SDKKinds, SkillManifest, LuFile } from '@bfc/shared'; +import { DialogInfo, DialogSchemaFile, ITrigger, SDKKinds, SkillManifest, LuFile, QnAFile } from '@bfc/shared'; import { JSONSchema7 } from '@bfc/extension-client'; import { Activities, Activity, activityHandlerMap, ActivityTypes, DispatchModels } from './constants'; @@ -17,6 +17,7 @@ export const generateSkillManifest = ( dialogs: DialogInfo[], dialogSchemas: DialogSchemaFile[], luFiles: LuFile[], + qnaFiles: QnAFile[], selectedTriggers: ITrigger[], selectedDialogs: Partial[] ) => { @@ -41,7 +42,7 @@ export const generateSkillManifest = ( }, []); const activities = generateActivities(dialogSchemas, triggers, resolvedDialogs); - const dispatchModels = generateDispatchModels(schema, dialogs, triggers, luFiles); + const dispatchModels = generateDispatchModels(schema, dialogs, triggers, luFiles, qnaFiles); const definitions = getDefinitions(dialogSchemas, resolvedDialogs); return { @@ -104,7 +105,8 @@ export const generateDispatchModels = ( schema: JSONSchema7, dialogs: DialogInfo[], selectedTriggers: any[], - luFiles: LuFile[] + luFiles: LuFile[], + qnaFiles: QnAFile[] ): { dispatchModels?: DispatchModels } => { const intents = selectedTriggers.filter(({ $kind }) => $kind === SDKKinds.OnIntent).map(({ intent }) => intent); const { id: rootId } = dialogs.find((dialog) => dialog?.isRoot) || {}; @@ -114,16 +116,46 @@ export const generateDispatchModels = ( return luId === rootId; }); - if (!intents.length || !schema.properties?.dispatchModels) { + const rootQnAFiles = qnaFiles.filter(({ id: qnaFileId }) => { + const [qnaId] = qnaFileId.split('.'); + return qnaId === rootId; + }); + + if (!schema.properties?.dispatchModels) { return {}; } - const languages = rootLuFiles.reduce((acc, { id }) => { + const luLanguages = intents.length + ? rootLuFiles.reduce((acc, { empty, id }) => { + const [name, locale] = id.split('.'); + const { content = {} } = dialogs.find(({ id }) => id === name) || {}; + const { recognizer = '' } = content; + + if (!recognizer.includes('.lu') || empty) { + return acc; + } + + return { + ...acc, + [locale]: [ + ...(acc[locale] ?? []), + { + name, + contentType: 'application/lu', + url: `<${id}.lu url>`, + description: '', + }, + ], + }; + }, {}) + : {}; + + const languages = rootQnAFiles.reduce((acc, { empty, id }) => { const [name, locale] = id.split('.'); const { content = {} } = dialogs.find(({ id }) => id === name) || {}; const { recognizer = '' } = content; - if (!''.endsWith.call(recognizer, '.lu')) { + if (!recognizer.includes('.qna') || empty) { return acc; } @@ -133,19 +165,21 @@ export const generateDispatchModels = ( ...(acc[locale] ?? []), { name, - contentType: 'application/lu', - url: `<${id} url>`, + contentType: 'application/qna', + url: `<${id}.qna url>`, description: '', }, ], }; - }, {}); + }, luLanguages); + + const dispatchModels = { + ...(Object.keys(languages).length ? { languages } : {}), + ...(intents.length ? { intents } : {}), + }; return { - dispatchModels: { - ...(Object.keys(languages).length ? { languages } : {}), - ...(intents.length ? { intents } : {}), - }, + ...(Object.keys(dispatchModels).length ? { dispatchModels } : {}), }; }; diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx index 72770b5497..df49f376c0 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx @@ -18,6 +18,7 @@ import { dispatcherState, luFilesState, skillManifestsState, + qnaFilesState, } from '../../../recoilModel'; import { editorSteps, ManifestEditorSteps, order } from './constants'; @@ -34,6 +35,7 @@ const ExportSkillModal: React.FC = ({ onSubmit, onDismiss const dialogs = useRecoilValue(dialogsState); const dialogSchemas = useRecoilValue(dialogSchemasState); const luFiles = useRecoilValue(luFilesState); + const qnaFiles = useRecoilValue(qnaFilesState); const skillManifests = useRecoilValue(skillManifestsState); const { updateSkillManifest } = useRecoilValue(dispatcherState); @@ -59,6 +61,7 @@ const ExportSkillModal: React.FC = ({ onSubmit, onDismiss dialogs, dialogSchemas, luFiles, + qnaFiles, selectedTriggers, selectedDialogs ); diff --git a/Composer/packages/server/__tests__/controllers/project.test.ts b/Composer/packages/server/__tests__/controllers/project.test.ts index 01e30225c0..10f67abbaa 100644 --- a/Composer/packages/server/__tests__/controllers/project.test.ts +++ b/Composer/packages/server/__tests__/controllers/project.test.ts @@ -187,7 +187,7 @@ describe('dialog operation', () => { const mockReq = { params: { projectId }, query: {}, - body: { name: 'bot1.dialog', content: '' }, + body: { name: 'bot1.dialog', content: JSON.stringify({ $kind: 'aaa' }) }, } as Request; await ProjectController.updateFile(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -196,7 +196,7 @@ describe('dialog operation', () => { const mockReq = { params: { projectId }, query: {}, - body: { name: 'test2.dialog', content: '' }, + body: { name: 'test2.dialog', content: JSON.stringify({ $kind: 'aaa' }) }, } as Request; await ProjectController.createFile(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index a5af9ced8e..084d40386a 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -370,6 +370,7 @@ export class BotProject implements IBotProject { } const relativePath = file.relativePath; + this._validateFileContent(name, content); const lastModified = await this._updateFile(relativePath, content); return lastModified; }; @@ -414,6 +415,7 @@ export class BotProject implements IBotProject { public createFile = async (name: string, content = '') => { const filename = name.trim(); this.validateFileName(filename); + this._validateFileContent(name, content); const botName = this.name; const defaultLocale = this.settings?.defaultLanguage || defaultLanguage; const relativePath = defaultFilePath(botName, defaultLocale, filename); @@ -610,6 +612,9 @@ export class BotProject implements IBotProject { // to root dir instead of dataDir dataDir is not aware at this layer private _createFile = async (relativePath: string, content: string) => { const absolutePath = Path.resolve(this.dir, relativePath); + if (!absolutePath.startsWith(this.dir)) { + throw new Error('Cannot create file outside of current project folder'); + } await this.ensureDirExists(Path.dirname(absolutePath)); debug('Creating file: %s', absolutePath); await this.fileStorage.writeFile(absolutePath, content); @@ -640,7 +645,9 @@ export class BotProject implements IBotProject { } const absolutePath = `${this.dir}/${relativePath}`; - + if (!absolutePath.startsWith(this.dir)) { + throw new Error('Cannot update file outside of current project folder'); + } // only write if the file has actually changed if (file.content !== content) { file.content = content; @@ -666,7 +673,6 @@ export class BotProject implements IBotProject { const absolutePath = `${this.dir}/${relativePath}`; await this.fileStorage.removeFile(absolutePath); }; - // ensure dir exist, dir is a absolute dir path private ensureDirExists = async (dir: string) => { if (!dir || dir === '.') { @@ -764,4 +770,18 @@ export class BotProject implements IBotProject { }; } }; + + private _validateFileContent = (name: string, content: string) => { + const extension = Path.extname(name); + if (extension === '.dialog' || name === 'appsettings.json') { + try { + const parsedContent = JSON.parse(content); + if (typeof parsedContent !== 'object' || Array.isArray(parsedContent)) { + throw new Error('Invalid file content'); + } + } catch (e) { + throw new Error('Invalid file content'); + } + } + }; }