Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/server/HelixProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,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`);
Expand Down
53 changes: 53 additions & 0 deletions src/server/HelixServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,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();
}

Expand Down Expand Up @@ -325,6 +329,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('..')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need both checks ? isn't ..` sufficient?

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
Expand Down
1 change: 0 additions & 1 deletion src/up.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export default function up() {
describe: 'URL path where html-folder files are served (e.g., / for root). Defaults to /FOLDER.',
type: 'string',
})

.help();
},
handler: async (argv) => {
Expand Down
147 changes: 147 additions & 0 deletions test/server-html-folder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
});
Loading