gitoria
All repositories: gitoria
7.9 KB
// transport.hl — CLONE, FETCH AND PUSH OVER HTTPS (ticket gitoria#7; docs/git-backend.md). Git's "smart HTTP" protocol,// answered by this app itself with the `git` binary: on a repo address `<slug>.<domain>` the paths// /<slug>.git/info/refs?service=git-upload-pack | git-receive-pack (what a client asks first)// /<slug>.git/git-upload-pack (fetch / clone) /<slug>.git/git-receive-pack (push)// spawn `git upload-pack | receive-pack --stateless-rpc` and hand the request body over and the answer back.// The clone URL is https://<slug>.<domain>/<slug>.git — the folder git makes is named like the repo.// * READ (clone, fetch) needs no login: every repo is public (the concept has no private repos yet).// * WRITE (push) needs a token as the password (tokens.hl) of THE REPO'S OWNER — nobody else may push (decision below).// * The body travels through temp files, byte for byte (a String here holds raw bytes; hl:fs writes and reads them as// they are): hl:proc run() has no stdin, so the child reads `< body` and writes `> answer` in a tiny `sh -c` with// the paths as positional arguments (no user text in the script). A gzip body (git compresses big requests) is// unpacked first. Git protocol v2 is passed through (`Git-Protocol` → GIT_PROTOCOL, digits only).// * No hook: pull requests and releases are read from the commits when their page is built.import { Response } from 'hl:http1'import { run } from 'hl:proc'import { writeFile, readFile, remove, exists, mkDir } from 'hl:fs'import { randomBytes } from 'hl:crypto'import { slugError, repoBySlug } from './repos.hl'import { slugOfHost, userRecord, publicUrl } from './users.hl'import { userOfToken } from './tokens.hl'import { gitRoot, repoDir } from './git.hl'import { notifyPush } from './tickets.hl'static NL = ""static seconds = 300 // one upload-pack / receive-pack callstatic maxBody = 500000000 // a push or fetch request above this is refused (bytes)static isBase64 = (s) => {if (s.length == 0 || s.length > 600) { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 43 || c == 47 || c == 61)) { return false }i = i + 1}return true}// the password of a Basic `Authorization` header ('user:password' → 'password'); null without one.// Hybriel has no base64 decoder (hybriel ticket candidate), so `base64 -d` decodes it — the text goes in as an// argument, after a check that it only holds base64 characters.static passwordOf = (header) => {if (header == null || hlTypeName(header) != 'String') { return null }let h = header.trim()if (h.length < 7 || h.slice(0, 6).toLowerCase() != 'basic ') { return null }let b = h.slice(6).trim()if (!isBase64(b)) { return null }let r = run(['sh', '-c', 'printf %s "$1" | base64 -d 2>/dev/null', 'sh', b], { timeout = 10 })if (r == null || r.exit != 0 || r.lines.length == 0) { return null }let text = r.lines[0]let colon = text.indexOf(':')return colon < 0 ? null : text.slice(colon + 1)}static plain = (status, text, extra) => {let headers = { 'Content-Type' = 'text/plain; charset=utf-8' 'Cache-Control' = 'no-store' }if (extra != null) { for (k of extra.keys()) { headers[k] = extra[k] } }return new Response(text + NL, { status = status headers = headers })}// git speaks protocol v2 when the client says so: `Git-Protocol: version=2` (only that shape is passed on)static protocolEnv = (req) => {let v = req.headers['git-protocol']if (v == null || hlTypeName(v) != 'String' || v.length > 40) { return {} }let ok = v.length > 0let i = 0while (i < v.length) {let c = v.charCodeAt(i)if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122) || c == 61 || c == 58)) { ok = false }i = i + 1}return ok ? { GIT_PROTOCOL = v } : {}}static tmpDir = () => {let d = gitRoot + '/.tmp'if (!exists(d)) { mkDir(d, 448) }return d}// ONE GIT CALL: the request body (or none) in, the answer out. → the answer's bytes as a String, or null (git failed and said nothing)static callGit = (slug, service, advertise, req) => {let dir = tmpDir()let name = dir + '/' + randomBytes(12, 'hex')let inFile = name + '.in'let outFile = name + '.out'let zipped = falselet script = 'exec git ' + service.slice(4) + ' --stateless-rpc --advertise-refs "$1" > "$3"'if (!advertise) {let body = req.body == null ? '' : req.bodywriteFile(inFile, body, 384)let enc = req.headers['content-encoding']zipped = enc != null && hlTypeName(enc) == 'String' && (enc.toLowerCase() == 'gzip' || enc.toLowerCase() == 'x-gzip')script = zipped ? 'gzip -dc < "$2" | git ' + service.slice(4) + ' --stateless-rpc "$1" > "$3"' : 'exec git ' + service.slice(4) + ' --stateless-rpc "$1" < "$2" > "$3"'}let r = run(['sh', '-c', script, 'sh', repoDir(slug), inFile, outFile], { timeout = seconds env = protocolEnv(req) })let out = exists(outFile) ? readFile(outFile) : nullif (exists(inFile)) { remove(inFile) }if (exists(outFile)) { remove(outFile) }// git answers nothing to the client's "0000" probe of a big push (exit 0): that is a 200 with an empty body, as git http-backend doesif (out == null || (out == '' && (r == null || r.exit != 0))) { return null }return out}// pkt-line: 4 hex digits of the length (itself included), then the textstatic pktLine = (text) => {let n = text.length + 4let hex = '0123456789abcdef'let out = ''let i = 0while (i < 4) {let d = n % 16out = hex[d] + outn = (n - d) / 16i = i + 1}return out + text}// THE ROUTE (project.hl): `/:repo/info/refs`, `/:repo/git-upload-pack`, `/:repo/git-receive-pack`static gitTransport = (route, req) => {let hostHeader = req.headers['host']let slug = slugOfHost(hostHeader == null ? '' : hostHeader.split(':')[0])let repoName = route.params.repoif (slug == '' || slugError(slug) != null || repoName != slug + '.git' || repoBySlug(slug) == null) { return plain(404, 'no such repository') }let last = req.path.slice(req.path.lastIndexOf('/') + 1)let advertise = last == 'refs'let service = advertise ? (req.query != null ? req.query.service : null) : lastif (service != 'git-upload-pack' && service != 'git-receive-pack') { return plain(403, 'only the smart git protocol is served here: use git clone / fetch / push') }if (advertise && req.method != 'GET') { return plain(405, 'GET only') }if (!advertise && req.method != 'POST') { return plain(405, 'POST only') }if (req.body != null && req.body.length > maxBody) { return plain(413, 'that request is too big') }if (service == 'git-receive-pack') {let realm = { 'WWW-Authenticate' = 'Basic realm="gitoria"' }let pw = passwordOf(req.headers['authorization'])if (pw == null) { return plain(401, 'pushing needs a token: log in at ' + publicUrl + ', make one under "Access tokens", and use it as the password', realm) }let userId = userOfToken(pw)if (userId == null) { return plain(401, 'that token is not valid (removed, or mistyped)', realm) }let repo = repoBySlug(slug)if (userRecord(userId) == null || repo.owner != userId) { return plain(403, 'only the owner of this repository can push to it') }}let out = callGit(slug, service, advertise, req)if (out == null) { return plain(500, 'git gave no answer') }let headers = { 'Cache-Control' = 'no-cache, max-age=0, must-revalidate' 'Pragma' = 'no-cache' }if (advertise) {headers['Content-Type'] = 'application/x-' + service + '-advertisement'// protocol v2 answers without the service banner; v0/v1 starts with it (as git http-backend does)let v2 = protocolEnv(req).GIT_PROTOCOL != null && protocolEnv(req).GIT_PROTOCOL.includes('version=2')if (!v2) { out = pktLine('# service=' + service + NL) + '0000' + out }} else {headers['Content-Type'] = 'application/x-' + service + '-result'// a push arrived: tell the connected tickets project about commits naming a ticket (tickets.hl; never fails the push)if (service == 'git-receive-pack') { notifyPush(slug, false) }}return new Response(out, { headers = headers })}
Branches
- mainmain branch
Latest commits
- 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
- 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
- fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
- 4a2d7125initial commitmre