Skip to content

Commit d473077

Browse files
committed
Add --ftp option to for directory listing (python http.server style)
1 parent 1917d05 commit d473077

5 files changed

Lines changed: 135 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Fix [object Object] env vars leaked to fork mode subprocesses #6073
2525
- Rewrite TreeKill: single ps call + in-memory tree build, eliminates race conditions #6084
2626
- Fix Windows home path: use os.homedir() instead of HOMEPATH/HOMEDRIVE env vars #6106
27+
- Add --ftp option to `pm2 serve` for directory listing (python http.server style)
2728

2829
## 6.0.14
2930

lib/API.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,7 @@ class API {
974974
PM2_SERVE_HOST: serve.host,
975975
PM2_SERVE_PATH: serve.path,
976976
PM2_SERVE_SPA: serve.spa,
977+
PM2_SERVE_FTP: serve.ftp,
977978
PM2_SERVE_DIRECTORY: serve.directory,
978979
PM2_SERVE_BASIC_AUTH: serve.basic_auth !== undefined,
979980
PM2_SERVE_BASIC_AUTH_USERNAME: serve.basic_auth ? serve.basic_auth.username : null,

lib/API/Extra.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ module.exports = function(CLI) {
570570
opts.env.PM2_SERVE_PORT = servePort;
571571
opts.env.PM2_SERVE_PATH = servePath;
572572
opts.env.PM2_SERVE_SPA = opts.spa;
573+
opts.env.PM2_SERVE_FTP = opts.ftp || false;
573574
if (opts.basicAuthUsername && opts.basicAuthPassword) {
574575
opts.env.PM2_SERVE_BASIC_AUTH = 'true';
575576
opts.env.PM2_SERVE_BASIC_AUTH_USERNAME = opts.basicAuthUsername;

lib/API/Serve.js

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ var options = {
198198
host: process.env.PM2_SERVE_HOST || process.argv[4] || '0.0.0.0',
199199
path: path.resolve(process.env.PM2_SERVE_PATH || process.argv[2] || '.'),
200200
spa: process.env.PM2_SERVE_SPA === 'true',
201+
ftp: process.env.PM2_SERVE_FTP === 'true',
201202
homepage: process.env.PM2_SERVE_HOMEPAGE || '/index.html',
202203
basic_auth: process.env.PM2_SERVE_BASIC_AUTH === 'true' ? {
203204
username: process.env.PM2_SERVE_BASIC_AUTH_USERNAME,
@@ -249,10 +250,6 @@ http.createServer(function (request, response) {
249250
function serveFile(uri, request, response) {
250251
var file = decodeURIComponent(new URL(uri || request.url, 'http://localhost').pathname);
251252

252-
if (file === '/' || file === '') {
253-
file = options.homepage;
254-
request.wantHomepage = true;
255-
}
256253
var filePath = path.resolve(options.path + file);
257254

258255
// since we call filesystem directly so we need to verify that the
@@ -262,6 +259,136 @@ function serveFile(uri, request, response) {
262259
return response.end('403 Forbidden');
263260
}
264261

262+
fs.stat(filePath, function (err, stats) {
263+
if (!err && stats.isDirectory()) {
264+
if (options.ftp) {
265+
// In ftp mode, try index.html first, fall back to directory listing
266+
var indexPath = path.join(filePath, 'index.html');
267+
fs.access(indexPath, fs.constants.R_OK, function (err) {
268+
if (!err) {
269+
return serveStaticFile(indexPath, file + '/index.html', request, response);
270+
}
271+
return serveDirectoryListing(filePath, file, response);
272+
});
273+
return;
274+
}
275+
// Not ftp mode: serve homepage
276+
if (file === '/' || file === '') {
277+
file = options.homepage;
278+
request.wantHomepage = true;
279+
filePath = path.resolve(options.path + file);
280+
}
281+
} else if (file === '/' || file === '') {
282+
file = options.homepage;
283+
request.wantHomepage = true;
284+
filePath = path.resolve(options.path + file);
285+
}
286+
serveStaticFile(filePath, file, request, response);
287+
});
288+
}
289+
290+
function formatFileSize(bytes) {
291+
if (bytes < 1024) return bytes + 'B';
292+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
293+
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'M';
294+
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'G';
295+
}
296+
297+
function serveDirectoryListing(dirPath, urlPath, response) {
298+
fs.readdir(dirPath, function (err, entries) {
299+
if (err) {
300+
response.writeHead(500);
301+
return response.end('Error reading directory');
302+
}
303+
304+
// Ensure urlPath ends with /
305+
if (urlPath && urlPath[urlPath.length - 1] !== '/') urlPath += '/';
306+
if (!urlPath) urlPath = '/';
307+
308+
var pending = entries.length;
309+
var items = [];
310+
311+
if (pending === 0) {
312+
return renderDirectoryListing(dirPath, urlPath, items, response);
313+
}
314+
315+
entries.forEach(function (entry) {
316+
// Skip dotfiles
317+
if (entry[0] === '.') {
318+
if (--pending === 0) renderDirectoryListing(dirPath, urlPath, items, response);
319+
return;
320+
}
321+
fs.stat(path.join(dirPath, entry), function (err, stats) {
322+
if (!err) {
323+
items.push({
324+
name: entry,
325+
isDir: stats.isDirectory(),
326+
size: stats.size,
327+
mtime: stats.mtime
328+
});
329+
}
330+
if (--pending === 0) renderDirectoryListing(dirPath, urlPath, items, response);
331+
});
332+
});
333+
});
334+
}
335+
336+
function renderDirectoryListing(dirPath, urlPath, items, response) {
337+
// Directories first, then files, alphabetical within each group
338+
items.sort(function (a, b) {
339+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
340+
return a.name.localeCompare(b.name);
341+
});
342+
343+
var escHtml = function(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); };
344+
345+
var rows = '';
346+
347+
// Parent directory link (unless at root)
348+
if (urlPath !== '/') {
349+
rows += '<tr><td class="name"><a href="../">../</a></td><td class="size">-</td><td class="mtime">-</td></tr>\n';
350+
}
351+
352+
items.forEach(function (item) {
353+
var displayName = item.isDir ? item.name + '/' : item.name;
354+
var href = encodeURIComponent(item.name) + (item.isDir ? '/' : '');
355+
var size = item.isDir ? '-' : formatFileSize(item.size);
356+
var mtime = item.mtime.toISOString().replace('T', ' ').slice(0, 19);
357+
rows += '<tr>'
358+
+ '<td class="name"><a href="' + escHtml(href) + '">' + escHtml(displayName) + '</a></td>'
359+
+ '<td class="size">' + size + '</td>'
360+
+ '<td class="mtime">' + mtime + '</td>'
361+
+ '</tr>\n';
362+
});
363+
364+
var html = '<!DOCTYPE html>\n<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">'
365+
+ '<title>Index of ' + escHtml(urlPath) + '</title>'
366+
+ '<style>'
367+
+ 'body{font-family:monospace;margin:20px;background:#1e1e1e;color:#ccc}'
368+
+ 'h1{font-size:1.2em;border-bottom:1px solid #444;padding-bottom:8px}'
369+
+ 'table{border-collapse:collapse;width:100%}'
370+
+ 'th,td{text-align:left;padding:4px 12px}'
371+
+ 'th{border-bottom:2px solid #444;font-size:0.9em;color:#888}'
372+
+ 'tr:hover{background:#2a2a2a}'
373+
+ '.size,.mtime{white-space:nowrap}'
374+
+ '.size{text-align:right}'
375+
+ 'a{color:#58a6ff;text-decoration:none}'
376+
+ 'a:hover{text-decoration:underline}'
377+
+ '</style></head><body>'
378+
+ '<h1>Index of ' + escHtml(urlPath) + '</h1>'
379+
+ '<table><thead><tr><th>Name</th><th class="size">Size</th><th>Last Modified</th></tr></thead>'
380+
+ '<tbody>' + rows + '</tbody></table>'
381+
+ '</body></html>';
382+
383+
response.writeHead(200, {
384+
'Content-Type': 'text/html',
385+
'Access-Control-Allow-Origin': '*',
386+
'Access-Control-Allow-Methods': 'GET'
387+
});
388+
response.end(html, 'utf-8');
389+
}
390+
391+
function serveStaticFile(filePath, file, request, response) {
265392
var contentType = contentTypes[filePath.split('.').pop().toLowerCase()] || 'text/plain';
266393

267394
fs.readFile(filePath, function (error, content) {

lib/binaries/CLI.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,7 @@ commander.command('serve [path] [port]')
997997
.alias('expose')
998998
.option('--port [port]', 'specify port to listen to')
999999
.option('--spa', 'always serving index.html on inexistant sub path')
1000+
.option('--ftp', 'directory listing like python http.server')
10001001
.option('--basic-auth-username [username]', 'set basic auth username')
10011002
.option('--basic-auth-password [password]', 'set basic auth password')
10021003
.option('--monitor [frontend-app]', 'frontend app monitoring (auto integrate snippet on html files)')

0 commit comments

Comments
 (0)