diff --git a/src/server/HelixProject.js b/src/server/HelixProject.js index 0552dc85d..e4c195ffb 100644 --- a/src/server/HelixProject.js +++ b/src/server/HelixProject.js @@ -228,7 +228,10 @@ export class HelixProject extends BaseProject { await lstat(htmlFolderPath); this.log.debug(`Registered HTML folder for live-reload: ${this._htmlFolder}`); // Watch all HTML files in the folder - only .html extension - this.liveReload.registerFiles([`${htmlFolderPath}/**/*.html`], this._server.mountPrefix); + this.liveReload.registerFiles([ + `${htmlFolderPath}/**/*.html`, + `${htmlFolderPath}/**/*.json`, + ], this._server.mountPrefix); } catch (e) { this.log.error(`HTML folder '${this._htmlFolder}' does not exist`); throw new Error(`HTML folder '${this._htmlFolder}' does not exist`); diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index 5a4676c17..62913f2d3 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -307,6 +307,10 @@ export class HelixServer extends BaseServer { // Resolve which file to serve (.html or .plain.html) const resolvedFile = await this.resolveHtmlFolderFile(relativePath); if (!resolvedFile) { + // Serve static assets (e.g. .json) from the html-folder as-is + if (relativePath.includes('.')) { + return this.serveHtmlFolderStaticFile(req, res, next, relativePath); + } return next(); } @@ -348,6 +352,55 @@ export class HelixServer extends BaseServer { return undefined; } + /** + * Serves static files from the HTML folder as-is + * @param {Express.Request} req request + * @param {Express.Response} res response + * @param {Function} next next middleware + * @param {string} relativePath path relative to HTML folder + */ + async serveHtmlFolderStaticFile(req, res, next, relativePath) { + if (relativePath.includes('/../') || relativePath.includes('..')) { + return next(); + } + + const filePath = path.resolve( + this._project.directory, + this._htmlFolder, + relativePath, + ); + + if (!utils.validatePathSecurity( + filePath, + this._project.directory, + )) { + return next(); + } + + try { + const stats = await lstat(filePath); + if (!stats.isFile()) { + return next(); + } + } catch (e) { + return next(); + } + + const sendFile = promisify(res.sendFile).bind(res); + try { + await sendFile(filePath, { + dotfiles: 'allow', + headers: { 'access-control-allow-origin': '*' }, + }); + this.log.debug( + `served static file ${filePath} for ${req.url}`, + ); + } catch (e) { + return next(); + } + return undefined; + } + /** * Proxy Mode route handler * @param {Express.Request} req request diff --git a/test/server-html-folder.test.js b/test/server-html-folder.test.js index b8fb36b5e..337ec32b8 100644 --- a/test/server-html-folder.test.js +++ b/test/server-html-folder.test.js @@ -1423,4 +1423,151 @@ describe('Helix Server - HTML Folder', () => { } }); }); + + describe('Static file serving via --html-folder', () => { + it('serves .json files as-is from html-folder', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); + const formsFolder = path.join(cwd, 'forms'); + await fs.mkdir(formsFolder, { recursive: true }); + const formJson = JSON.stringify({ id: 'my-form', items: [] }); + await fs.writeFile( + path.join(formsFolder, 'my-form.json'), + formJson, + ); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms'); + + await project.init(); + try { + await project.start(); + + const response = await fetch( + `http://127.0.0.1:${project.server.port}/forms/my-form.json`, + ); + assert.equal(response.status, 200); + assert.ok( + response.headers.get('content-type').includes('application/json'), + 'Should serve with JSON content-type', + ); + + const body = await response.json(); + assert.equal(body.id, 'my-form'); + } finally { + await project.stop(); + } + }); + + it('serves static files from nested directories', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); + const nested = path.join(cwd, 'forms', 'nested'); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile( + path.join(nested, 'data.json'), + JSON.stringify({ id: 'nested' }), + ); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms'); + + await project.init(); + try { + await project.start(); + + const response = await fetch( + `http://127.0.0.1:${project.server.port}/forms/nested/data.json`, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, 'nested'); + } finally { + await project.stop(); + } + }); + + it('blocks path traversal for static files', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); + const formsFolder = path.join(cwd, 'forms'); + await fs.mkdir(formsFolder, { recursive: true }); + await fs.writeFile( + path.join(formsFolder, 'safe.json'), + '{}', + ); + await fs.writeFile( + path.join(cwd, 'secret.json'), + '{"secret":true}', + ); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms'); + + await project.init(); + try { + await project.start(); + + const response = await fetch( + `http://127.0.0.1:${project.server.port}/forms/../secret.json`, + ); + assert.notEqual( + response.status, + 200, + 'Should not serve files outside html-folder', + ); + } finally { + await project.stop(); + } + }); + + it('serves static files with custom mount', async () => { + const cwd = await setupProject( + path.join(__rootdir, 'test', 'fixtures', 'project'), + testRoot, + ); + const formsFolder = path.join(cwd, 'forms'); + await fs.mkdir(formsFolder, { recursive: true }); + await fs.writeFile( + path.join(formsFolder, 'xpl.json'), + JSON.stringify({ id: 'xpl-form' }), + ); + + const project = new HelixProject() + .withCwd(cwd) + .withLogger(console) + .withHttpPort(0) + .withHtmlFolder('forms') + .withHtmlMount('/content/forms/af/'); + + await project.init(); + try { + await project.start(); + + const response = await fetch( + `http://127.0.0.1:${project.server.port}/content/forms/af/xpl.json`, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.id, 'xpl-form'); + } finally { + await project.stop(); + } + }); + }); });