📃 Commit ff26f90
add README, fix code viewer, file tree sidebar, repo metadata
ff26f9098506672e616597da503dfaeb4a9dd7f9
📝 Diff
README.md
| @@ -0,0 +1,143 @@ | |
| + | # otgit |
| + | |
| + | Self-hosted git host with a 1999 Microsoft-era UI. Reuses Octuna's CSS shell. |
| + | |
| + |  |
| + | |
| + | ## What it does |
| + | |
| + | - Host git repositories on your own server |
| + | - Browse repos, files, commits, and diffs in the browser |
| + | - Push and pull over HTTPS smart protocol with basic auth |
| + | - Render Markdown READMEs (safe subset, no raw HTML) |
| + | - Render unified diffs with red/green hunks like GitHub |
| + | - Per-user accounts, public/private repos, admin web UI |
| + | |
| + | ## Why |
| + | |
| + | Modern feature set, retro skin. Lightweight, low RAM, low code, secure. |
| + | No framework, no database, two npm dependencies maximum (currently zero |
| + | runtime deps beyond Node itself). Designed to be portable, easy to read, |
| + | and easy to host on any small VPS that has Node and the `git` binary. |
| + | |
| + | ## Hard limits |
| + | |
| + | - Server target under 60 MB resident, capped at 64 MB heap |
| + | - No source file over 400 lines |
| + | - Total CSS under 1000 lines |
| + | - Every tunable comes from `config.json` via `config.get()`. No magic |
| + | numbers in route files |
| + | |
| + | ## Stack |
| + | |
| + | - Raw Node `http` (no Express, Koa, Fastify) |
| + | - `git` binary spawned for all repo operations |
| + | - Bare repos at `data/repos/<owner>/<name>.git` |
| + | - JSON state with atomic writes (`users.json`, `repos.json`) |
| + | - scrypt password hashing, HMAC-signed session cookies |
| + | - `git http-backend` CGI bridge for smart HTTP push/pull |
| + | |
| + | ## Layout |
| + | |
| + | ``` |
| + | server.js boot, route registration, listen |
| + | setup.js creates admin, generates secrets |
| + | config.json runtime tunables (generated by setup) |
| + | config.example.json |
| + | lib/ |
| + | config.js layered config + setMany validate |
| + | store.js atomic JSON store |
| + | respond.js json/html/text + security headers + readJson |
| + | router.js :param pattern matcher |
| + | log.js leveled logger |
| + | validate.js name and path safety |
| + | auth.js scrypt + HMAC sessions |
| + | limit.js rate limit factory |
| + | data.js users.json + repos.json singletons |
| + | git.js spawn wrappers for every git op |
| + | http-backend.js smart HTTP CGI bridge |
| + | render.js templates, escape, sidebar, tree, blob, diff |
| + | render-md.js markdown safe subset |
| + | render-diff.js unified diff to HTML |
| + | routes/ |
| + | static.js cached public assets |
| + | auth.js signup, login, logout, me, password |
| + | repo-write.js create, settings, rename, delete |
| + | smart-http.js info/refs, upload-pack, receive-pack |
| + | pages.js home, signup, new repo |
| + | admin.js admin settings page (full config control) |
| + | repo.js repo home, tree, blob, history, commit |
| + | user.js user profile |
| + | views/ HTML templates |
| + | public/ style.css, pages.css, app.js, logo.png |
| + | scripts/ |
| + | smoke.js end-to-end smoke test |
| + | data/ |
| + | users.json |
| + | repos.json |
| + | repos/<owner>/<name>.git/ |
| + | ``` |
| + | |
| + | ## Setup |
| + | |
| + | ``` |
| + | git clone https://git.dek.cx/dek/octuna.git otgit |
| + | cd otgit |
| + | node setup.js |
| + | npm start |
| + | ``` |
| + | |
| + | `setup.js` prompts for admin username, password, public URL, and port, |
| + | then writes `config.json` and creates the admin user. |
| + | |
| + | ## Reverse proxy (nginx) |
| + | |
| + | ``` |
| + | server { |
| + | listen 80; |
| + | server_name git.example.com; |
| + | client_max_body_size 500M; |
| + | location / { |
| + | proxy_pass http://127.0.0.1:3031; |
| + | proxy_http_version 1.1; |
| + | proxy_set_header Host $host; |
| + | proxy_set_header X-Real-IP $remote_addr; |
| + | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| + | proxy_set_header X-Forwarded-Proto $scheme; |
| + | proxy_request_buffering off; |
| + | proxy_buffering off; |
| + | proxy_read_timeout 300s; |
| + | } |
| + | } |
| + | ``` |
| + | |
| + | Then `certbot --nginx -d git.example.com`. |
| + | |
| + | ## Pushing code |
| + | |
| + | ``` |
| + | git remote add origin https://git.example.com/<user>/<repo>.git |
| + | git push -u origin main |
| + | ``` |
| + | |
| + | Use the account password as the git basic auth password. Public repos |
| + | clone without auth. |
| + | |
| + | ## Admin |
| + | |
| + | The admin user (set by `setup.js`) sees an "Admin settings" link in |
| + | the sidebar. The page lets you edit any non-secret value in |
| + | `config.json` from the browser, with restart-required fields flagged. |
| + | |
| + | ## v1 scope |
| + | |
| + | In: signup, login, password change, repo CRUD, file tree, blob viewer |
| + | with line numbers, commit history, single-commit diff with hunks, |
| + | README render, push/pull, admin settings, per-repo settings. |
| + | |
| + | Out (post-v1): issues, pull requests, stars, forks, webhooks, CI, |
| + | releases, wikis, organizations, SSH transport, federation. |
| + | |
| + | ## License |
| + | |
| + | MIT. |
lib/git.js
| @@ -38,18 +38,22 @@ function run(args, gitDir) { | |
} | |
| |
function init(gitDir) { | |
| + | const branch = config.get('repos.defaultBranch') || 'main'; |
return new Promise((resolve, reject) => { | |
| - | const proc = spawn(bin(), ['init', '--bare', gitDir]); |
| + | const proc = spawn(bin(), ['init', '--bare', '--initial-branch=' + branch, gitDir]); |
proc.on('error', reject); | |
proc.on('close', code => { | |
| - | if (code !== 0) reject(new Error('git init --bare failed')); |
| - | else resolve(); |
| + | if (code !== 0) return reject(new Error('git init --bare failed')); |
| + | // Enable HTTP receive-pack so smart HTTP push works. |
| + | const cfg = spawn(bin(), ['--git-dir=' + gitDir, 'config', 'http.receivepack', 'true']); |
| + | cfg.on('close', () => resolve()); |
| + | cfg.on('error', () => resolve()); |
}); | |
}); | |
} | |
| |
async function refs(gitDir) { | |
| - | let headRef = config.get('repos.defaultBranch') || 'main'; |
| + | let headRef = null; |
try { | |
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim(); | |
const m = head.match(/^ref: refs\/heads\/(.+)$/); | |
| @@ -60,6 +64,12 @@ async function refs(gitDir) { | |
const out = await run(['branch', '--format=%(refname:short)'], gitDir); | |
branches = out.split('\n').map(s => s.trim()).filter(Boolean); | |
} catch {} | |
| + | // HEAD may point to a branch that does not exist yet (bare repo created |
| + | // before any push). Prefer an actual existing branch. |
| + | if (!headRef || !branches.includes(headRef)) { |
| + | if (branches.length) headRef = branches[0]; |
| + | else headRef = config.get('repos.defaultBranch') || 'main'; |
| + | } |
return { default: headRef, branches }; | |
} | |
| |
| @@ -142,9 +152,16 @@ async function diff(gitDir, sha) { | |
return run(['diff-tree', '--no-commit-id', '-p', sha], gitDir); | |
} | |
| |
| + | async function fileCount(gitDir, ref) { |
| + | try { |
| + | const out = await run(['ls-tree', '-r', '--name-only', ref], gitDir); |
| + | return out.split('\n').filter(Boolean).length; |
| + | } catch { return 0; } |
| + | } |
| + | |
module.exports = { | |
reposRoot, repoPath, exists, | |
init, refs, commitCount, log, tree, | |
blobStream, blobContent, objectSize, | |
| - | commitInfo, diff |
| + | commitInfo, diff, fileCount |
}; | |
lib/render.js
| @@ -112,19 +112,29 @@ function commitRows(commits, owner, repo) { | |
| |
function blobLines(content) { | |
const lines = content.split('\n'); | |
| - | const nums = lines.map((_, i) => `<span id="L${i + 1}">${i + 1}</span>`).join('\n'); |
| - | const code = lines.map(l => `<span>${escape(l)}</span>`).join('\n'); |
| - | return `<table class="blob-table"> |
| - | <tbody> |
| - | <tr> |
| - | <td class="blob-nums"><pre>${nums}</pre></td> |
| - | <td class="blob-code"><pre>${code}</pre></td> |
| - | </tr> |
| - | </tbody> |
| - | </table>`; |
| + | const rows = lines.map((line, i) => { |
| + | const n = i + 1; |
| + | const safe = escape(line); |
| + | return `<tr><td class="blob-num" id="L${n}"><a href="#L${n}">${n}</a></td><td class="blob-line">${safe || ' '}</td></tr>`; |
| + | }).join(''); |
| + | return `<table class="blob-table">${rows}</table>`; |
| + | } |
| + | |
| + | function fileTreeSidebar(entries, owner, repo, ref, currentFile) { |
| + | const items = entries.map(e => { |
| + | const isDir = e.type === 'tree'; |
| + | const href = isDir |
| + | ? `/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(e.name)}` |
| + | : `/${escape(owner)}/${escape(repo)}/blob/${escape(ref)}/${escape(e.name)}`; |
| + | const active = (currentFile === e.name) ? ' class="active"' : ''; |
| + | const icon = isDir ? '📁' : '📄'; |
| + | return `<li${active}>${icon} <a href="${href}">${escape(e.name)}${isDir ? '/' : ''}</a></li>`; |
| + | }).join(''); |
| + | const rootLink = `<div class="tree-sidebar-root"><a href="/${escape(owner)}/${escape(repo)}">↩ ${escape(repo)}</a></div>`; |
| + | return `<div class="panel"><div class="head">📁 Files</div><div class="body tree-sidebar-body">${rootLink}<ul class="tree-sidebar">${items}</ul></div></div>`; |
} | |
| |
module.exports = { | |
escape, fill, page, loadView, | |
| - | sidebar, breadcrumb, treeRows, commitRows, blobLines |
| + | sidebar, breadcrumb, treeRows, commitRows, blobLines, fileTreeSidebar |
}; | |
public/pages.css
| @@ -7,25 +7,49 @@ | |
| |
/* Blob viewer */ | |
.blob-meta { font-size: 11px; color: #555; padding: 4px 8px; border-bottom: 1px solid var(--silver); } | |
| - | .blob-view { overflow-x: auto; background: #fff; } |
| - | .blob-table { width: 100%; border-collapse: collapse; font-family: "Courier New", monospace; font-size: 12px; } |
| - | .blob-nums { |
| - | width: 1%; |
| - | min-width: 40px; |
| - | padding: 0; |
| + | .blob-view { |
| + | overflow-x: auto; |
| + | background: #fff; |
| + | max-width: 100%; |
| + | } |
| + | .blob-table { |
| + | border-collapse: collapse; |
| + | font-family: "Courier New", Consolas, monospace; |
| + | font-size: 12px; |
| + | min-width: 100%; |
| + | } |
| + | .blob-table tr { line-height: 1.5; } |
| + | .blob-num { |
background: #f0f0f0; | |
border-right: 1px solid #ccc; | |
| + | padding: 0 10px; |
text-align: right; | |
| - | vertical-align: top; |
user-select: none; | |
| + | white-space: nowrap; |
| + | min-width: 44px; |
| + | vertical-align: top; |
| + | } |
| + | .blob-num a { color: #888; text-decoration: none; } |
| + | .blob-num:target { background: #ffffcc; } |
| + | .blob-num:target a { color: var(--navy); } |
| + | .blob-line { |
| + | padding: 0 10px; |
| + | white-space: pre; |
| + | vertical-align: top; |
| + | width: 100%; |
} | |
| - | .blob-nums pre { margin: 0; padding: 4px 8px; line-height: 1.5; } |
| - | .blob-nums span { display: block; color: #888; } |
| - | .blob-nums span:target { color: var(--navy); background: #ffffcc; } |
| - | .blob-code { padding: 0; vertical-align: top; white-space: pre; } |
| - | .blob-code pre { margin: 0; padding: 4px 8px; line-height: 1.5; } |
| - | .blob-code span { display: block; } |
| - | .blob-code span:hover { background: #fffde0; } |
| + | .blob-table tr:hover .blob-line { background: #fffde0; } |
| + | |
| + | /* File tree sidebar (blob view) */ |
| + | .tree-sidebar-body { padding: 6px 8px; } |
| + | .tree-sidebar-root { padding: 2px 0 6px; border-bottom: 1px dashed #ccc; margin-bottom: 4px; font-family: Tahoma, sans-serif; } |
| + | .tree-sidebar-root a { text-decoration: none; font-weight: bold; } |
| + | .tree-sidebar { list-style: none; padding: 0; margin: 0; } |
| + | .tree-sidebar li { padding: 2px 0; font-family: "Courier New", monospace; font-size: 11px; word-break: break-all; } |
| + | .tree-sidebar li a { text-decoration: none; } |
| + | .tree-sidebar li a:hover { text-decoration: underline; } |
| + | .tree-sidebar li.active { background: #ffffcc; padding: 2px 4px; } |
| + | .tree-sidebar li.active a { color: var(--navy); font-weight: bold; } |
| |
/* Commit list */ | |
.commit-table td.commit-sha { width: 70px; font-family: "Courier New", monospace; font-size: 11px; } | |
public/style.css
| @@ -133,10 +133,12 @@ a:visited { color: var(--visited); } | |
/* Layout */ | |
.layout { | |
display: grid; | |
| - | grid-template-columns: 200px 1fr; |
| + | grid-template-columns: 220px minmax(0, 1fr); |
gap: 10px; | |
padding: 10px; | |
} | |
| + | .layout main { min-width: 0; } |
| + | .layout aside { min-width: 0; } |
@media (max-width: 760px) { | |
.layout { grid-template-columns: 1fr; } | |
.toolbar img.logo { width: 36px; height: 36px; } | |
routes/repo.js
| @@ -26,12 +26,13 @@ async function repoHome(req, res, params) { | |
const gitDir = git.repoPath(params.owner, params.repo); | |
if (!git.exists(gitDir)) return respond.notFound(res); | |
| |
| - | let refsData, count, entries, readmeHtml; |
| + | let refsData, count, entries, readmeHtml, fileCount = 0; |
try { | |
refsData = await git.refs(gitDir); | |
const ref = refsData.default; | |
count = await git.commitCount(gitDir, ref); | |
entries = await git.tree(gitDir, ref, ''); | |
| + | fileCount = await git.fileCount(gitDir, ref); |
const readmeEntry = entries.find(e => e.type === 'blob' && /^readme(\.|$)/i.test(e.name)); | |
if (readmeEntry) { | |
const buf = await git.blobContent(gitDir, ref, readmeEntry.name); | |
| @@ -39,7 +40,7 @@ async function repoHome(req, res, params) { | |
} | |
} catch { | |
refsData = { default: config.get('repos.defaultBranch'), branches: [] }; | |
| - | count = 0; entries = []; |
| + | count = 0; entries = []; fileCount = 0; |
} | |
| |
const ref = refsData.default; | |
| @@ -47,6 +48,10 @@ async function repoHome(req, res, params) { | |
`<option value="${render.escape(b)}"${b === ref ? ' selected' : ''}>${render.escape(b)}</option>` | |
).join(''); | |
| |
| + | const createdDate = repo.createdAt |
| + | ? new Date(repo.createdAt).toISOString().slice(0, 10) |
| + | : ''; |
| + | |
const isOwner = session && session.username === params.owner; | |
const content = render.fill(render.loadView('repo-home.html'), { | |
OWNER: render.escape(params.owner), | |
| @@ -55,6 +60,8 @@ async function repoHome(req, res, params) { | |
DESCRIPTION: render.escape(repo.description || ''), | |
VISIBILITY: render.escape(repo.visibility), | |
COMMIT_COUNT: count, | |
| + | FILE_COUNT: fileCount, |
| + | CREATED_DATE: render.escape(createdDate), |
BRANCH_OPTIONS: branchOpts, | |
TREE_ROWS: render.treeRows(entries, params.owner, params.repo, ref, ''), | |
README_HTML: readmeHtml ? `<div class="readme-body">${readmeHtml}</div>` : '', | |
| @@ -110,6 +117,14 @@ async function repoBlob(req, res, params) { | |
? `<p>File too large to display (${(buf.length / 1024 / 1024).toFixed(1)} MB). <a href="${rawUrl}">Download raw</a></p>` | |
: render.blobLines(buf.toString('utf8')); | |
| |
| + | let sidebarHtml = ''; |
| + | try { |
| + | const dirPath = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : ''; |
| + | const sidebarEntries = await git.tree(gitDir, ref, dirPath); |
| + | const currentName = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath; |
| + | sidebarHtml = render.fileTreeSidebar(sidebarEntries, params.owner, params.repo, ref, currentName); |
| + | } catch {} |
| + | |
const content = render.fill(render.loadView('repo-blob.html'), { | |
OWNER: render.escape(params.owner), | |
REPO: render.escape(params.repo), | |
| @@ -122,7 +137,7 @@ async function repoBlob(req, res, params) { | |
BLOB_HTML: blobHtml | |
}); | |
| |
| - | res.end(render.page({ title: filePath, content, session, addressUrl: req.url })); |
| + | res.end(render.page({ title: filePath, content, session, addressUrl: req.url, extraSidebar: sidebarHtml })); |
} | |
| |
async function rawBlob(req, res, params) { | |
views/repo-home.html
| @@ -7,7 +7,12 @@ | |
<p>{{DESCRIPTION}}</p> | |
<div class="repo-meta"> | |
<span>📃 {{COMMIT_COUNT}} commits</span> | |
| - | |
| + | · |
| + | <span>📄 {{FILE_COUNT}} files</span> |
| + | · |
| + | <span>📅 created {{CREATED_DATE}}</span> |
| + | </div> |
| + | <div class="repo-meta"> |
<label for="branchSel">Branch:</label> | |
<select id="branchSel" onchange="switchBranch(this.value)">{{BRANCH_OPTIONS}}</select> | |
| |
| |