Skip to content

Commit a65193e

Browse files
authored
feat(chat): add runtime additional_css folder support (Issue #7125) (#7126)
1 parent 4012103 commit a65193e

7 files changed

Lines changed: 397 additions & 0 deletions

File tree

apps/chat/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ DIAL Chat uses environment variables for configuration. All environment variable
127127
| `DEFAULT_MODEL` | No | A model that will be used for the new conversation if the recently-used models or recently-used models from local storage are not available. If none of the above is available - the first model from the DIAL Core config will be used as a pre-selected conversation model. `Reference` or `ID` of the agent. | Any string | First available model from [DIAL Core](https://github.com/epam/ai-dial-core?tab=readme-ov-file#dynamic-settings) config listing |
128128
| `RECENT_MODELS_IDS` | No | A list of `references` or `IDs` for recently-used models | Any string | |
129129
| `THEMES_CONFIG_HOST` | No | The host URL for a custom themes configuration.<br />Can lead to public and private resource.<br />Refer to [Theme Customization](../../docs/THEME-CUSTOMIZATION.md) to learn more. | Any string | |
130+
| `ADDITIONAL_CSS_DIR` | No | Absolute path to a directory of `.css` files injected into every page.<br />Refer to [Theme Customization](../../docs/THEME-CUSTOMIZATION.md#additional-css) to learn more. | Any string | `<cwd>/additional_css` |
130131
| `QUICK_APPS_HOST` | No | The host URL for a Quick Apps Completion URLs.<br />Can lead to public and private resource. more. | Any string | |
131132
| `QUICK_APPS_SCHEMA_ID` | No | `applicationTypeSchemaId` value for QuickApp application type | Any string | |
132133
| `QUICK_APPS_MODEL` | No | A model that will be used in Quick Apps | Any string | |

apps/chat/src/pages/_document.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DocumentProps, Head, Html, Main, NextScript } from 'next/document';
22
import Script from 'next/script';
33

44
import { isRtlLocale } from '@/src/utils/app/rtl';
5+
import { getAdditionalCssFilenames } from '@/src/utils/server/additional-css';
56

67
import i18nextConfig from '@/next-i18next.config';
78
import { documentWithJss } from '@epam/ai-dial-modulify-ui';
@@ -15,6 +16,7 @@ function Document(props: Props) {
1516
props.__NEXT_DATA__.locale ?? i18nextConfig.i18n.defaultLocale;
1617

1718
const dir = isRtlLocale(currentLocale) ? 'rtl' : 'ltr';
19+
const additionalCssFiles = getAdditionalCssFilenames();
1820

1921
return (
2022
<Html lang={currentLocale} dir={dir}>
@@ -34,6 +36,13 @@ function Document(props: Props) {
3436
{!!process.env.THEMES_CONFIG_HOST && (
3537
<link rel="stylesheet" href={'/api/themes/styles'} />
3638
)}
39+
{additionalCssFiles.map((file) => (
40+
<link
41+
key={file}
42+
rel="stylesheet"
43+
href={`/api/additional-css/${file}`}
44+
/>
45+
))}
3746
<link rel="manifest" href="/api/manifest" />
3847
</Head>
3948
<body>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
import {
4+
isValidAdditionalCssFilename,
5+
readAdditionalCssFile,
6+
} from '@/src/utils/server/additional-css';
7+
8+
const handler = (req: NextApiRequest, res: NextApiResponse) => {
9+
const filename = req.query.filename;
10+
11+
if (!filename || Array.isArray(filename)) {
12+
return res.status(400).send('Filename not provided');
13+
}
14+
15+
if (!isValidAdditionalCssFilename(filename)) {
16+
return res.status(400).send('Invalid filename');
17+
}
18+
19+
const content = readAdditionalCssFile(filename);
20+
21+
if (content === null) {
22+
return res.status(404).send('CSS file not found');
23+
}
24+
25+
return res.status(200).setHeader('Content-Type', 'text/css').send(content);
26+
};
27+
28+
export default handler;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { NextApiRequest, NextApiResponse } from 'next';
4+
5+
import handler from '../[filename]';
6+
7+
const { mockReadAdditionalCssFile, mockIsValidAdditionalCssFilename } =
8+
vi.hoisted(() => ({
9+
mockReadAdditionalCssFile: vi.fn(),
10+
mockIsValidAdditionalCssFilename: vi.fn(),
11+
}));
12+
13+
vi.mock('@/src/utils/server/additional-css', () => ({
14+
isValidAdditionalCssFilename: mockIsValidAdditionalCssFilename,
15+
readAdditionalCssFile: mockReadAdditionalCssFile,
16+
}));
17+
18+
describe('additional-css/[filename] handler', () => {
19+
const mockSend = vi.fn();
20+
const mockSetHeader = vi.fn(() => ({ send: mockSend }));
21+
const mockStatus = vi.fn(() => ({
22+
send: mockSend,
23+
setHeader: mockSetHeader,
24+
}));
25+
26+
const createResponse = () =>
27+
({
28+
status: mockStatus,
29+
setHeader: mockSetHeader,
30+
send: mockSend,
31+
}) as unknown as NextApiResponse;
32+
33+
beforeEach(() => {
34+
vi.resetAllMocks();
35+
mockIsValidAdditionalCssFilename.mockReturnValue(true);
36+
mockReadAdditionalCssFile.mockReturnValue('body { color: red; }');
37+
});
38+
39+
it('should return css content with text/css content type', () => {
40+
// Arrange
41+
const req = {
42+
query: { filename: 'custom.css' },
43+
} as unknown as NextApiRequest;
44+
const res = createResponse();
45+
46+
// Act
47+
handler(req, res);
48+
49+
// Assert
50+
expect(mockStatus).toHaveBeenCalledWith(200);
51+
expect(mockSetHeader).toHaveBeenCalledWith('Content-Type', 'text/css');
52+
expect(mockSend).toHaveBeenCalledWith('body { color: red; }');
53+
});
54+
55+
it('should return 400 when filename is missing', () => {
56+
// Arrange
57+
const req = { query: {} } as unknown as NextApiRequest;
58+
const res = createResponse();
59+
60+
// Act
61+
handler(req, res);
62+
63+
// Assert
64+
expect(mockStatus).toHaveBeenCalledWith(400);
65+
expect(mockSend).toHaveBeenCalledWith('Filename not provided');
66+
});
67+
68+
it('should return 400 when filename is an array', () => {
69+
// Arrange
70+
const req = {
71+
query: { filename: ['custom.css'] },
72+
} as unknown as NextApiRequest;
73+
const res = createResponse();
74+
75+
// Act
76+
handler(req, res);
77+
78+
// Assert
79+
expect(mockStatus).toHaveBeenCalledWith(400);
80+
expect(mockSend).toHaveBeenCalledWith('Filename not provided');
81+
});
82+
83+
it('should return 400 for invalid filenames', () => {
84+
// Arrange
85+
mockIsValidAdditionalCssFilename.mockReturnValue(false);
86+
const req = {
87+
query: { filename: '../secret.css' },
88+
} as unknown as NextApiRequest;
89+
const res = createResponse();
90+
91+
// Act
92+
handler(req, res);
93+
94+
// Assert
95+
expect(mockStatus).toHaveBeenCalledWith(400);
96+
expect(mockSend).toHaveBeenCalledWith('Invalid filename');
97+
});
98+
99+
it('should return 404 when css file is not found', () => {
100+
// Arrange
101+
mockReadAdditionalCssFile.mockReturnValue(null);
102+
const req = {
103+
query: { filename: 'missing.css' },
104+
} as unknown as NextApiRequest;
105+
const res = createResponse();
106+
107+
// Act
108+
handler(req, res);
109+
110+
// Assert
111+
expect(mockStatus).toHaveBeenCalledWith(404);
112+
expect(mockSend).toHaveBeenCalledWith('CSS file not found');
113+
});
114+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import {
4+
getAdditionalCssDir,
5+
getAdditionalCssFilenames,
6+
isValidAdditionalCssFilename,
7+
readAdditionalCssFile,
8+
} from '../additional-css';
9+
10+
import path from 'path';
11+
12+
const { mockReaddirSync, mockReadFileSync } = vi.hoisted(() => ({
13+
mockReaddirSync: vi.fn(),
14+
mockReadFileSync: vi.fn(),
15+
}));
16+
17+
vi.mock('fs', () => ({
18+
default: {
19+
readdirSync: mockReaddirSync,
20+
readFileSync: mockReadFileSync,
21+
},
22+
readdirSync: mockReaddirSync,
23+
readFileSync: mockReadFileSync,
24+
}));
25+
26+
describe('getAdditionalCssDir', () => {
27+
beforeEach(() => {
28+
delete process.env.ADDITIONAL_CSS_DIR;
29+
});
30+
31+
it('should use ADDITIONAL_CSS_DIR when set', () => {
32+
// Arrange
33+
process.env.ADDITIONAL_CSS_DIR = '/custom/css';
34+
35+
// Act
36+
const result = getAdditionalCssDir();
37+
38+
// Assert
39+
expect(result).toBe('/custom/css');
40+
});
41+
42+
it('should fall back to cwd/additional_css when env var is not set', () => {
43+
// Act
44+
const result = getAdditionalCssDir();
45+
46+
// Assert
47+
expect(result).toBe(path.join(process.cwd(), 'additional_css'));
48+
});
49+
});
50+
51+
describe('getAdditionalCssFilenames', () => {
52+
beforeEach(() => {
53+
vi.resetAllMocks();
54+
});
55+
56+
it('should return sorted css filenames when folder exists', () => {
57+
// Arrange
58+
mockReaddirSync.mockReturnValue(['zebra.css', 'alpha.css', 'notes.txt']);
59+
60+
// Act
61+
const result = getAdditionalCssFilenames('/css');
62+
63+
// Assert
64+
expect(result).toEqual(['alpha.css', 'zebra.css']);
65+
expect(mockReaddirSync).toHaveBeenCalledWith('/css');
66+
});
67+
68+
it('should exclude non-css files', () => {
69+
// Arrange
70+
mockReaddirSync.mockReturnValue(['styles.css', 'readme.md', 'script.js']);
71+
72+
// Act
73+
const result = getAdditionalCssFilenames('/css');
74+
75+
// Assert
76+
expect(result).toEqual(['styles.css']);
77+
});
78+
79+
it('should return an empty array when folder does not exist', () => {
80+
// Arrange
81+
mockReaddirSync.mockImplementation(() => {
82+
const error = new Error('ENOENT') as NodeJS.ErrnoException;
83+
error.code = 'ENOENT';
84+
throw error;
85+
});
86+
87+
// Act
88+
const result = getAdditionalCssFilenames('/missing');
89+
90+
// Assert
91+
expect(result).toEqual([]);
92+
});
93+
94+
it('should return an empty array when readdirSync throws any error', () => {
95+
// Arrange
96+
mockReaddirSync.mockImplementation(() => {
97+
throw new Error('Permission denied');
98+
});
99+
100+
// Act
101+
const result = getAdditionalCssFilenames('/restricted');
102+
103+
// Assert
104+
expect(result).toEqual([]);
105+
});
106+
});
107+
108+
describe('isValidAdditionalCssFilename', () => {
109+
it.each([
110+
['custom.css', true],
111+
['nested/file.css', false],
112+
['..\\secret.css', false],
113+
['../secret.css', false],
114+
['styles.txt', false],
115+
])('isValidAdditionalCssFilename(%s) = %s', (filename, expected) => {
116+
expect(isValidAdditionalCssFilename(filename)).toBe(expected);
117+
});
118+
});
119+
120+
describe('readAdditionalCssFile', () => {
121+
beforeEach(() => {
122+
vi.resetAllMocks();
123+
delete process.env.ADDITIONAL_CSS_DIR;
124+
});
125+
126+
it('should return file contents for a valid css filename', () => {
127+
// Arrange
128+
process.env.ADDITIONAL_CSS_DIR = '/css';
129+
mockReadFileSync.mockReturnValue('body { color: red; }');
130+
131+
// Act
132+
const result = readAdditionalCssFile('custom.css');
133+
134+
// Assert
135+
expect(result).toBe('body { color: red; }');
136+
expect(mockReadFileSync).toHaveBeenCalledWith('/css/custom.css', 'utf-8');
137+
});
138+
139+
it('should return null for an invalid filename', () => {
140+
// Act
141+
const result = readAdditionalCssFile('../secret.css');
142+
143+
// Assert
144+
expect(result).toBeNull();
145+
expect(mockReadFileSync).not.toHaveBeenCalled();
146+
});
147+
148+
it('should return null when the file is missing', () => {
149+
// Arrange
150+
mockReadFileSync.mockImplementation(() => {
151+
throw new Error('ENOENT');
152+
});
153+
154+
// Act
155+
const result = readAdditionalCssFile('missing.css');
156+
157+
// Assert
158+
expect(result).toBeNull();
159+
});
160+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
export function getAdditionalCssDir(): string {
5+
return (
6+
process.env.ADDITIONAL_CSS_DIR ?? path.join(process.cwd(), 'additional_css')
7+
);
8+
}
9+
10+
export function getAdditionalCssFilenames(dir?: string): string[] {
11+
const targetDir = dir ?? getAdditionalCssDir();
12+
13+
try {
14+
return fs
15+
.readdirSync(targetDir)
16+
.filter((file) => file.endsWith('.css'))
17+
.sort();
18+
} catch {
19+
return [];
20+
}
21+
}
22+
23+
export function isValidAdditionalCssFilename(filename: string): boolean {
24+
if (!filename.endsWith('.css')) {
25+
return false;
26+
}
27+
28+
if (
29+
filename.includes('..') ||
30+
filename.includes('/') ||
31+
filename.includes('\\')
32+
) {
33+
return false;
34+
}
35+
36+
return true;
37+
}
38+
39+
export function readAdditionalCssFile(filename: string): string | null {
40+
if (!isValidAdditionalCssFilename(filename)) {
41+
return null;
42+
}
43+
44+
const filePath = path.join(getAdditionalCssDir(), filename);
45+
46+
try {
47+
return fs.readFileSync(filePath, 'utf-8');
48+
} catch {
49+
return null;
50+
}
51+
}

0 commit comments

Comments
 (0)