tickets
All repositories: gitoria
31.0 KB
// project.hl — tickets.worldapi.org: THE APP. Routes (pages + JSON API) and WHO HEARS WHAT.import WebFramework from 'hl:web'import { env } from 'hl:proc'import Styles from './styles.hl'import { ticketRows, ticketWithEvents, projectNames, projectByAnySlug, projectByRef, projectRecords, projectRowOf, createProject, updateProject, memberRows, setMemberRole, removeMember, userByRef, isAdminOf, roleOf, createTicket, addComment, changeState, assignTicket, editTicket, pageEventOf, stateOf, states, roles, inboxRows, ticketByRef, ticketAt, ticketHref, setParent, changeBlocker, relatedViews, addMember, projectSlugOf } from './store.hl'import { migrateAll } from './migrate.hl'import { createInvite, acceptedInvite } from './invites.hl'import { Response } from 'hl:http1'import { reply, fail, queryOf, readBody, refuse, unauthorized } from './api.hl'import { userOfBearer, exchangeCode, ensureUser, userRecord } from './users.hl'import { createRequest, connectionOfKey, personOf, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'import { randomBytes } from 'hl:crypto'import { wantsMarkdown, markdownReply, listDocument, ticketDocument } from './mdview.hl'import TicketList from './components/ticket_list.hl'import Ticket from './components/ticket.hl'import Inbox from './components/inbox.hl'import You from './components/you.hl'import NewProject from './components/new_project.hl'import Settings from './components/settings.hl'import Connect from './components/connect.hl'static siteName = "tickets"appTitle = siteNamestyles = Styles// ---- the JSON API (for the scheduler and a CLI) ----------------------------------------// Every write pushes the same frames the web faces push, so an open browser follows an// API write live. See README.md "API" for the shapes. Every POST body goes through// readBody (api.hl): invalid JSON, unknown field, missing/empty required field, non-string// value → 400.// WRITES NEED A TOKEN (ticket #7): `Authorization: Bearer <token>` (users.hl; a logged-in user// makes tokens on /you). No / bad / revoked token → 401, checked BEFORE the body. The author is// the token's user; a body with `author` → 400 naming it. Reads stay public.//// THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,// the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise,// unchanged.// EDITING (ticket #8): POST …/edit { subject?, summary? } — a member with the role edit or admin// of the ticket's project → else 403; the history keeps the previous values.//// TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global// number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the// per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.byRef = (route) => { return ticketByRef(route.params.ref) }byNumber = (route) => { return ticketAt(route.params.slug, route.params.number) }stateFilter = (q) => {if (q.state == null || q.state == '') { return { state = null } }let st = stateOf(q.state)if (st == null) { return { bad = fail(400, 'unknown state — one of: ' + states.join(', ')) } }return { state = st }}listTickets = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }// hl:http1 hands the parsed query string as `req.query`; the path carries nonelet q = req.query != null ? req.query : queryOf(req.path)let sf = stateFilter(q)if (sf.bad != null) { return sf.bad }let pr = q.project != null && q.project != '' ? q.project : nulllet rows = ticketRows(pr, sf.state)if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }return { tickets = rows }}// the filters of a list, for the Markdown heading ('' = none)filterLabel = (project, state) => {let parts = []if (project != null) { parts.push('project ' + project) }if (state != null) { parts.push('state ' + state) }return parts.join(', ')}// the token's user of an API write, or null (→ 401)apiUser = (req) => { return userOfBearer(req.headers['authorization']) }// WHO WRITES, for the endpoints an app's KEY may also use (ticket #21: create a ticket, comment, change the state):// { user } for a user's token, { conn } for a project key (connections.hl), null = 401 (checked before the body)apiAuth = (req) => {let h = req.headers['authorization']let u = userOfBearer(h)if (u != null) { return { user = u } }let c = connectionOfKey(h)return c != null ? { conn = c } : null}// the acting user inside project `projectId`: { user } or { response }. A key reaches ITS project only and names the// person by `X-Tickets-Identity`; the project's roles then decide as for any user.actorIn = (auth, req, projectId) => {if (auth.user != null) { return { user = auth.user } }if (projectId == null || auth.conn.project != projectId) { return { response = reply(403, { error = 'this key belongs to another project' field = '' }) } }let a = personOf(req.headers['x-tickets-identity'])if (a.error != null) { return { response = reply(403, { error = a.error field = '' }) } }return { user = a.user }}// POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }createFromApi = (project, b, auth, req) => {let pr = projectByRef(project)let w = actorIn(auth, req, pr != null ? pr.id : null)if (w.response != null) { return w.response }let user = w.userlet r = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }if (!r.existed) { emit client ticketCreated(r.ticket) }return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })}postTicket = (route, req) => {if (req.method == 'GET') { return listTickets(route, req) }if (req.method != 'POST') { return fail(405, 'GET or POST') }let u = apiAuth(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])if (rb.bad != null) { return refuse(rb.bad) }return createFromApi(rb.body.project, rb.body, u, req)}projectTickets = (route, req) => {let slug = route.params.slugif (req.method == 'GET') {if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }let q = req.query != null ? req.query : queryOf(req.path)let sf = stateFilter(q)if (sf.bad != null) { return sf.bad }let rows = ticketRows(slug, sf.state)if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }return { tickets = rows }}if (req.method != 'POST') { return fail(405, 'GET or POST') }let u = apiAuth(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])if (rb.bad != null) { return refuse(rb.bad) }return createFromApi(slug, rb.body, u, req)}getOne = (t, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }if (t == null) { return fail(404, 'no such ticket') }let data = ticketWithEvents(t)if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }return data}commentOnTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let auth = apiAuth(req)if (auth == null) { return unauthorized() }let rb = readBody(req, ['text'], [])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }let w = actorIn(auth, req, t.project)if (w.response != null) { return w.response }let u = w.userlet b = rb.bodylet r = addComment(t.id, u, b.text)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return reply(201, r)}stateOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let auth = apiAuth(req)if (auth == null) { return unauthorized() }let rb = readBody(req, ['state'], ['text'])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }let w = actorIn(auth, req, t.project)if (w.response != null) { return w.response }let u = w.userlet b = rb.bodylet r = changeState(t.id, b.state, u, b.text)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }emit client ticketsRelated(relatedViews(r.ticket.id, []))return reply(201, r)}// POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.// Needs the role edit or admin → else 403.assignOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, [], ['assignee'])if (rb.bad != null) { return refuse(rb.bad) }if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }if (t == null) { return fail(404, 'no such ticket') }let who = ''if (rb.body.assignee.trim() != '') {let f = userByRef(rb.body.assignee, t.project)if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }who = f.user.id}let r = assignTicket(t.id, u, who)if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)return reply(201, r)}// POST …/edit { subject?, summary? } (ticket #8): the token's user must be the ticket's authoreditOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, [], ['subject' 'summary'])if (rb.bad != null) { return refuse(rb.bad) }if (t == null) { return fail(404, 'no such ticket') }let b = rb.bodylet r = editTicket(t.id, u, b.subject, b.summary)if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(relatedViews(r.ticket.id, []))return reply(201, r)}// RELATIONS (ticket #4, mission 017; store.hl "relations"): a ticket is named as// `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.// POST …/parent { parent }: set the parent; `"parent": ""` removes it.// POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.// 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.linkReply = (r) => {if (r.error == 'no such ticket') { return fail(404, r.error) }if (r.forbidden == true) { return reply(403, { error = r.error }) }if (r.error != null) { return refuse({ error = r.error field = r.field }) }emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)emit client ticketsRelated(r.related)return reply(201, { ticket = r.ticket event = r.event })}parentOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, [], ['parent'])if (rb.bad != null) { return refuse(rb.bad) }if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }if (t == null) { return fail(404, 'no such ticket') }return linkReply(setParent(t.id, u, rb.body.parent))}blockersOfTicket = (t, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, [], ['add' 'remove'])if (rb.bad != null) { return refuse(rb.bad) }let b = rb.bodyif ((b.add == null) == (b.remove == null)) { return refuse({ error = "give exactly one of 'add' or 'remove' (<project>#<number>)" field = b.add == null ? 'add' : 'remove' }) }if (t == null) { return fail(404, 'no such ticket') }let adding = b.add != nullreturn linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))}getTicket = (route, req) => { return getOne(byRef(route), req) }postComment = (route, req) => { return commentOnTicket(byRef(route), req) }postState = (route, req) => { return stateOfTicket(byRef(route), req) }getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }postEdit = (route, req) => { return editOfTicket(byRef(route), req) }postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }postParent = (route, req) => { return parentOfTicket(byRef(route), req) }postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }// ---- PROJECTS AND MEMBERS (ticket #20; store.hl) ---------------------------------------------------// GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }// POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens// a project and is its first admin; the slug is generated from the title unless given// GET /api/projects/:slug → { project, members } (the slug may be an old one)// POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working// POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone// who logged in here; sets (adds / changes) the role: use | edit | admin// POST /api/projects/:slug/members/remove { user } — admin only// POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }// GET /api/inbox — the token's user: { pending, review }, the tickets assigned to themgetProjects = (route, req) => {if (req.method == 'POST') { return postNewProject(req) }if (req.method != 'GET') { return fail(405, 'GET or POST') }let details = []for (p of projectRecords()) { details.push(projectRowOf(p)) }return { projects = projectNames() details = details states = states roles = roles }}// a refusal of the store: 403 when the role is missing, else 400denied = (r) => {if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }return refuse({ error = r.error field = r.field })}postNewProject = (req) => {let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['title'], ['slug' 'description'])if (rb.bad != null) { return refuse(rb.bad) }let r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)if (r.error != null) { return denied(r) }return reply(201, r)}getProject = (route, req) => {let p = projectByAnySlug(route.params.slug)if (req.method == 'GET') {if (p == null) { return fail(404, 'no such project') }return { project = projectRowOf(p) members = memberRows(p.id) }}if (req.method != 'POST') { return fail(405, 'GET or POST') }let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, [], ['title' 'slug' 'description'])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }let r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)if (r.error != null) { return denied(r) }return reply(200, r)}postMembers = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let p = projectByAnySlug(route.params.slug)let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['user' 'role'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }let f = userByRef(rb.body.user, null)if (f.error != null) { return refuse({ error = f.error field = 'user' }) }let r = setMemberRole(p.id, u, f.user.id, rb.body.role)if (r.error != null) { return denied(r) }return reply(200, r)}postMemberRemove = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let p = projectByAnySlug(route.params.slug)let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['user'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }let f = userByRef(rb.body.user, p.id)if (f.error != null) { return refuse({ error = f.error field = 'user' }) }let r = removeMember(p.id, u, f.user.id)if (r.error != null) { return denied(r) }return reply(200, r)}// an invite is only for an admin; ident makes the link (invites.hl)makeInvite = (p, u, role, uses, days, email) => {if (!isAdminOf(p.id, u)) { return { error = 'only an admin of the project can invite' field = '' forbidden = true } }if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }let n = uses == null ? 1 : toNumber(uses)let d = days == null ? 7 : toNumber(days)if (n == null || n < 1 || n > 1000) { return { error = "'uses' must be a number from 1 to 1000" field = 'uses' } }if (d == null || d < 1 || d > 90) { return { error = "'days' must be a number from 1 to 90" field = 'days' } }return createInvite(p.id, role, n, d, email)}postInvites = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let p = projectByAnySlug(route.params.slug)let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['role'], ['uses' 'days' 'email'])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }let b = rb.bodylet r = makeInvite(p, u, b.role, b.uses, b.days, b.email)if (r.error != null) { return denied(r) }return reply(201, r)}getInbox = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let u = apiUser(req)if (u == null) { return unauthorized() }return inboxRows(u)}// ---- CONNECTING AN APP (ticket #21; connections.hl) --------------------------------------------------// GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to// the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.// POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:// 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.// GET /api/projects/:slug/connections — who is connected (public, like the project page)// POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at onceconnectStart = (route, req) => {if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }let q = req.query != null ? req.query : {}let r = createRequest(q.app, q.label, q['return'], q.state)if (r.error != null) { return htmlPage(400, 'Connect', r.error) }let to = '/connect/' + r.noncereturn new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })}connectExchange = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let rb = readBody(req, ['code'], [])if (rb.bad != null) { return refuse(rb.bad) }let r = exchangeConnectCode(rb.body.code)if (r.error != null) { return refuse({ error = r.error field = 'code' }) }emit client connectionsChanged(r.project)return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })}projectConnections = (route, req) => {if (req.method != 'GET') { return fail(405, 'GET only') }let p = projectByAnySlug(route.params.slug)if (p == null) { return fail(404, 'no such project') }return { connections = connectionRows(p.id) }}projectDisconnect = (route, req) => {if (req.method != 'POST') { return fail(405, 'POST only') }let p = projectByAnySlug(route.params.slug)let u = apiUser(req)if (u == null) { return unauthorized() }let rb = readBody(req, ['id'], [])if (rb.bad != null) { return refuse(rb.bad) }if (p == null) { return fail(404, 'no such project') }let r = disconnect(p.id, u, rb.body.id)if (r.error != null) { return denied(r) }emit client connectionsChanged(p.slug)return reply(200, r)}// OLD PAGE URLS (`/tickets/<old global number>`, also `/tickets/<uuid>`) move for good// to `/projects/<slug>/<number>`oldTicketPage = (route, req) => {let t = ticketByRef(route.params.ref)if (t == null) { return new Response('no such ticket', { status = 404 headers = { 'Content-Type' = 'text/plain; charset=utf-8' } }) }let to = ticketHref(projectSlugOf(t), t.number)return new Response('moved to ' + to, { status = 301 headers = { 'Location' = to 'Content-Type' = 'text/plain; charset=utf-8' } })}// ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------// The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=// <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.// The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app// identity id; its tickets user (made at the first login) goes into THIS browser's hl:web// session (`req.session`, the cookie's — hybriel#11; minted here when the browser brought none), then → the// page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks// for a display name if there is none yet.// (The identity selector logs in through the shell's face `identLogin` instead — no reload.)htmlPage = (status, title, text) => {let body = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>tickets | ' + title + '</title><style>body{margin:0;padding:1.5rem 1rem;font:16px/1.5 system-ui,sans-serif;color:rgb(195, 200, 205);background:rgb(25, 30, 35)}main{max-width:34rem;margin:0 auto;display:grid;gap:1rem}h1{margin:0;font-size:1.3rem;color:rgb(245, 250, 255)}p{margin:0}a{color:#c586c0}.error{color:#f44747}</style></head><body><main><h1>' + title + '</h1><p id="error" class="error">' + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + '</p><p><a id="home" href="/">back to the tickets</a></p></main></body></html>'return new Response(body, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })}// BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the// button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no// backslash — `/\host` is another host to some browsers), only URL-safe characters (no// scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.// Anything else → `/`.nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'safeNext = (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}loginCallback = (route, req) => {if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }let q = req.query != null ? req.query : {}let code = q.ident_codeif (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }let x = exchangeCode(code)if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }let u = ensureUser(x.identity)if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }// an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the code; only when ident says this// identity accepted it does the person join the project, with the invite's rolelet joined = nullif (q.invite != null && q.invite != '') {let a = acceptedInvite(q.invite, x.identity)if (a.error != null) { return htmlPage(400, 'Invite failed', a.error) }let jp = projectByRef(a.project)if (jp == null || !roles.includes(a.role)) { return htmlPage(400, 'Invite failed', 'the invite is for a project or role that does not exist here') }addMember(jp.id, u.id, a.role)joined = jp}let s = req.sessionlet fresh = s == nullif (fresh) { s = server.sessions.mint() }s.user = { id = u.id }s.data.tag = randomBytes(16)server.sessions.save(s)let to = joined != null ? '/projects/' + joined.slug : safeNext(q.next)let res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }return res}routes = [{ pattern = "/favicon.ico" direct = "" }{ pattern = "/login/callback" function = loginCallback }{ pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }// ticket #12 (mission 024): the Markdown editor <md-editor>, vendored from worldapi-components{ pattern = "/md-editor.js" file = "./shared/md-editor.js" headers = { 'Cache-Control' = 'no-cache' } }{ pattern = "/api/tickets" function = postTicket }{ pattern = "/api/tickets/:ref" function = getTicket }{ pattern = "/api/tickets/:ref/comments" function = postComment }{ pattern = "/api/tickets/:ref/state" function = postState }{ pattern = "/api/tickets/:ref/edit" function = postEdit }{ pattern = "/api/tickets/:ref/assign" function = postAssign }{ pattern = "/api/tickets/:ref/parent" function = postParent }{ pattern = "/api/tickets/:ref/blocked-by" function = postBlockers }{ pattern = "/api/projects" function = getProjects }{ pattern = "/api/inbox" function = getInbox }{ pattern = "/api/projects/:slug" function = getProject }{ pattern = "/api/projects/:slug/members" function = postMembers }{ pattern = "/api/projects/:slug/members/remove" function = postMemberRemove }{ pattern = "/api/projects/:slug/invites" function = postInvites }{ pattern = "/api/connect/exchange" function = connectExchange }{ pattern = "/api/projects/:slug/connections" function = projectConnections }{ pattern = "/api/projects/:slug/connections/remove" function = projectDisconnect }{ pattern = "/connect" function = connectStart }{ pattern = "/api/projects/:slug/tickets" function = projectTickets }{ pattern = "/api/projects/:slug/tickets/:number" function = getProjectTicket }{ pattern = "/api/projects/:slug/tickets/:number/comments" function = postProjectComment }{ pattern = "/api/projects/:slug/tickets/:number/state" function = postProjectState }{ pattern = "/api/projects/:slug/tickets/:number/edit" function = postProjectEdit }{ pattern = "/api/projects/:slug/tickets/:number/assign" function = postProjectAssign }{ pattern = "/api/projects/:slug/tickets/:number/parent" function = postProjectParent }{ pattern = "/api/projects/:slug/tickets/:number/blocked-by" function = postProjectBlockers }{ pattern = "/" component = TicketList }{ pattern = "/project/:projectName" component = TicketList }{ pattern = "/projects/:projectName" component = TicketList }{ pattern = "/state/:stateSlug" component = TicketList }{ pattern = "/project/:projectName/state/:stateSlug" component = TicketList }{ pattern = "/inbox" component = Inbox }{ pattern = "/new-project" component = NewProject }{ pattern = "/connect/:nonce" component = Connect }{ pattern = "/projects/:projectSlug/settings" component = Settings }{ pattern = "/you" component = You }{ pattern = "/projects/:projectSlug/:ticketNumber" component = Ticket }{ pattern = "/tickets/:ref" function = oldTicketPage }]// WHO GETS THE PUSH: tickets and events are public (reading needs no login). A frame// reaches a connection only if a mounted component listens for it.// `signedIn` / `signedOut` (ticket #7) go to the tabs of ONE session: the one whose login// carries that random tag (session.data.tag, set at login) — every page of it switches to// logged in / out without a reload.tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }audience = {ticketCreated = (row, session) => { return true }ticketEvent = (ticketId, ev, row, session) => { return true }// ticket #4: the relation views (store.hl relationViewOf) of a ticket and its related onesticketsRelated = (views, session) => { return true }// ticket #21: an app was connected / disconnected — the project page refreshes its lineconnectionsChanged = (slug, session) => { return true }signedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }signedOut = (tag, session) => { return tag != null && tagOf(session) == tag }}sessionDir = env('TICKETS_SESSIONS') != null ? env('TICKETS_SESSIONS') : nullport = env('TICKETS_PORT') != null ? toNumber(env('TICKETS_PORT')) : 8350// THE LISTENER's interface (mission 010, deploy to Byrodin): hl:web reads HL_HOST (or HOST)// itself (hybriel#24): 127.0.0.1 on Byrodin behind nginx; unset = 0.0.0.0 (dev on Loreana).// TICKETS_WATCH=0 turns the dev watcher off (the container: a deploy is an rsync + restart,// half-copied .hl files must not be re-analysed); unset = on, as before.watching = env('TICKETS_WATCH') != '0'// THE MIGRATION of ticket #20 (migrate.hl): idempotent, runs at every startfor (line of migrateAll()) { console.log('migrated: ' + line) }server = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching)// THE GLOBAL ERROR HANDLER (ticket #7): a plugin's failure — above all hl:fetch's refused /// timed-out exchange with ident (users.hl exchangeCode) — arrives here instead of aborting the// request with a 500 that shows source paths; the failed call yields null and the caller answers// it ("ident did not answer"). Every absorbed error is logged.on Error(e) { console.log('error absorbed: ' + e.message) }
Branches
- mainmain branch