gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite2deed6de2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemree2deed6d/project.hl

10.7 KB

  1. // project.hl — gitoria.worldapi.org: THE APP. Routes and WHO HEARS WHAT. Hybriel on hl:web.
  2. //
  3. // HOW A REPO GETS ITS ADDRESS (ticket #6, gitoria#16): nginx sends `gitoria.worldapi.org` AND every
  4. // `<slug>.gitoria.worldapi.org` to this one app. Every page component that declares `host = null` gets the
  5. // request's host (without port, hybriel#74) on the SERVER, on the first load and on every hl:web navigation:
  6. // the main address shows the repo list + "create" (components/index.hl → list.hl), `<slug>.` shows that repo —
  7. // each repo view is its own page component on its own route (readme, code, branch, commit, pulls, releases,
  8. // tickets), rendered on the server WITH its content: git is read with hl:proc run(), which waits (hybriel#80).
  9. // The session cookie carries `Domain=.<host>` (hl:web `sessionDomain`, hybriel#44) so one
  10. // login holds on every address; the login button always returns through the main address
  11. // (the only origin registered in ident) and then on to the repo the login started from.
  12. import WebFramework from 'hl:web'
  13. import { env } from 'hl:proc'
  14. import { Response } from 'hl:http1'
  15. import { randomBytes } from 'hl:crypto'
  16. import Styles from './styles.hl'
  17. import { reply, fail, wantsMarkdown, markdownReply } from './api.hl'
  18. import { repoRows, repoRow, repoDocument, listDocument, slugError } from './repos.hl'
  19. import { exchangeCode, ensureUser, hostPort, hostOnly, scheme, domainFor, slugOfHost } from './users.hl'
  20. import { ownsRepo, setTicketsConnection, repoBySlug } from './repos.hl'
  21. import { connectHref, exchangeConnect, notifyPush } from './tickets.hl'
  22. import { gitTransport } from './transport.hl'
  23. import { gitKeys, gitAccess } from './sshgate.hl'
  24. import Index from './components/index.hl'
  25. import Code from './components/code.hl'
  26. import Branch from './components/branch.hl'
  27. import Commit from './components/commit.hl'
  28. import Pulls from './components/pulls.hl'
  29. import Releases from './components/releases.hl'
  30. import Tickets from './components/tickets.hl'
  31. import Settings from './components/settings.hl'
  32. import LoginFailed from './components/loginfailed.hl'
  33. static siteName = "gitoria"
  34. appTitle = siteName
  35. styles = Styles
  36. // ---- the API: reads are public (creating a repo is a web action for now) ------------------------
  37. apiRepos = (route, req) => {
  38. if (req.method != 'GET') { return fail(405, 'GET only') }
  39. let rows = repoRows()
  40. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows)) }
  41. return { repos = rows }
  42. }
  43. apiRepo = (route, req) => {
  44. if (req.method != 'GET') { return fail(405, 'GET only') }
  45. let row = repoRow(route.params.slug)
  46. if (row == null) { return fail(404, 'no such repository') }
  47. if (wantsMarkdown(req)) { return markdownReply(repoDocument(row)) }
  48. return row
  49. }
  50. // ---- THE LOGIN BUTTON'S RETURN (ident README "How apps use ident") ----------------------------
  51. // BACK TO THE PAGE: /login.js puts `?next=` into the button's return URL at the click. Only a same-origin PATH
  52. // goes (one `/`, URL-safe characters, ≤ 500) — or a full URL of THIS system: <scheme>://<label>.<host>
  53. // with a valid, non-reserved repo label and such a path. Anything else → `/`.
  54. nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  55. safePath = (want) => {
  56. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  57. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  58. let i = 0
  59. while (i < want.length) {
  60. if (!nextChars.includes(want[i])) { return '/' }
  61. i = i + 1
  62. }
  63. return want
  64. }
  65. safeNext = (want) => {
  66. if (want == null || hlTypeName(want) != 'String' || want.length > 500) { return '/' }
  67. let prefix = scheme + '://'
  68. if (!want.startsWith(prefix)) { return safePath(want) }
  69. let rest = want.slice(prefix.length)
  70. let slash = rest.indexOf('/')
  71. let hp = slash < 0 ? rest : rest.slice(0, slash)
  72. let path = slash < 0 ? '/' : rest.slice(slash)
  73. let dot = hp.indexOf('.')
  74. if (dot < 1 || hp.slice(dot + 1) != hostPort || slugError(hp.slice(0, dot)) != null) { return '/' }
  75. let p = safePath(path)
  76. if (p == '/' && path != '/') { return '/' }
  77. return prefix + hp + p
  78. }
  79. // A FAILED LOGIN is a page (components/loginfailed.hl): the reason is parked in the session, then → /login/failed
  80. failed = (req, why) => {
  81. let s = req.session
  82. let fresh = s == null
  83. if (fresh) { s = server.sessions.mint() }
  84. s.data.loginError = why
  85. server.sessions.save(s)
  86. let res = new Response('login failed: ' + why, { status = 302 headers = { 'Location' = '/login/failed' 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  87. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  88. return res
  89. }
  90. // the function route gets the cookie's session as req.session (hybriel #11); none yet → minted here
  91. loginCallback = (route, req) => {
  92. if (req.method != 'GET') { return failed(req, 'GET only') }
  93. let q = req.query != null ? req.query : {}
  94. let code = q.ident_code
  95. if (code == null || code == '') { return failed(req, 'ident sent no login code') }
  96. let x = exchangeCode(code)
  97. if (x.error != null) { return failed(req, x.error) }
  98. let u = ensureUser(x.identity)
  99. if (u == null) { return failed(req, 'could not store the user') }
  100. let s = req.session
  101. let fresh = s == null
  102. if (fresh) { s = server.sessions.mint() }
  103. s.user = { id = u.id }
  104. s.data.tag = randomBytes(16)
  105. s.data.loginError = null
  106. server.sessions.save(s)
  107. let res = new Response('logged in', { status = 302 headers = { 'Location' = safeNext(q.next) 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  108. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  109. return res
  110. }
  111. // ---- CONNECT A REPO TO A TICKETS PROJECT (gitoria#18; tickets.hl, components/settings.hl) ----------------------------
  112. // /settings/connect: the owner's click → a fresh nonce parked in the session (bound to the repo) → tickets' /connect.
  113. // /settings/connected: tickets' return with ?code&state → the nonce must match, the person must own the repo → the code is
  114. // exchanged from here, the key stored per repo → back to /settings. A failure is a note on the settings page.
  115. repoOfRequest = (req) => {
  116. let h = req.headers['host']
  117. let slug = slugOfHost(h == null ? '' : h.split(':')[0])
  118. return slug == '' || slugError(slug) != null ? null : repoBySlug(slug)
  119. }
  120. goTo = (req, where) => {
  121. let res = new Response('redirect', { status = 302 headers = { 'Location' = where 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  122. return res
  123. }
  124. connectStart = (route, req) => {
  125. if (req.method != 'GET') { return fail(405, 'GET only') }
  126. let repo = repoOfRequest(req)
  127. if (repo == null) { return fail(404, 'no such repository') }
  128. let s = req.session
  129. if (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }
  130. let nonce = randomBytes(16)
  131. s.data.connectState = repo.slug + '.' + nonce
  132. s.data.connectNote = null
  133. server.sessions.save(s)
  134. return goTo(req, connectHref(repo.slug, nonce))
  135. }
  136. connectBack = (route, req) => {
  137. if (req.method != 'GET') { return fail(405, 'GET only') }
  138. let repo = repoOfRequest(req)
  139. if (repo == null) { return fail(404, 'no such repository') }
  140. let s = req.session
  141. if (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }
  142. let q = req.query != null ? req.query : {}
  143. let want = s.data.connectState
  144. s.data.connectState = null
  145. let note = ''
  146. if (q.error != null && q.code == null) {
  147. note = 'Tickets did not connect: ' + ('' + q.error).slice(0, 100)
  148. } else if (want == null || want != repo.slug + '.' + q.state) {
  149. note = 'That connection was not started here (or was used already) — click "Tickets: connect" again.'
  150. } else {
  151. let x = exchangeConnect(q.code)
  152. if (x.error != null) {
  153. note = x.error
  154. } else {
  155. let r = setTicketsConnection(repo.slug, x.key, x.project, x.title, x.api)
  156. if (r.error != null) { note = r.error } else { notifyPush(repo.slug, true) }
  157. }
  158. }
  159. s.data.connectNote = note == '' ? null : note
  160. server.sessions.save(s)
  161. return goTo(req, '/settings')
  162. }
  163. // EVERY REPO VIEW IS ITS OWN PAGE (gitoria#16); `/` is the list on the main address, the Readme on a repo address.
  164. routes = [
  165. { pattern = "/favicon.ico" direct = "" }
  166. { pattern = "/login/callback" function = loginCallback }
  167. { pattern = "/login/failed" component = LoginFailed }
  168. { pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }
  169. { pattern = "/api/repos" function = apiRepos }
  170. { pattern = "/api/repos/:slug" function = apiRepo }
  171. { pattern = "/" component = Index }
  172. { pattern = "/code" component = Code }
  173. { pattern = "/code/*path" component = Code }
  174. { pattern = "/branch/*path" component = Branch }
  175. { pattern = "/commit/*path" component = Commit }
  176. { pattern = "/pulls" component = Pulls }
  177. { pattern = "/releases" component = Releases }
  178. { pattern = "/tickets" component = Tickets }
  179. { pattern = "/settings" component = Settings }
  180. { pattern = "/settings/connect" function = connectStart }
  181. { pattern = "/settings/connected" function = connectBack }
  182. { pattern = "/__git/keys" function = gitKeys }
  183. { pattern = "/__git/access" function = gitAccess }
  184. { pattern = "/:repo/info/refs" function = gitTransport }
  185. { pattern = "/:repo/git-upload-pack" function = gitTransport }
  186. { pattern = "/:repo/git-receive-pack" function = gitTransport }
  187. ]
  188. // WHO GETS THE PUSH: a new repo reaches every open page (the list follows live)
  189. // `gitoriaSignedIn` / `gitoriaSignedOut` go to the tabs of ONE session: the one whose login carries
  190. // that random tag (session.data.tag, set at login) — every open page of it switches without a reload.
  191. tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }
  192. audience = {
  193. repoCreated = (row, session) => { return true }
  194. ticketOpened = (slug, row, session) => { return true }
  195. gitoriaSignedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }
  196. gitoriaSignedOut = (tag, session) => { return tag != null && tagOf(session) == tag }
  197. }
  198. sessionDir = env('GITORIA_SESSIONS') != null ? env('GITORIA_SESSIONS') : null
  199. port = env('GITORIA_PORT') != null ? toNumber(env('GITORIA_PORT')) : 8360
  200. // HL_HOST = the interface hl:web binds (hybriel #24, fixed upstream): 127.0.0.1 on Byrodin behind nginx;
  201. // unset = 0.0.0.0 (dev on Loreana). GITORIA_WATCH=0 = no dev watcher. The session cookie is `gitoriasid` (hybriel #10).
  202. watching = env('GITORIA_WATCH') != '0'
  203. sessionCookie = 'gitoriasid'
  204. // the cookie's Domain (hybriel#44): every <slug>.<host> shares the login (users.hl domainFor; '' = host-only → null)
  205. sessionDomain = domainFor(hostOnly) != '' ? domainFor(hostOnly) : null
  206. server = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching, sessionCookie = sessionCookie, sessionDomain = sessionDomain)
  207. on Error(e) { console.log('error absorbed: ' + e.message) }

Branches

Latest commits

  • 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