webtun 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -77,13 +77,18 @@ const MIME_MAP = {
77
77
  '.go':'text/x-go','.rs':'text/x-rust','.php':'text/x-php','.pl':'text/x-perl',
78
78
  '.sql':'application/sql','.graphql':'application/graphql',
79
79
  '.yaml':'text/yaml','.yml':'text/yaml','.toml':'application/toml','.ini':'text/plain',
80
- '.env':'text/plain','.lock':'text/plain',
80
+ '.env':'text/plain','.lock':'text/plain','.epub':'application/epub+zip',
81
81
  };
82
82
  function mimeLookup(filePath) {
83
83
  const ext = path.extname(filePath).toLowerCase();
84
84
  return MIME_MAP[ext] || 'application/octet-stream';
85
85
  }
86
86
 
87
+ // Binary extensions — preview not supported in editor (client has same list). Keep in sync.
88
+ const BINARY_EXTS = new Set([
89
+ '3dm','3ds','3g2','3gp','7z','a','aac','adp','afdesign','afphoto','afpub','ai','aif','aiff','alz','ape','apk','appimage','ar','arj','asf','au','avi','bak','baml','bh','bin','bk','bmp','btif','bz2','bzip2','cab','caf','cgm','class','cmx','cpio','cr2','cr3','cur','dat','dcm','deb','dex','djvu','dll','dmg','dng','doc','docm','docx','dot','dotm','dra','ds_store','dsk','dts','dtshd','dvb','dwg','dxf','ecelp4800','ecelp7470','ecelp9600','egg','eol','eot','epub','exe','f4v','fbs','fh','fla','flac','flatpak','fli','flv','fpx','fst','fvt','g3','gh','gif','graffle','gz','gzip','h261','h263','h264','icns','ico','ief','img','ipa','iso','jar','jpeg','jpg','jpgv','jpm','jxr','key','ktx','lha','lib','lvp','lz','lzh','lzma','lzo','m3u','m4a','m4v','mar','mdi','mht','mid','midi','mj2','mka','mkv','mmr','mng','mobi','mov','movie','mp3','mp4','mp4a','mpeg','mpg','mpga','mxu','nef','npx','numbers','nupkg','o','odp','ods','odt','oga','ogg','ogv','otf','ott','pages','pbm','pcx','pdb','pdf','pea','pgm','pic','png','pnm','pot','potm','potx','ppa','ppam','ppm','pps','ppsm','ppsx','ppt','pptm','pptx','psd','pya','pyc','pyo','pyv','qt','rar','ras','raw','resources','rgb','rip','rlc','rmf','rmvb','rpm','rtf','rz','s3m','s7z','scpt','sgi','shar','snap','sil','sketch','slk','smv','snk','so','stl','suo','sub','swf','tar','tbz','tbz2','tga','tgz','thmx','tif','tiff','tlz','ttc','ttf','txz','udf','uvh','uvi','uvm','uvp','uvs','uvu','viv','vob','war','wav','wax','wbmp','wdp','weba','webm','webp','whl','wim','wm','wma','wmv','wmx','woff','woff2','wrm','wvx','xbm','xif','xla','xlam','xls','xlsb','xlsm','xlsx','xlt','xltm','xltx','xm','xmind','xpi','xpm','xwd','xz','z','zip','zipx'
90
+ ]);
91
+
87
92
  // In-memory rate limiter factory
88
93
  // NOTE: Behind cloudflared tunnel every remote IP appears as 127.0.0.1 (tunnel collapses to loopback).
89
94
  // We use req.ip (Express respects app.set('trust proxy')) so limiter correctly respects trust proxy config.
@@ -132,7 +137,7 @@ const SHELL = (os.platform() === 'win32' && !process.env.WEBTUN_SHELL)
132
137
  ? 'powershell.exe'
