gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit4a2d71254a2d7125initial commitmre4a2d7125/transport.hl

8.2 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. static NL = "
  23. "
  24. static seconds = 300 // one upload-pack / receive-pack call
  25. static maxBody = 500000000 // a push or fetch request above this is refused (bytes)
  26. static isBase64 = (s) => {
  27. if (s.length == 0 || s.length > 600) { return false }
  28. let i = 0
  29. while (i < s.length) {
  30. let c = s.charCodeAt(i)
  31. if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 43 || c == 47 || c == 61)) { return false }
  32. i = i + 1
  33. }
  34. return true
  35. }
  36. // the password of a Basic `Authorization` header ('user:password' → 'password'); null without one.
  37. // Hybriel has no base64 decoder (hybriel ticket candidate), so `base64 -d` decodes it — the text goes in as an
  38. // argument, after a check that it only holds base64 characters.
  39. static passwordOf = (header) => {
  40. if (header == null || hlTypeName(header) != 'String') { return null }
  41. let h = header.trim()
  42. if (h.length < 7 || h.slice(0, 6).toLowerCase() != 'basic ') { return null }
  43. let b = h.slice(6).trim()
  44. if (!isBase64(b)) { return null }
  45. let r = run(['sh', '-c', 'printf %s "$1" | base64 -d 2>/dev/null', 'sh', b], { timeout = 10 })
  46. if (r == null || r.exit != 0 || r.lines.length == 0) { return null }
  47. let text = r.lines[0]
  48. let colon = text.indexOf(':')
  49. return colon < 0 ? null : text.slice(colon + 1)
  50. }
  51. static plain = (status, text, extra) => {
  52. let headers = { 'Content-Type' = 'text/plain; charset=utf-8' 'Cache-Control' = 'no-store' }
  53. if (extra != null) { for (k of extra.keys()) { headers[k] = extra[k] } }
  54. return new Response(text + NL, { status = status headers = headers })
  55. }
  56. // git speaks protocol v2 when the client says so: `Git-Protocol: version=2` (only that shape is passed on)
  57. static protocolEnv = (req) => {
  58. let v = req.headers['git-protocol']
  59. if (v == null || hlTypeName(v) != 'String' || v.length > 40) { return {} }
  60. let ok = v.length > 0
  61. let i = 0
  62. while (i < v.length) {
  63. let c = v.charCodeAt(i)
  64. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122) || c == 61 || c == 58)) { ok = false }
  65. i = i + 1
  66. }
  67. return ok ? { GIT_PROTOCOL = v } : {}
  68. }
  69. static tmpDir = () => {
  70. let d = gitRoot + '/.tmp'
  71. if (!exists(d)) { mkDir(d, 448) }
  72. return d
  73. }
  74. // 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)
  75. static callGit = (slug, service, advertise, req) => {
  76. let dir = tmpDir()
  77. let name = dir + '/' + randomBytes(12, 'hex')
  78. let inFile = name + '.in'
  79. let outFile = name + '.out'
  80. let zipped = false
  81. let script = 'exec git ' + service.slice(4) + ' --stateless-rpc --advertise-refs "$1" > "$3"'
  82. if (!advertise) {
  83. let body = req.body == null ? '' : req.body
  84. writeFile(inFile, body, 384)
  85. let enc = req.headers['content-encoding']
  86. zipped = enc != null && hlTypeName(enc) == 'String' && (enc.toLowerCase() == 'gzip' || enc.toLowerCase() == 'x-gzip')
  87. script = zipped ? 'gzip -dc < "$2" | git ' + service.slice(4) + ' --stateless-rpc "$1" > "$3"' : 'exec git ' + service.slice(4) + ' --stateless-rpc "$1" < "$2" > "$3"'
  88. }
  89. let r = run(['sh', '-c', script, 'sh', repoDir(slug), inFile, outFile], { timeout = seconds env = protocolEnv(req) })
  90. let out = exists(outFile) ? readFile(outFile) : null
  91. if (exists(inFile)) { remove(inFile) }
  92. if (exists(outFile)) { remove(outFile) }
  93. // 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
  94. if (out == null || (out == '' && (r == null || r.exit != 0))) { return null }
  95. return out
  96. }
  97. // pkt-line: 4 hex digits of the length (itself included), then the text
  98. static pktLine = (text) => {
  99. let n = text.length + 4
  100. let hex = '0123456789abcdef'
  101. let out = ''
  102. let i = 0
  103. while (i < 4) {
  104. let d = n % 16
  105. out = hex[d] + out
  106. n = (n - d) / 16
  107. i = i + 1
  108. }
  109. return out + text
  110. }
  111. // THE ROUTE (project.hl): `/:repo/info/refs`, `/:repo/git-upload-pack`, `/:repo/git-receive-pack`
  112. static gitTransport = (route, req) => {
  113. let hostHeader = req.headers['host']
  114. let slug = slugOfHost(hostHeader == null ? '' : hostHeader.split(':')[0])
  115. let repoName = route.params.repo
  116. if (slug == '' || slugError(slug) != null || repoName != slug + '.git' || repoBySlug(slug) == null) { return plain(404, 'no such repository') }
  117. let last = req.path.slice(req.path.lastIndexOf('/') + 1)
  118. let advertise = last == 'refs'
  119. let service = advertise ? (req.query != null ? req.query.service : null) : last
  120. 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') }
  121. if (advertise && req.method != 'GET') { return plain(405, 'GET only') }
  122. if (!advertise && req.method != 'POST') { return plain(405, 'POST only') }
  123. // hl:http1 reads no chunked request body (body = null); nginx in front buffers it and sends a length. Say so, not "empty".
  124. let te = req.headers['transfer-encoding']
  125. if (!advertise && req.body == null && te != null && hlTypeName(te) == 'String' && te.toLowerCase().includes('chunked')) { return plain(411, 'this server needs the request with a length (behind the proxy that is automatic; a direct client: git config http.postBuffer 524288000)') }
  126. if (req.body != null && req.body.length > maxBody) { return plain(413, 'that request is too big') }
  127. if (service == 'git-receive-pack') {
  128. let realm = { 'WWW-Authenticate' = 'Basic realm="gitoria"' }
  129. let pw = passwordOf(req.headers['authorization'])
  130. 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) }
  131. let userId = userOfToken(pw)
  132. if (userId == null) { return plain(401, 'that token is not valid (removed, or mistyped)', realm) }
  133. let repo = repoBySlug(slug)
  134. if (userRecord(userId) == null || repo.owner != userId) { return plain(403, 'only the owner of this repository can push to it') }
  135. }
  136. let out = callGit(slug, service, advertise, req)
  137. if (out == null) { return plain(500, 'git gave no answer') }
  138. let headers = { 'Cache-Control' = 'no-cache, max-age=0, must-revalidate' 'Pragma' = 'no-cache' }
  139. if (advertise) {
  140. headers['Content-Type'] = 'application/x-' + service + '-advertisement'
  141. // protocol v2 answers without the service banner; v0/v1 starts with it (as git http-backend does)
  142. let v2 = protocolEnv(req).GIT_PROTOCOL != null && protocolEnv(req).GIT_PROTOCOL.includes('version=2')
  143. if (!v2) { out = pktLine('# service=' + service + NL) + '0000' + out }
  144. } else {
  145. headers['Content-Type'] = 'application/x-' + service + '-result'
  146. }
  147. return new Response(out, { headers = headers })
  148. }

Branches

Latest commits

  • 4a2d7125initial commitmre