ff26f90 - git - Webernet Explorer _X
git 🏠 Home 👤 Sign up Self hosted git, retro shell
★ otgit, code hosting from the year 2000 ★
📃 Commit ff26f90

add README, fix code viewer, file tree sidebar, repo metadata

dek · 2026-05-04 · History

ff26f9098506672e616597da503dfaeb4a9dd7f9
📝 Diff
README.md
@@ -0,0 +1,143 @@
+# otgit
+
+Self-hosted git host with a 1999 Microsoft-era UI. Reuses Octuna's CSS shell.
+
+![otgit](public/logo.png)
+
+## 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 || '&nbsp;'}</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 ? '&#128193;' : '&#128196;';
+ 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)}">&#8617; ${escape(repo)}</a></div>`;
+ return `<div class="panel"><div class="head">&#128193; 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>&#128195; {{COMMIT_COUNT}} commits</span>
- &nbsp;&nbsp;
+ &nbsp;&middot;&nbsp;
+ <span>&#128196; {{FILE_COUNT}} files</span>
+ &nbsp;&middot;&nbsp;
+ <span>&#128197; created {{CREATED_DATE}}</span>
+ </div>
+ <div class="repo-meta">
<label for="branchSel">Branch:</label>
<select id="branchSel" onchange="switchBranch(this.value)">{{BRANCH_OPTIONS}}</select>
&nbsp;&nbsp;
Done 🔒 Internet