gh-here 2.0.0 → 3.0.0

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/lib/server.js CHANGED
@@ -6,6 +6,7 @@ const { getGitStatus, getGitBranch, commitAllChanges, commitSelectedFiles, getGi
6
6
  const { isIgnoredByGitignore, getGitignoreRules } = require('./gitignore');
7
7
  const { renderDirectory, renderFileDiff, renderFile, renderNewFile } = require('./renderers');
8
8
  const { isImageFile, isBinaryFile } = require('./file-utils');
9
+ const { buildFileTree } = require('./file-tree-builder');
9
10
 
10
11
  /**
11
12
  * Express server setup and route handlers
@@ -18,6 +19,84 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
18
19
  app.use('/octicons', express.static(path.join(__dirname, '..', 'node_modules', '@primer', 'octicons', 'build')));
19
20
 
20
21
 
22
+ // API endpoint for file tree
23
+ app.get('/api/file-tree', (req, res) => {
24
+ try {
25
+ const showGitignored = req.query.showGitignored === 'true';
26
+ const gitignoreRules = getGitignoreRules(workingDir);
27
+ const tree = buildFileTree(workingDir, '', gitignoreRules, workingDir, showGitignored);
28
+ res.json({ success: true, tree });
29
+ } catch (error) {
30
+ res.status(500).json({ success: false, error: error.message });
31
+ }
32
+ });
33
+
34
+ // API endpoint for global file search
35
+ app.get('/api/search', (req, res) => {
36
+ try {
37
+ const query = req.query.q || '';
38
+ if (!query.trim()) {
39
+ return res.json({ success: true, results: [] });
40
+ }
41
+
42
+ const gitignoreRules = getGitignoreRules(workingDir);
43
+ const results = [];
44
+ const lowerQuery = query.toLowerCase();
45
+
46
+ function searchDirectory(dir, relativePath = '') {
47
+ const entries = fs.readdirSync(dir);
48
+
49
+ for (const entry of entries) {
50
+ const fullPath = path.join(dir, entry);
51
+ const relPath = path.join(relativePath, entry).replace(/\\/g, '/');
52
+
53
+ try {
54
+ const stats = fs.statSync(fullPath);
55
+
56
+ // Skip gitignored files
57
+ if (!isIgnoredByGitignore(fullPath, gitignoreRules, workingDir, stats.isDirectory())) {
58
+ // Check if filename matches
59
+ if (entry.toLowerCase().includes(lowerQuery)) {
60
+ results.push({
61
+ path: relPath,
62
+ name: entry,
63
+ isDirectory: stats.isDirectory(),
64
+ modified: stats.mtime
65
+ });
66
+ }
67
+
68
+ // Recurse into directories
69
+ if (stats.isDirectory()) {
70
+ searchDirectory(fullPath, relPath);
71
+ }
72
+ }
73
+ } catch (err) {
74
+ // Skip files we can't access
75
+ continue;
76
+ }
77
+ }
78
+ }
79
+
80
+ searchDirectory(workingDir);
81
+
82
+ // Sort results: exact matches first, then directories, then alphabetically
83
+ results.sort((a, b) => {
84
+ const aExact = a.name.toLowerCase() === lowerQuery ? 1 : 0;
85
+ const bExact = b.name.toLowerCase() === lowerQuery ? 1 : 0;
86
+ if (aExact !== bExact) return bExact - aExact;
87
+
88
+ if (a.isDirectory !== b.isDirectory) {
89
+ return a.isDirectory ? -1 : 1;
90
+ }
91
+ return a.path.localeCompare(b.path);
92
+ });
93
+
94
+ res.json({ success: true, results, query });
95
+ } catch (error) {
96
+ res.status(500).json({ success: false, error: error.message });
97
+ }
98
+ });
99
+
21
100
  // Download route
22
101
  app.get('/download', (req, res) => {
23
102
  const filePath = req.query.path || '';
@@ -48,7 +127,7 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
48
127
  // Main route - directory or file view
49
128
  app.get('/', async (req, res) => {
50
129
  const currentPath = req.query.path || '';
51
- const showGitignored = req.query.gitignore === 'false';
130
+ const showGitignored = req.query.showGitignored === 'true';
52
131
  const fullPath = path.join(workingDir, currentPath);
53
132
 
54
133
  // Get git status and branch info
@@ -61,36 +140,39 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
61
140
  if (stats.isDirectory()) {
62
141
  const gitignoreRules = getGitignoreRules(workingDir);
63
142
 
64
- let items = fs.readdirSync(fullPath).map(item => {
65
- const itemPath = path.join(fullPath, item);
66
- const itemStats = fs.statSync(itemPath);
67
- const absoluteItemPath = path.resolve(itemPath);
68
- const gitInfo = gitStatus[absoluteItemPath] || null;
69
-
70
- return {
71
- name: item,
72
- path: path.join(currentPath, item).replace(/\\/g, '/'),
73
- isDirectory: itemStats.isDirectory(),
74
- size: itemStats.size,
75
- modified: itemStats.mtime,
76
- gitStatus: gitInfo
77
- };
78
- });
79
-
80
- // Filter out gitignored files unless explicitly requested to show them
81
- if (!showGitignored) {
82
- items = items.filter(item => {
83
- const itemFullPath = path.join(fullPath, item.name);
84
- return !isIgnoredByGitignore(itemFullPath, gitignoreRules, workingDir, item.isDirectory);
143
+ // Optimize: Batch file system operations
144
+ const dirEntries = fs.readdirSync(fullPath);
145
+ const items = dirEntries
146
+ .map(item => {
147
+ const itemPath = path.join(fullPath, item);
148
+ const itemStats = fs.statSync(itemPath);
149
+ const absoluteItemPath = path.resolve(itemPath);
150
+ const gitInfo = gitStatus[absoluteItemPath] || null;
151
+
152
+ return {
153
+ name: item,
154
+ path: path.join(currentPath, item).replace(/\\/g, '/'),
155
+ isDirectory: itemStats.isDirectory(),
156
+ size: itemStats.size,
157
+ modified: itemStats.mtime,
158
+ gitStatus: gitInfo,
159
+ fullPath: itemPath
160
+ };
161
+ })
162
+ .filter(item => {
163
+ // Filter gitignored files unless explicitly requested
164
+ if (!showGitignored) {
165
+ return !isIgnoredByGitignore(item.fullPath, gitignoreRules, workingDir, item.isDirectory);
166
+ }
167
+ return true;
168
+ })
169
+ .sort((a, b) => {
170
+ // Sort: directories first, then alphabetically
171
+ if (a.isDirectory !== b.isDirectory) {
172
+ return a.isDirectory ? -1 : 1;
173
+ }
174
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
85
175
  });
86
- }
87
-
88
- // Sort items (directories first, then alphabetically)
89
- items.sort((a, b) => {
90
- if (a.isDirectory && !b.isDirectory) return -1;
91
- if (!a.isDirectory && b.isDirectory) return 1;
92
- return a.name.localeCompare(b.name);
93
- });
94
176
 
95
177
  res.send(renderDirectory(currentPath, items, showGitignored, gitBranch, gitStatus, workingDir));
96
178
  } else {
@@ -108,11 +190,11 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
108
190
  const absolutePath = path.resolve(fullPath);
109
191
  const gitInfo = gitStatus[absolutePath];
110
192
  if (gitInfo) {
111
- const diffHtml = await renderFileDiff(currentPath, ext, gitInfo, gitRepoRoot, workingDir);
193
+ const diffHtml = await renderFileDiff(currentPath, ext, gitInfo, gitRepoRoot, workingDir, gitBranch);
112
194
  return res.send(diffHtml);
113
195
  }
114
196
  }
115
-
197
+
116
198
  res.send(await renderFile(currentPath, content, ext, viewMode, gitStatus, workingDir));
117
199
  }
118
200
  } catch (error) {
@@ -143,6 +225,12 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
143
225
  }
144
226
 
145
227
  const content = fs.readFileSync(fullPath, 'utf-8');
228
+
229
+ // Set proper headers for raw file view (like GitHub)
230
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
231
+ res.setHeader('X-Content-Type-Options', 'nosniff');
232
+ res.setHeader('Content-Disposition', `inline; filename="${path.basename(fullPath)}"`);
233
+
146
234
  res.send(content);
147
235
  } catch (error) {
148
236
  res.status(404).send(`File not found: ${error.message}`);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Input validation and security utilities
3
+ */
4
+
5
+ const path = require('path');
6
+
7
+ /**
8
+ * Validates that a path is within the allowed working directory
9
+ * Prevents path traversal attacks
10
+ */
11
+ function validatePath(requestPath, workingDir) {
12
+ const fullPath = path.resolve(path.join(workingDir, requestPath || ''));
13
+ return fullPath.startsWith(workingDir);
14
+ }
15
+
16
+ /**
17
+ * Sanitizes file paths to prevent injection
18
+ */
19
+ function sanitizePath(filePath) {
20
+ if (!filePath) {
21
+ return '';
22
+ }
23
+ return filePath.replace(/\.\./g, '').replace(/[<>:"|?*]/g, '');
24
+ }
25
+
26
+ /**
27
+ * Validates commit message
28
+ */
29
+ function validateCommitMessage(message) {
30
+ if (!message || typeof message !== 'string') {
31
+ return false;
32
+ }
33
+ return message.trim().length > 0 && message.trim().length <= 5000;
34
+ }
35
+
36
+ /**
37
+ * Validates filename
38
+ */
39
+ function validateFilename(filename) {
40
+ if (!filename || typeof filename !== 'string') {
41
+ return false;
42
+ }
43
+
44
+ const sanitized = filename.trim();
45
+
46
+ if (sanitized.length === 0 || sanitized.length > 255) {
47
+ return false;
48
+ }
49
+
50
+ const invalidChars = /[<>:"|?*\x00-\x1F]/;
51
+ if (invalidChars.test(sanitized)) {
52
+ return false;
53
+ }
54
+
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Validates an array of file paths
60
+ */
61
+ function validateFilePaths(files) {
62
+ if (!Array.isArray(files) || files.length === 0) {
63
+ return false;
64
+ }
65
+
66
+ return files.every(file => {
67
+ return typeof file === 'string' && file.trim().length > 0;
68
+ });
69
+ }
70
+
71
+ module.exports = {
72
+ validatePath,
73
+ sanitizePath,
74
+ validateCommitMessage,
75
+ validateFilename,
76
+ validateFilePaths
77
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-here",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "A local GitHub-like file browser for viewing code",
5
5
  "repository": "https://github.com/corywilkerson/gh-here",
6
6
  "main": "index.js",