gitoria
All repositories: gitoria
10.7 KB
// project.hl — gitoria.worldapi.org: THE APP. Routes and WHO HEARS WHAT. Hybriel on hl:web.//// HOW A REPO GETS ITS ADDRESS (ticket #6, gitoria#16): nginx sends `gitoria.worldapi.org` AND every// `<slug>.gitoria.worldapi.org` to this one app. Every page component that declares `host = null` gets the// request's host (without port, hybriel#74) on the SERVER, on the first load and on every hl:web navigation:// the main address shows the repo list + "create" (components/index.hl → list.hl), `<slug>.` shows that repo —// each repo view is its own page component on its own route (readme, code, branch, commit, pulls, releases,// tickets), rendered on the server WITH its content: git is read with hl:proc run(), which waits (hybriel#80).// The session cookie carries `Domain=.<host>` (hl:web `sessionDomain`, hybriel#44) so one// login holds on every address; the login button always returns through the main address// (the only origin registered in ident) and then on to the repo the login started from.import WebFramework from 'hl:web'import { env } from 'hl:proc'import { Response } from 'hl:http1'import { randomBytes } from 'hl:crypto'import Styles from './styles.hl'import { reply, fail, wantsMarkdown, markdownReply } from './api.hl'import { repoRows, repoRow, repoDocument, listDocument, slugError } from './repos.hl'import { exchangeCode, ensureUser, hostPort, hostOnly, scheme, domainFor, slugOfHost } from './users.hl'import { ownsRepo, setTicketsConnection, repoBySlug } from './repos.hl'import { connectHref, exchangeConnect, notifyPush } from './tickets.hl'import { gitTransport } from './transport.hl'import { gitKeys, gitAccess } from './sshgate.hl'import Index from './components/index.hl'import Code from './components/code.hl'import Branch from './components/branch.hl'import Commit from './components/commit.hl'import Pulls from './components/pulls.hl'import Releases from './components/releases.hl'import Tickets from './components/tickets.hl'import Settings from './components/settings.hl'import LoginFailed from './components/loginfailed.hl'static siteName = "gitoria"appTitle = siteNamestyles = Styles// ---- the API: reads are public (creating a repo is a web action for now) ------------------------apiRepos = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let rows = repoRows()if (wantsMarkdown(req)) { return markdownReply(listDocument(rows)) }return { repos = rows }}apiRepo = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let row = repoRow(route.params.slug)if (row == null) { return fail(404, 'no such repository') }if (wantsMarkdown(req)) { return markdownReply(repoDocument(row)) }return row}// ---- THE LOGIN BUTTON'S RETURN (ident README "How apps use ident") ----------------------------// BACK TO THE PAGE: /login.js puts `?next=` into the button's return URL at the click. Only a same-origin PATH// goes (one `/`, URL-safe characters, ≤ 500) — or a full URL of THIS system: <scheme>://<label>.<host>// with a valid, non-reserved repo label and such a path. Anything else → `/`.nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'safePath = (want) => {if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }let i = 0while (i < want.length) {if (!nextChars.includes(want[i])) { return '/' }i = i + 1}return want}safeNext = (want) => {if (want == null || hlTypeName(want) != 'String' || want.length > 500) { return '/' }let prefix = scheme + '://'if (!want.startsWith(prefix)) { return safePath(want) }let rest = want.slice(prefix.length)let slash = rest.indexOf('/')let hp = slash < 0 ? rest : rest.slice(0, slash)let path = slash < 0 ? '/' : rest.slice(slash)let dot = hp.indexOf('.')if (dot < 1 || hp.slice(dot + 1) != hostPort || slugError(hp.slice(0, dot)) != null) { return '/' }let p = safePath(path)if (p == '/' && path != '/') { return '/' }return prefix + hp + p}// A FAILED LOGIN is a page (components/loginfailed.hl): the reason is parked in the session, then → /login/failedfailed = (req, why) => {let s = req.sessionlet fresh = s == nullif (fresh) { s = server.sessions.mint() }s.data.loginError = whyserver.sessions.save(s)let res = new Response('login failed: ' + why, { status = 302 headers = { 'Location' = '/login/failed' 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }return res}// the function route gets the cookie's session as req.session (hybriel #11); none yet → minted hereloginCallback = (route, req) => {if (req.method != 'GET') { return failed(req, 'GET only') }let q = req.query != null ? req.query : {}let code = q.ident_codeif (code == null || code == '') { return failed(req, 'ident sent no login code') }let x = exchangeCode(code)if (x.error != null) { return failed(req, x.error) }let u = ensureUser(x.identity)if (u == null) { return failed(req, 'could not store the user') }let s = req.sessionlet fresh = s == nullif (fresh) { s = server.sessions.mint() }s.user = { id = u.id }s.data.tag = randomBytes(16)s.data.loginError = nullserver.sessions.save(s)let res = new Response('logged in', { status = 302 headers = { 'Location' = safeNext(q.next) 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }return res}// ---- CONNECT A REPO TO A TICKETS PROJECT (gitoria#18; tickets.hl, components/settings.hl) ----------------------------// /settings/connect: the owner's click → a fresh nonce parked in the session (bound to the repo) → tickets' /connect.// /settings/connected: tickets' return with ?code&state → the nonce must match, the person must own the repo → the code is// exchanged from here, the key stored per repo → back to /settings. A failure is a note on the settings page.repoOfRequest = (req) => {let h = req.headers['host']let slug = slugOfHost(h == null ? '' : h.split(':')[0])return slug == '' || slugError(slug) != null ? null : repoBySlug(slug)}goTo = (req, where) => {let res = new Response('redirect', { status = 302 headers = { 'Location' = where 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })return res}connectStart = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let repo = repoOfRequest(req)if (repo == null) { return fail(404, 'no such repository') }let s = req.sessionif (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }let nonce = randomBytes(16)s.data.connectState = repo.slug + '.' + nonces.data.connectNote = nullserver.sessions.save(s)return goTo(req, connectHref(repo.slug, nonce))}connectBack = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let repo = repoOfRequest(req)if (repo == null) { return fail(404, 'no such repository') }let s = req.sessionif (s == null || !ownsRepo(repo.slug, s)) { return goTo(req, '/settings') }let q = req.query != null ? req.query : {}let want = s.data.connectStates.data.connectState = nulllet note = ''if (q.error != null && q.code == null) {note = 'Tickets did not connect: ' + ('' + q.error).slice(0, 100)} else if (want == null || want != repo.slug + '.' + q.state) {note = 'That connection was not started here (or was used already) — click "Tickets: connect" again.'} else {let x = exchangeConnect(q.code)if (x.error != null) {note = x.error} else {let r = setTicketsConnection(repo.slug, x.key, x.project, x.title, x.api)if (r.error != null) { note = r.error } else { notifyPush(repo.slug, true) }}}s.data.connectNote = note == '' ? null : noteserver.sessions.save(s)return goTo(req, '/settings')}// EVERY REPO VIEW IS ITS OWN PAGE (gitoria#16); `/` is the list on the main address, the Readme on a repo address.routes = [{ pattern = "/favicon.ico" direct = "" }{ pattern = "/login/callback" function = loginCallback }{ pattern = "/login/failed" component = LoginFailed }{ pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/api/repos" function = apiRepos }{ pattern = "/api/repos/:slug" function = apiRepo }{ pattern = "/" component = Index }{ pattern = "/code" component = Code }{ pattern = "/code/*path" component = Code }{ pattern = "/branch/*path" component = Branch }{ pattern = "/commit/*path" component = Commit }{ pattern = "/pulls" component = Pulls }{ pattern = "/releases" component = Releases }{ pattern = "/tickets" component = Tickets }{ pattern = "/settings" component = Settings }{ pattern = "/settings/connect" function = connectStart }{ pattern = "/settings/connected" function = connectBack }{ pattern = "/__git/keys" function = gitKeys }{ pattern = "/__git/access" function = gitAccess }{ pattern = "/:repo/info/refs" function = gitTransport }{ pattern = "/:repo/git-upload-pack" function = gitTransport }{ pattern = "/:repo/git-receive-pack" function = gitTransport }]// WHO GETS THE PUSH: a new repo reaches every open page (the list follows live)// `gitoriaSignedIn` / `gitoriaSignedOut` go to the tabs of ONE session: the one whose login carries// that random tag (session.data.tag, set at login) — every open page of it switches without a reload.tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }audience = {repoCreated = (row, session) => { return true }ticketOpened = (slug, row, session) => { return true }gitoriaSignedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }gitoriaSignedOut = (tag, session) => { return tag != null && tagOf(session) == tag }}sessionDir = env('GITORIA_SESSIONS') != null ? env('GITORIA_SESSIONS') : nullport = env('GITORIA_PORT') != null ? toNumber(env('GITORIA_PORT')) : 8360// HL_HOST = the interface hl:web binds (hybriel #24, fixed upstream): 127.0.0.1 on Byrodin behind nginx;// unset = 0.0.0.0 (dev on Loreana). GITORIA_WATCH=0 = no dev watcher. The session cookie is `gitoriasid` (hybriel #10).watching = env('GITORIA_WATCH') != '0'sessionCookie = 'gitoriasid'// the cookie's Domain (hybriel#44): every <slug>.<host> shares the login (users.hl domainFor; '' = host-only → null)sessionDomain = domainFor(hostOnly) != '' ? domainFor(hostOnly) : nullserver = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching, sessionCookie = sessionCookie, sessionDomain = sessionDomain)on Error(e) { console.log('error absorbed: ' + e.message) }
Branches
- mainmain branch