gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitfdfb4b1bfdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mrefdfb4b1b/transport.hl

7.9 KB

  1. // transport.hl — CLONE, FETCH AND PUSH OVER HTTPS (ticket gitoria#7; docs/git-backend.md). Git's "smart HTTP" protocol,
  2. // answered by this app itself with the `git` binary: on a repo address `<slug>.<domain>` the paths
  3. // /<slug>.git/info/refs?service=git-upload-pack | git-receive-pack (what a client asks first)
  4. // /<slug>.git/git-upload-pack (fetch / clone) /<slug>.git/git-receive-pack (push)
  5. // spawn `git upload-pack | receive-pack --stateless-rpc` and hand the request body over and the answer back.
  6. // The clone URL is https://<slug>.<domain>/<slug>.git — the folder git makes is named like the repo.
  7. // * READ (clone, fetch) needs no login: every repo is public (the concept has no private repos yet).
  8. // * WRITE (push) needs a token as the password (tokens.hl) of THE REPO'S OWNER — nobody else may push (decision below).
  9. // * The body travels through temp files, byte for byte (a String here holds raw bytes; hl:fs writes and reads them as
  10. // they are): hl:proc run() has no stdin, so the child reads `< body` and writes `> answer` in a tiny `sh -c` with
  11. // the paths as positional arguments (no user text in the script). A gzip body (git compresses big requests) is
  12. // unpacked first. Git protocol v2 is passed through (`Git-Protocol` → GIT_PROTOCOL, digits only).
  13. // * No hook: pull requests and releases are read from the commits when their page is built.
  14. import { Response } from 'hl:http1'
  15. import { run } from 'hl:proc'
  16. import { writeFile, readFile, remove, exists, mkDir } from 'hl:fs'
  17. import { randomBytes } from 'hl:crypto'
  18. import { slugError, repoBySlug } from './repos.hl'
  19. import { slugOfHost, userRecord, publicUrl } from './users.hl'
  20. import { userOfToken } from './tokens.hl'
  21. import { gitRoot, repoDir } from './git.hl'
  22. import { notifyPush } from './tickets.hl'
  23. static NL = "
  24. "
  25. static seconds = 300 // one upload-pack / receive-pack call
  26. static maxBody = 500000000 // a push or fetch request above this is refused (bytes)
  27. static isBase64 = (s) => {
  28. if (s.length == 0 || s.length > 600) { return false }
  29. let i = 0
  30. while (i < s.length) {
  31. let c = s.charCodeAt(i)
  32. if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 43 || c == 47 || c == 61)) { return false }
  33. i = i + 1
  34. }
  35. return true
  36. }
  37. // the password of a Basic `Authorization` header ('user:password' → 'password'); null without one.
  38. // Hybriel has no base64 decoder (hybriel ticket candidate), so `base64 -d` decodes it — the text goes in as an
  39. // argument, after a check that it only holds base64 characters.
  40. static passwordOf = (header) => {
  41. if (header == null || hlTypeName(header) != 'String') { return null }
  42. let h = header.trim()
  43. if (h.length < 7 || h.slice(0, 6).toLowerCase() != 'basic ') { return null }
  44. let b = h.slice(6).trim()
  45. if (!isBase64(b)) { return null }
  46. let r = run(['sh', '-c', 'printf %s "$1" | base64 -d 2>/dev/null', 'sh', b], { timeout = 10 })
  47. if (r == null || r.exit != 0 || r.lines.length == 0) { return null }
  48. let text = r.lines[0]
  49. let colon = text.indexOf(':')
  50. return colon < 0 ? null : text.slice(colon + 1)
  51. }
  52. static plain = (status, text, extra) => {
  53. let headers = { 'Content-Type' = 'text/plain; charset=utf-8' 'Cache-Control' = 'no-store' }
  54. if (extra != null) { for (k of extra.keys()) { headers[k] = extra[k] } }
  55. return new Response(text + NL, { status = status headers = headers })
  56. }
  57. // git speaks protocol v2 when the client says so: `Git-Protocol: version=2` (only that shape is passed on)
  58. static protocolEnv = (req) => {
  59. let v = req.headers['git-protocol']
  60. if (v == null || hlTypeName(v) != 'String' || v.length > 40) { return {} }
  61. let ok = v.length > 0
  62. let i = 0
  63. while (i < v.length) {
  64. let c = v.charCodeAt(i)
  65. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122) || c == 61 || c == 58)) { ok = false }
  66. i = i + 1
  67. }
  68. return ok ? { GIT_PROTOCOL = v } : {}
  69. }
  70. static tmpDir = () => {
  71. let d = gitRoot + '/.tmp'
  72. if (!exists(d)) { mkDir(d, 448) }
  73. return d
  74. }
  75. // 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)
  76. static callGit = (slug, service, advertise, req) => {
  77. let dir = tmpDir()
  78. let name = dir + '/' + randomBytes(12, 'hex')
  79. let inFile = name + '.in'
  80. let outFile = name + '.out'
  81. let zipped = false
  82. let script = 'exec git ' + service.slice(4) + ' --stateless-rpc --advertise-refs "$1" > "$3"'
  83. if (!advertise) {
  84. let body = req.body == null ? '' : req.body
  85. writeFile(inFile, body, 384)
  86. let enc = req.headers['content-encoding']
  87. zipped = enc != null && hlTypeName(enc) == 'String' && (enc.toLowerCase() == 'gzip' || enc.toLowerCase() == 'x-gzip')
  88. script = zipped ? 'gzip -dc < "$2" | git ' + service.slice(4) + ' --stateless-rpc "$1" > "$3"' : 'exec git ' + service.slice(4) + ' --stateless-rpc "$1" < "$2" > "$3"'
  89. }
  90. let r = run(['sh', '-c', script, 'sh', repoDir(slug), inFile, outFile], { timeout = seconds env = protocolEnv(req) })
  91. let out = exists(outFile) ? readFile(outFile) : null
  92. if (exists(inFile)) { remove(inFile) }
  93. if (exists(outFile)) { remove(outFile) }
  94. // 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 does
  95. if (out == null || (out == '' && (r == null || r.exit != 0))) { return null }
  96. return out
  97. }
  98. // pkt-line: 4 hex digits of the length (itself included), then the text
  99. static pktLine = (text) => {
  100. let n = text.length + 4
  101. let hex = '0123456789abcdef'
  102. let out = ''
  103. let i = 0
  104. while (i < 4) {
  105. let d = n % 16
  106. out = hex[d] + out
  107. n = (n - d) / 16
  108. i = i + 1
  109. }
  110. return out + text
  111. }
  112. // THE ROUTE (project.hl): `/:repo/info/refs`, `/:repo/git-upload-pack`, `/:repo/git-receive-pack`
  113. static gitTransport = (route, req) => {
  114. let hostHeader = req.headers['host']
  115. let slug = slugOfHost(hostHeader == null ? '' : hostHeader.split(':')[0])
  116. let repoName = route.params.repo
  117. if (slug == '' || slugError(slug) != null || repoName != slug + '.git' || repoBySlug(slug) == null) { return plain(404, 'no such repository') }
  118. let last = req.path.slice(req.path.lastIndexOf('/') + 1)
  119. let advertise = last == 'refs'
  120. let service = advertise ? (req.query != null ? req.query.service : null) : last
  121. if (service != 'git-upload-pack' && service != 'git-receive-pack') { return plain(403, 'only the smart git protocol is served here: use git clone / fetch / push') }
  122. if (advertise && req.method != 'GET') { return plain(405, 'GET only') }
  123. if (!advertise && req.method != 'POST') { return plain(405, 'POST only') }
  124. if (req.body != null && req.body.length > maxBody) { return plain(413, 'that request is too big') }
  125. if (service == 'git-receive-pack') {
  126. let realm = { 'WWW-Authenticate' = 'Basic realm="gitoria"' }
  127. let pw = passwordOf(req.headers['authorization'])
  128. 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) }
  129. let userId = userOfToken(pw)
  130. if (userId == null) { return plain(401, 'that token is not valid (removed, or mistyped)', realm) }
  131. let repo = repoBySlug(slug)
  132. if (userRecord(userId) == null || repo.owner != userId) { return plain(403, 'only the owner of this repository can push to it') }
  133. }
  134. let out = callGit(slug, service, advertise, req)
  135. if (out == null) { return plain(500, 'git gave no answer') }
  136. let headers = { 'Cache-Control' = 'no-cache, max-age=0, must-revalidate' 'Pragma' = 'no-cache' }
  137. if (advertise) {
  138. headers['Content-Type'] = 'application/x-' + service + '-advertisement'
  139. // protocol v2 answers without the service banner; v0/v1 starts with it (as git http-backend does)
  140. let v2 = protocolEnv(req).GIT_PROTOCOL != null && protocolEnv(req).GIT_PROTOCOL.includes('version=2')
  141. if (!v2) { out = pktLine('# service=' + service + NL) + '0000' + out }
  142. } else {
  143. headers['Content-Type'] = 'application/x-' + service + '-result'
  144. // a push arrived: tell the connected tickets project about commits naming a ticket (tickets.hl; never fails the push)
  145. if (service == 'git-receive-pack') { notifyPush(slug, false) }
  146. }
  147. return new Response(out, { headers = headers })
  148. }

Branches

Latest commits

  • fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
  • 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
  • 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
  • e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
  • 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
  • 3dc43108antcolony#40: mission references point to the moved missionsmre
  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 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