gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite85eaf01e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmree85eaf01/project.hl

12.8 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 { dark, darker } from './shared/tokens.hl'
  18. import { reply, fail, wantsMarkdown, markdownReply } from './api.hl'
  19. import { repoRows, repoRow, repoDocument, listDocument, slugError } from './repos.hl'
  20. import { exchangeCode, ensureUser, hostPort, hostOnly, scheme, domainFor, slugOfHost } from './users.hl'
  21. import { ownsRepo, setTicketsConnection, repoBySlug } from './repos.hl'
  22. import { connectHref, exchangeConnect, notifyPush } from './tickets.hl'
  23. import { gitTransport } from './transport.hl'
  24. import { gitKeys, gitAccess } from './sshgate.hl'
  25. import Index from './components/index.hl'
  26. import Code from './components/code.hl'
  27. import Branch from './components/branch.hl'
  28. import Commit from './components/commit.hl'
  29. import Pulls from './components/pulls.hl'
  30. import Releases from './components/releases.hl'
  31. import Tickets from './components/tickets.hl'
  32. import Settings from './components/settings.hl'
  33. import LoginFailed from './components/loginfailed.hl'
  34. static siteName = "gitoria"
  35. appTitle = siteName
  36. styles = Styles
  37. // ---- THE INSTALLABLE APP (mission 046), the same way calendar.worldapi.org and tracker do it: hl:web's own web app
  38. // manifest (/__hl/manifest.webmanifest, linked from every head with the apple-touch-icon and theme-color) and service
  39. // worker (/__hl/sw.js) from these settings — no JavaScript of ours. Icons in icons/ (icon.svg is the source, the PNGs
  40. // are rendered from it with rsvg-convert, README "PWA"). The theme colour is the header's background (darker), the
  41. // splash background the page's (dark) — both off the tokens. Every repo address is its own origin, so each one gets
  42. // its own manifest and worker (start_url / = that repo's Readme).
  43. appThemeColor = darker.value
  44. appBackgroundColor = dark.value
  45. appIcons = [
  46. { src = '/icons/icon-192.png' sizes = '192x192' purpose = 'any' }
  47. { src = '/icons/icon-512.png' sizes = '512x512' purpose = 'any' }
  48. { src = '/icons/icon-192.png' sizes = '192x192' purpose = 'maskable' }
  49. { src = '/icons/icon-512.png' sizes = '512x512' purpose = 'maskable' }
  50. ]
  51. appTouchIcon = '/icons/apple-touch-icon.png'
  52. appFavicon = '/icons/favicon.svg'
  53. // OFFLINE: only the shell and `/` (components/main.hl says "You are offline" while the browser has no network). `/`
  54. // is kept as last seen (the worker is network-first and refreshes its copy on every online visit): the repo list on
  55. // the main address, the Readme on a repo address. Every other page (code, commits, pulls, …) needs the network and
  56. // gets hl:web's "Unavailable offline" page.
  57. offline = [ Index ]
  58. // ---- the API: reads are public (creating a repo is a web action for now) ------------------------
  59. apiRepos = (route, req) => {
  60. if (req.method != 'GET') { return fail(405, 'GET only') }
  61. let rows = repoRows()
  62. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows)) }
  63. return { repos = rows }
  64. }
  65. apiRepo = (route, req) => {
  66. if (req.method != 'GET') { return fail(405, 'GET only') }
  67. let row = repoRow(route.params.slug)
  68. if (row == null) { return fail(404, 'no such repository') }
  69. if (wantsMarkdown(req)) { return markdownReply(repoDocument(row)) }
  70. return row
  71. }
  72. // ---- THE LOGIN BUTTON'S RETURN (ident README "How apps use ident") ----------------------------
  73. // BACK TO THE PAGE: /login.js puts `?next=` into the button's return URL at the click. Only a same-origin PATH
  74. // goes (one `/`, URL-safe characters, ≤ 500) — or a full URL of THIS system: <scheme>://<label>.<host>
  75. // with a valid, non-reserved repo label and such a path. Anything else → `/`.
  76. nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  77. safePath = (want) => {
  78. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  79. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  80. let i = 0
  81. while (i < want.length) {
  82. if (!nextChars.includes(want[i])) { return '/' }
  83. i = i + 1
  84. }
  85. return want
  86. }
  87. safeNext = (want) => {
  88. if (want == null || hlTypeName(want) != 'String' || want.length > 500) { return '/' }
  89. let prefix = scheme + '://'
  90. if (!want.startsWith(prefix)) { return safePath(want) }
  91. let rest = want.slice(prefix.length)
  92. let slash = rest.indexOf('/')
  93. let hp = slash < 0 ? rest : rest.slice(0, slash)
  94. let path = slash < 0 ? '/' : rest.slice(slash)
  95. let dot = hp.indexOf('.')
  96. if (dot < 1 || hp.slice(dot + 1) != hostPort || slugError(hp.slice(0, dot)) != null) { return '/' }
  97. let p = safePath(path)
  98. if (p == '/' && path != '/') { return '/' }
  99. return prefix + hp + p
  100. }
  101. // A FAILED LOGIN is a page (components/loginfailed.hl): the reason is parked in the session, then → /login/failed
  102. failed = (req, why) => {
  103. let s = req.session
  104. let fresh = s == null
  105. if (fresh) { s = server.sessions.mint() }
  106. s.data.loginError = why
  107. server.sessions.save(s)
  108. let res = new Response('login failed: ' + why, { status = 302 headers = { 'Location' = '/login/failed' 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  109. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  110. return res
  111. }
  112. // the function route gets the cookie's session as req.session (hybriel #11); none yet → minted here
  113. loginCallback = (route, req) => {
  114. if (req.method != 'GET') { return failed(req, 'GET only') }
  115. let q = req.query != null ? req.query : {}
  116. let code = q.ident_code
  117. if (code == null || code == '') { return failed(req, 'ident sent no login code') }
  118. let x = exchangeCode(code)
  119. if (x.error != null) { return failed(req, x.error) }
  120. let u = ensureUser(x.identity)
  121. if (u == null) { return failed(req, 'could not store the user') }
  122. let s = req.session
  123. let fresh = s == null
  124. if (fresh) { s = server.sessions.mint() }
  125. s.user = { id = u.id }
  126. s.data.tag = randomBytes(16)
  127. s.data.loginError = null
  128. server.sessions.save(s)
  129. let res = new Response('logged in', { status = 302 headers = { 'Location' = safeNext(q.next) 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  130. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  131. return res
  132. }
  133. // ---- CONNECT A REPO TO A TICKETS PROJECT (gitoria#18; tickets.hl, components/settings.hl) ----------------------------
  134. // /settings/connect: the owner's click → a fresh nonce parked in the session (bound to the repo) → tickets' /connect.
  135. // /settings/connected: tickets' return with ?code&state → the nonce must match, the person must own the repo → the code is
  136. // exchanged from here, the key stored per repo → back to /settings. A failure is a note on the settings page.
  137. repoOfRequest = (req) => {
  138. let h = req.headers['host']
  139. let slug = slugOfHost(h == null ? '' : h.split(':')[0])
  140. return slug == '' || slugError(slug) != null ? null : repoBySlug(slug)
  141. }
  142. goTo = (req, where) => {
  143. let res = new Response('redirect', { status = 302 headers = { 'Location' = where 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  144. return res
  145. }
  146. connectStart = (route, req) => {
  147. if (req.method != 'GET') { return fail(405, 'GET only') }
  148. let repo = repoOfRequest(req)
  149. if (repo == null) { return fail(404, 'no such repository') }
  150. let s = req.session
  151. if (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }
  152. let nonce = randomBytes(16)
  153. s.data.connectState = repo.slug + '.' + nonce
  154. s.data.connectNote = null
  155. server.sessions.save(s)
  156. return goTo(req, connectHref(repo.slug, nonce))
  157. }
  158. connectBack = (route, req) => {
  159. if (req.method != 'GET') { return fail(405, 'GET only') }
  160. let repo = repoOfRequest(req)
  161. if (repo == null) { return fail(404, 'no such repository') }
  162. let s = req.session
  163. if (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }
  164. let q = req.query != null ? req.query : {}
  165. let want = s.data.connectState
  166. s.data.connectState = null
  167. let note = ''
  168. if (q.error != null && q.code == null) {
  169. note = 'Tickets did not connect: ' + ('' + q.error).slice(0, 100)
  170. } else if (want == null || want != repo.slug + '.' + q.state) {
  171. note = 'That connection was not started here (or was used already) — click "Tickets: connect" again.'
  172. } else {
  173. let x = exchangeConnect(q.code)
  174. if (x.error != null) {
  175. note = x.error
  176. } else {
  177. let r = setTicketsConnection(repo.slug, x.key, x.project, x.title, x.api)
  178. if (r.error != null) { note = r.error } else { notifyPush(repo.slug, true) }
  179. }
  180. }
  181. s.data.connectNote = note == '' ? null : note
  182. server.sessions.save(s)
  183. return goTo(req, '/settings')
  184. }
  185. // EVERY REPO VIEW IS ITS OWN PAGE (gitoria#16); `/` is the list on the main address, the Readme on a repo address.
  186. routes = [
  187. { pattern = "/favicon.ico" file = "./icons/favicon.ico" headers = { 'Cache-Control' = 'no-cache' } }
  188. // the installable app (mission 046): the icons (manifest and service worker are hl:web's own, from appIcons / offline)
  189. { pattern = "/icons/icon-192.png" file = "./icons/icon-192.png" headers = { 'Cache-Control' = 'no-cache' } }
  190. { pattern = "/icons/icon-512.png" file = "./icons/icon-512.png" headers = { 'Cache-Control' = 'no-cache' } }
  191. { pattern = "/icons/apple-touch-icon.png" file = "./icons/apple-touch-icon.png" headers = { 'Cache-Control' = 'no-cache' } }
  192. { pattern = "/icons/favicon.svg" file = "./icons/favicon.svg" headers = { 'Cache-Control' = 'no-cache' } }
  193. { pattern = "/login/callback" function = loginCallback }
  194. { pattern = "/login/failed" component = LoginFailed }
  195. { pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }
  196. { pattern = "/api/repos" function = apiRepos }
  197. { pattern = "/api/repos/:slug" function = apiRepo }
  198. { pattern = "/" component = Index }
  199. { pattern = "/code" component = Code }
  200. { pattern = "/code/*path" component = Code }
  201. { pattern = "/branch/*path" component = Branch }
  202. { pattern = "/commit/*path" component = Commit }
  203. { pattern = "/pulls" component = Pulls }
  204. { pattern = "/releases" component = Releases }
  205. { pattern = "/tickets" component = Tickets }
  206. { pattern = "/settings" component = Settings }
  207. { pattern = "/settings/connect" function = connectStart }
  208. { pattern = "/settings/connected" function = connectBack }
  209. { pattern = "/__git/keys" function = gitKeys }
  210. { pattern = "/__git/access" function = gitAccess }
  211. { pattern = "/:repo/info/refs" function = gitTransport }
  212. { pattern = "/:repo/git-upload-pack" function = gitTransport }
  213. { pattern = "/:repo/git-receive-pack" function = gitTransport }
  214. ]
  215. // WHO GETS THE PUSH: a new repo reaches every open page (the list follows live)
  216. // `gitoriaSignedIn` / `gitoriaSignedOut` go to the tabs of ONE session: the one whose login carries
  217. // that random tag (session.data.tag, set at login) — every open page of it switches without a reload.
  218. tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }
  219. audience = {
  220. repoCreated = (row, session) => { return true }
  221. ticketOpened = (slug, row, session) => { return true }
  222. gitoriaSignedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }
  223. gitoriaSignedOut = (tag, session) => { return tag != null && tagOf(session) == tag }
  224. }
  225. sessionDir = env('GITORIA_SESSIONS') != null ? env('GITORIA_SESSIONS') : null
  226. port = env('GITORIA_PORT') != null ? toNumber(env('GITORIA_PORT')) : 8360
  227. // HL_HOST = the interface hl:web binds (hybriel #24, fixed upstream): 127.0.0.1 on Byrodin behind nginx;
  228. // unset = 0.0.0.0 (dev on Loreana). GITORIA_WATCH=0 = no dev watcher. The session cookie is `gitoriasid` (hybriel #10).
  229. watching = env('GITORIA_WATCH') != '0'
  230. sessionCookie = 'gitoriasid'
  231. // the cookie's Domain (hybriel#44): every <slug>.<host> shares the login (users.hl domainFor; '' = host-only → null)
  232. sessionDomain = domainFor(hostOnly) != '' ? domainFor(hostOnly) : null
  233. server = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching, sessionCookie = sessionCookie, sessionDomain = sessionDomain)
  234. on Error(e) { console.log('error absorbed: ' + e.message) }

Branches

Latest commits

  • 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