133
138
  : (process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh')));
134
139
  const HOST = process.env.HOST || '0.0.0.0';
135
- const ALLOW_FULL_FS = process.env.ALLOW_FULL_FS === 'true';
140
+ const ALLOW_FULL_FS = process.env.ALLOW_FULL_FS !== 'false';
136
141
  const WORKSPACE_ROOT = (() => {
137
142
  let ws = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
138
143
  if (!ws || ws.trim() === '') ws = os.homedir() || process.cwd();
@@ -169,7 +174,7 @@ app.use((req, res, next) => {
169
174
  res.setHeader('X-Frame-Options', 'DENY');
170
175
  res.setHeader('Referrer-Policy', 'no-referrer');
171
176
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
172
- res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' https://*.trycloudflare.com wss:; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src 'self' data: https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:;");
177
+ res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' https://*.trycloudflare.com wss: blob:; script-src 'self' https://cdn.jsdelivr.net blob:; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; font-src 'self' data: https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data: blob:; frame-src 'self' blob:; child-src 'self' blob:; worker-src 'self' blob: https://cdn.jsdelivr.net;");
173
178
  next();
174
179
  });
175
180
 
@@ -613,7 +618,7 @@ app.get('/api/files', checkPin, async (req, res) => {
613
618
  const full = path.join(dir, e.name);
614
619
  const st = await safeStat(full);
615
620
  files.push({
616
- name: e.name, path: full, isDir: e.isDirectory(),
621
+ name: e.name, path: full, isDir: st ? st.isDirectory() : e.isDirectory(),
617
622
  isSymlink: e.isSymbolicLink(), size: st ? st.size : 0,
618
623
  modified: st ? st.mtime : null, ext: path.extname(e.name).toLowerCase()
619
624
  });
@@ -633,11 +638,14 @@ app.get('/api/files', checkPin, async (req, res) => {
633
638
  entries.map(async e => {
634
639
  const full = path.join(dir, e.name);
635
640
  const st = await safeStat(full);
641
+ const isSymlink = e.isSymbolicLink();
642
+ // Use stat result for isDir so symlink→dir is navigable; Dirent.isDirectory() is false for symlink
643
+ const isDir = st ? st.isDirectory() : e.isDirectory();
636
644
  return {
637
645
  name: e.name,
638
646
  path: full,
639
- isDir: e.isDirectory(),
640
- isSymlink: e.isSymbolicLink(),
647
+ isDir,
648
+ isSymlink,
641
649
  size: st ? st.size : 0,
642
650
  modified: st ? st.mtime : null,
643
651
  ext: path.extname(e.name).toLowerCase()
@@ -648,8 +656,10 @@ app.get('/api/files', checkPin, async (req, res) => {
648
656
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
649
657
  return a.name.localeCompare(b.name);
650
658
  });
651
- const parent = path.dirname(dir);
652
- res.json({ path: dir, parent: parent !== dir ? parent : null, files });
659
+ let parent = path.dirname(dir);
660
+ if (parent === dir) parent = null;
661
+ else if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, parent)) parent = null;
662
+ res.json({ path: dir, parent, files });
653
663
  } catch (e) {
654
664
  const status = e.status || 500;
655
665
  res.status(status).json({ error: e.message });
@@ -901,7 +911,15 @@ app.get('/api/files/read', checkPin, async (req, res) => {
901
911
  const st = await fsPromises.stat(p);
902
912
  if (st.isDirectory()) return res.status(400).json({ error: 'Cannot read a directory' });
903
913
  if (st.size > 10 * 1024 * 1024) return res.status(413).json({ error: 'File too large (max 10MB) - use download' });
904
- const content = await fsPromises.readFile(p, 'utf8');
914
+ const ext = path.extname(p).toLowerCase().slice(1);
915
+ if (ext && BINARY_EXTS.has(ext)) {
916
+ return res.status(415).json({ error: 'Preview not supported for binary files - use download', isBinary: true });
917
+ }
918
+ const buf = await fsPromises.readFile(p);
919
+ if (buf.includes(0)) {
920
+ return res.status(415).json({ error: 'Preview not supported for binary files - use download', isBinary: true });
921
+ }
922
+ const content = buf.toString('utf8');
905
923
  res.json({ content, length: st.size });
906
924
  } catch (e) {
907
925
  const status = e.status || 500;