| 1 | ; |
| 2 | |
| 3 | // Repository indexing: builds a tree of the repo on disk, applies an ignore |
| 4 | // list, and provides safe path resolution so nothing outside the repo (or in |
| 5 | // the ignore list) can ever be read. |
| 6 | |
| 7 | const fs = require('fs'); |
| 8 | const fsp = require('fs/promises'); |
| 9 | const path = require('path'); |
| 10 | |
| 11 | const IGNORED_NAMES = new Set([ |
| 12 | '.git', |
| 13 | 'node_modules', |
| 14 | '__pycache__', |
| 15 | '.cursor', |
| 16 | '.DS_Store', |
| 17 | '.dockerignore', |
| 18 | '.railwayignore', |
| 19 | ]); |
| 20 | |
| 21 | const IGNORED_PATTERNS = [ |
| 22 | /^\.env(\..*)?$/, // .env, .env.local, ... |
| 23 | /\.pyc$/, |
| 24 | ]; |
| 25 | |
| 26 | const TREE_TTL_MS = 10_000; |
| 27 | |
| 28 | function isIgnoredName(name) { |
| 29 | if (IGNORED_NAMES.has(name)) return true; |
| 30 | return IGNORED_PATTERNS.some((re) => re.test(name)); |
| 31 | } |
| 32 | |
| 33 | function isIgnoredPath(relPath) { |
| 34 | if (!relPath) return false; |
| 35 | return relPath.split('/').some(isIgnoredName); |
| 36 | } |
| 37 | |
| 38 | class Repo { |
| 39 | constructor(root) { |
| 40 | this.root = path.resolve(root); |
| 41 | this._tree = null; |
| 42 | this._treeBuiltAt = 0; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Normalise a URL path fragment into a safe repo-relative POSIX path. |
| 47 | * Returns null if the path escapes the repo or hits the ignore list. |
| 48 | */ |
| 49 | safeRelative(input) { |
| 50 | let rel = String(input || '') |
| 51 | .replace(/\\/g, '/') |
| 52 | .replace(/\/+/g, '/') |
| 53 | .replace(/^\/|\/$/g, ''); |
| 54 | if (rel === '') return ''; |
| 55 | const parts = rel.split('/'); |
| 56 | if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; |
| 57 | if (isIgnoredPath(rel)) return null; |
| 58 | // Ensure the resolved absolute path still lives inside root. |
| 59 | const abs = path.resolve(this.root, ...parts); |
| 60 | if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null; |
| 61 | return parts.join('/'); |
| 62 | } |
| 63 | |
| 64 | absolute(rel) { |
| 65 | return rel === '' ? this.root : path.join(this.root, ...rel.split('/')); |
| 66 | } |
| 67 | |
| 68 | /** stat a repo-relative path, resolving symlinks and re-checking containment. */ |
| 69 | async stat(rel) { |
| 70 | const abs = this.absolute(rel); |
| 71 | let real; |
| 72 | try { |
| 73 | real = await fsp.realpath(abs); |
| 74 | } catch { |
| 75 | return null; |
| 76 | } |
| 77 | if (real !== this.root && !real.startsWith(this.root + path.sep)) return null; |
| 78 | const st = await fsp.stat(real); |
| 79 | return { abs: real, isDir: st.isDirectory(), isFile: st.isFile(), size: st.size, mtime: st.mtime }; |
| 80 | } |
| 81 | |
| 82 | /** Cached full tree used for the sidebar, search and stats. */ |
| 83 | async tree() { |
| 84 | const now = Date.now(); |
| 85 | if (this._tree && now - this._treeBuiltAt < TREE_TTL_MS) return this._tree; |
| 86 | this._tree = await this._buildTree('', this.root); |
| 87 | this._treeBuiltAt = now; |
| 88 | return this._tree; |
| 89 | } |
| 90 | |
| 91 | async _buildTree(rel, abs) { |
| 92 | const entries = await fsp.readdir(abs, { withFileTypes: true }); |
| 93 | const dirs = []; |
| 94 | const files = []; |
| 95 | for (const ent of entries) { |
| 96 | if (isIgnoredName(ent.name)) continue; |
| 97 | const childRel = rel ? `${rel}/${ent.name}` : ent.name; |
| 98 | const childAbs = path.join(abs, ent.name); |
| 99 | if (ent.isSymbolicLink()) { |
| 100 | // Only follow symlinks that stay inside the repo. |
| 101 | let real; |
| 102 | try { |
| 103 | real = await fsp.realpath(childAbs); |
| 104 | } catch { |
| 105 | continue; |
| 106 | } |
| 107 | if (!real.startsWith(this.root + path.sep)) continue; |
| 108 | const st = await fsp.stat(real); |
| 109 | if (st.isDirectory()) dirs.push(await this._buildTree(childRel, real)); |
| 110 | else if (st.isFile()) files.push({ type: 'file', name: ent.name, path: childRel, size: st.size }); |
| 111 | continue; |
| 112 | } |
| 113 | if (ent.isDirectory()) { |
| 114 | dirs.push(await this._buildTree(childRel, childAbs)); |
| 115 | } else if (ent.isFile()) { |
| 116 | const st = await fsp.stat(childAbs); |
| 117 | files.push({ type: 'file', name: ent.name, path: childRel, size: st.size }); |
| 118 | } |
| 119 | } |
| 120 | const cmp = (a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }); |
| 121 | dirs.sort(cmp); |
| 122 | files.sort(cmp); |
| 123 | const children = [...dirs, ...files]; |
| 124 | const fileCount = children.reduce((n, c) => n + (c.type === 'dir' ? c.fileCount : 1), 0); |
| 125 | const totalSize = children.reduce((n, c) => n + (c.type === 'dir' ? c.totalSize : c.size), 0); |
| 126 | return { type: 'dir', name: rel ? path.posix.basename(rel) : '', path: rel, children, fileCount, totalSize }; |
| 127 | } |
| 128 | |
| 129 | /** Flat list of every file in the tree (for search and stats). */ |
| 130 | async allFiles() { |
| 131 | const out = []; |
| 132 | const walk = (node) => { |
| 133 | for (const c of node.children) { |
| 134 | if (c.type === 'dir') walk(c); |
| 135 | else out.push(c); |
| 136 | } |
| 137 | }; |
| 138 | walk(await this.tree()); |
| 139 | return out; |
| 140 | } |
| 141 | |
| 142 | /** Find a node by path in the cached tree. */ |
| 143 | async node(rel) { |
| 144 | const root = await this.tree(); |
| 145 | if (rel === '') return root; |
| 146 | let cur = root; |
| 147 | for (const part of rel.split('/')) { |
| 148 | cur = cur.children.find((c) => c.name === part); |
| 149 | if (!cur) return null; |
| 150 | } |
| 151 | return cur; |
| 152 | } |
| 153 | |
| 154 | readFile(rel, encoding) { |
| 155 | return fsp.readFile(this.absolute(rel), encoding); |
| 156 | } |
| 157 | |
| 158 | createReadStream(rel) { |
| 159 | return fs.createReadStream(this.absolute(rel)); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | module.exports = { Repo, isIgnoredName, isIgnoredPath }; |