gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitcc7bf7bacc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mrecc7bf7ba/lib/transport.hl

5.8 KB

  1. // lib/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. // The checks, the plain answers and the pkt-line are in transport-helpers.hl.
  15. import { Response } from 'hl:http1'
  16. import { run } from 'hl:proc'
  17. import { readFile, writeFile, remove, exists } from 'hl:fs'
  18. import { randomBytes } from 'hl:crypto'
  19. import { NL, slugOfHost, publicUrl } from './util.hl'
  20. import { repoBySlug, mayPush } from './repos.hl'
  21. import { slugError } from './repos-helpers.hl'
  22. import { userOfToken } from './tokens.hl'
  23. import { repoDir, tmpDir } from './git.hl'
  24. import { notifyPush } from './tickets.hl'
  25. import { passwordOf, plain, protocolEnv, pktLine } from './transport-helpers.hl'
  26. static seconds = 300 // one upload-pack / receive-pack call
  27. static maxBody = 500000000 // a push or fetch request above this is refused (bytes)
  28. // 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)
  29. static callGit = (slug, service, advertise, req) => {
  30. dir = tmpDir()
  31. name = dir + '/' + randomBytes(12, 'hex')
  32. inFile = name + '.in'
  33. outFile = name + '.out'
  34. let zipped = false
  35. let script = 'exec git ' + service.slice(4) + ' --stateless-rpc --advertise-refs "$1" > "$3"'
  36. if (!advertise) {
  37. body = req.body == null ? '' : req.body
  38. writeFile(inFile, body, 384)
  39. enc = req.headers['content-encoding']
  40. zipped = enc != null && hlTypeName(enc) == 'String' && (enc.toLowerCase() == 'gzip' || enc.toLowerCase() == 'x-gzip')
  41. script = zipped ? 'gzip -dc < "$2" | git ' + service.slice(4) + ' --stateless-rpc "$1" > "$3"' : 'exec git ' + service.slice(4) + ' --stateless-rpc "$1" < "$2" > "$3"'
  42. }
  43. r = run(['sh', '-c', script, 'sh', repoDir(slug), inFile, outFile], { timeout = seconds env = protocolEnv(req) })
  44. out = exists(outFile) ? readFile(outFile) : null
  45. if (exists(inFile)) { remove(inFile) }
  46. if (exists(outFile)) { remove(outFile) }
  47. // 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
  48. if (out == null || (out == '' && (r == null || r.exit != 0))) { return null }
  49. return out
  50. }
  51. // THE ROUTE (project.hl, a function route): `/:repo/info/refs`, `/:repo/git-upload-pack`, `/:repo/git-receive-pack`
  52. static gitTransport = (route, req) => {
  53. hostHeader = req.headers['host']
  54. slug = slugOfHost(hostHeader == null ? '' : hostHeader.split(':')[0])
  55. repoName = route.params.repo
  56. if (slug == '' || slugError(slug) != null || repoName != slug + '.git' || repoBySlug(slug) == null) { return plain(404, 'no such repository') }
  57. last = req.path.slice(req.path.lastIndexOf('/') + 1)
  58. advertise = last == 'refs'
  59. service = advertise ? (req.query != null ? req.query.service : null) : last
  60. 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') }
  61. if (advertise && req.method != 'GET') { return plain(405, 'GET only') }
  62. if (!advertise && req.method != 'POST') { return plain(405, 'POST only') }
  63. if (req.body != null && req.body.length > maxBody) { return plain(413, 'that request is too big') }
  64. if (service == 'git-receive-pack') {
  65. realm = { 'WWW-Authenticate' = 'Basic realm="gitoria"' }
  66. pw = passwordOf(req.headers['authorization'])
  67. 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) }
  68. userId = userOfToken(pw)
  69. if (userId == null) { return plain(401, 'that token is not valid (removed, or mistyped)', realm) }
  70. repo = repoBySlug(slug)
  71. if (!mayPush(userId, repo)) { return plain(403, 'only the owner of this repository can push to it') }
  72. }
  73. let out = callGit(slug, service, advertise, req)
  74. if (out == null) { return plain(500, 'git gave no answer') }
  75. let headers = { 'Cache-Control' = 'no-cache, max-age=0, must-revalidate' 'Pragma' = 'no-cache' }
  76. if (advertise) {
  77. headers['Content-Type'] = 'application/x-' + service + '-advertisement'
  78. // protocol v2 answers without the service banner; v0/v1 starts with it (as git http-backend does)
  79. v2 = protocolEnv(req).GIT_PROTOCOL != null && protocolEnv(req).GIT_PROTOCOL.includes('version=2')
  80. if (!v2) { out = pktLine('# service=' + service + NL) + '0000' + out }
  81. } else {
  82. headers['Content-Type'] = 'application/x-' + service + '-result'
  83. // a push arrived: tell the connected tickets project about commits naming a ticket (tickets.hl; never fails the push)
  84. if (service == 'git-receive-pack') { notifyPush(slug, false) }
  85. }
  86. return new Response(out, { headers = headers })
  87. }

Branches

Latest commits

  • cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
  • 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
  • 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
  • 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