gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitf12fa1bcf12fa1bcState of 2026-09-27, before the move to gitoriamref12fa1bc/project.hl

31.0 KB

  1. // project.hl — tickets.worldapi.org: THE APP. Routes (pages + JSON API) and WHO HEARS WHAT.
  2. import WebFramework from 'hl:web'
  3. import { env } from 'hl:proc'
  4. import Styles from './styles.hl'
  5. 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'
  6. import { migrateAll } from './migrate.hl'
  7. import { createInvite, acceptedInvite } from './invites.hl'
  8. import { Response } from 'hl:http1'
  9. import { reply, fail, queryOf, readBody, refuse, unauthorized } from './api.hl'
  10. import { userOfBearer, exchangeCode, ensureUser, userRecord } from './users.hl'
  11. import { createRequest, connectionOfKey, personOf, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'
  12. import { randomBytes } from 'hl:crypto'
  13. import { wantsMarkdown, markdownReply, listDocument, ticketDocument } from './mdview.hl'
  14. import TicketList from './components/ticket_list.hl'
  15. import Ticket from './components/ticket.hl'
  16. import Inbox from './components/inbox.hl'
  17. import You from './components/you.hl'
  18. import NewProject from './components/new_project.hl'
  19. import Settings from './components/settings.hl'
  20. import Connect from './components/connect.hl'
  21. static siteName = "tickets"
  22. appTitle = siteName
  23. styles = Styles
  24. // ---- the JSON API (for the scheduler and a CLI) ----------------------------------------
  25. // Every write pushes the same frames the web faces push, so an open browser follows an
  26. // API write live. See README.md "API" for the shapes. Every POST body goes through
  27. // readBody (api.hl): invalid JSON, unknown field, missing/empty required field, non-string
  28. // value → 400.
  29. // WRITES NEED A TOKEN (ticket #7): `Authorization: Bearer <token>` (users.hl; a logged-in user
  30. // makes tokens on /you). No / bad / revoked token → 401, checked BEFORE the body. The author is
  31. // the token's user; a body with `author` → 400 naming it. Reads stay public.
  32. //
  33. // THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,
  34. // the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise,
  35. // unchanged.
  36. // EDITING (ticket #8): POST …/edit { subject?, summary? } — a member with the role edit or admin
  37. // of the ticket's project → else 403; the history keeps the previous values.
  38. //
  39. // TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global
  40. // number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the
  41. // per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.
  42. byRef = (route) => { return ticketByRef(route.params.ref) }
  43. byNumber = (route) => { return ticketAt(route.params.slug, route.params.number) }
  44. stateFilter = (q) => {
  45. if (q.state == null || q.state == '') { return { state = null } }
  46. let st = stateOf(q.state)
  47. if (st == null) { return { bad = fail(400, 'unknown state — one of: ' + states.join(', ')) } }
  48. return { state = st }
  49. }
  50. listTickets = (route, req) => {
  51. if (req.method != 'GET') { return fail(405, 'GET only') }
  52. // hl:http1 hands the parsed query string as `req.query`; the path carries none
  53. let q = req.query != null ? req.query : queryOf(req.path)
  54. let sf = stateFilter(q)
  55. if (sf.bad != null) { return sf.bad }
  56. let pr = q.project != null && q.project != '' ? q.project : null
  57. let rows = ticketRows(pr, sf.state)
  58. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }
  59. return { tickets = rows }
  60. }
  61. // the filters of a list, for the Markdown heading ('' = none)
  62. filterLabel = (project, state) => {
  63. let parts = []
  64. if (project != null) { parts.push('project ' + project) }
  65. if (state != null) { parts.push('state ' + state) }
  66. return parts.join(', ')
  67. }
  68. // the token's user of an API write, or null (→ 401)
  69. apiUser = (req) => { return userOfBearer(req.headers['authorization']) }
  70. // WHO WRITES, for the endpoints an app's KEY may also use (ticket #21: create a ticket, comment, change the state):
  71. // { user } for a user's token, { conn } for a project key (connections.hl), null = 401 (checked before the body)
  72. apiAuth = (req) => {
  73. let h = req.headers['authorization']
  74. let u = userOfBearer(h)
  75. if (u != null) { return { user = u } }
  76. let c = connectionOfKey(h)
  77. return c != null ? { conn = c } : null
  78. }
  79. // the acting user inside project `projectId`: { user } or { response }. A key reaches ITS project only and names the
  80. // person by `X-Tickets-Identity`; the project's roles then decide as for any user.
  81. actorIn = (auth, req, projectId) => {
  82. if (auth.user != null) { return { user = auth.user } }
  83. if (projectId == null || auth.conn.project != projectId) { return { response = reply(403, { error = 'this key belongs to another project' field = '' }) } }
  84. let a = personOf(req.headers['x-tickets-identity'])
  85. if (a.error != null) { return { response = reply(403, { error = a.error field = '' }) } }
  86. return { user = a.user }
  87. }
  88. // POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }
  89. createFromApi = (project, b, auth, req) => {
  90. let pr = projectByRef(project)
  91. let w = actorIn(auth, req, pr != null ? pr.id : null)
  92. if (w.response != null) { return w.response }
  93. let user = w.user
  94. let r = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)
  95. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  96. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  97. if (!r.existed) { emit client ticketCreated(r.ticket) }
  98. return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })
  99. }
  100. postTicket = (route, req) => {
  101. if (req.method == 'GET') { return listTickets(route, req) }
  102. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  103. let u = apiAuth(req)
  104. if (u == null) { return unauthorized() }
  105. let rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])
  106. if (rb.bad != null) { return refuse(rb.bad) }
  107. return createFromApi(rb.body.project, rb.body, u, req)
  108. }
  109. projectTickets = (route, req) => {
  110. let slug = route.params.slug
  111. if (req.method == 'GET') {
  112. if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }
  113. let q = req.query != null ? req.query : queryOf(req.path)
  114. let sf = stateFilter(q)
  115. if (sf.bad != null) { return sf.bad }
  116. let rows = ticketRows(slug, sf.state)
  117. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }
  118. return { tickets = rows }
  119. }
  120. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  121. let u = apiAuth(req)
  122. if (u == null) { return unauthorized() }
  123. let rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])
  124. if (rb.bad != null) { return refuse(rb.bad) }
  125. return createFromApi(slug, rb.body, u, req)
  126. }
  127. getOne = (t, req) => {
  128. if (req.method != 'GET') { return fail(405, 'GET only') }
  129. if (t == null) { return fail(404, 'no such ticket') }
  130. let data = ticketWithEvents(t)
  131. if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }
  132. return data
  133. }
  134. commentOnTicket = (t, req) => {
  135. if (req.method != 'POST') { return fail(405, 'POST only') }
  136. let auth = apiAuth(req)
  137. if (auth == null) { return unauthorized() }
  138. let rb = readBody(req, ['text'], [])
  139. if (rb.bad != null) { return refuse(rb.bad) }
  140. if (t == null) { return fail(404, 'no such ticket') }
  141. let w = actorIn(auth, req, t.project)
  142. if (w.response != null) { return w.response }
  143. let u = w.user
  144. let b = rb.body
  145. let r = addComment(t.id, u, b.text)
  146. if (r.error == 'no such ticket') { return fail(404, r.error) }
  147. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  148. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  149. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  150. return reply(201, r)
  151. }
  152. stateOfTicket = (t, req) => {
  153. if (req.method != 'POST') { return fail(405, 'POST only') }
  154. let auth = apiAuth(req)
  155. if (auth == null) { return unauthorized() }
  156. let rb = readBody(req, ['state'], ['text'])
  157. if (rb.bad != null) { return refuse(rb.bad) }
  158. if (t == null) { return fail(404, 'no such ticket') }
  159. let w = actorIn(auth, req, t.project)
  160. if (w.response != null) { return w.response }
  161. let u = w.user
  162. let b = rb.body
  163. let r = changeState(t.id, b.state, u, b.text)
  164. if (r.error == 'no such ticket') { return fail(404, r.error) }
  165. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  166. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  167. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  168. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  169. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  170. return reply(201, r)
  171. }
  172. // POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.
  173. // Needs the role edit or admin → else 403.
  174. assignOfTicket = (t, req) => {
  175. if (req.method != 'POST') { return fail(405, 'POST only') }
  176. let u = apiUser(req)
  177. if (u == null) { return unauthorized() }
  178. let rb = readBody(req, [], ['assignee'])
  179. if (rb.bad != null) { return refuse(rb.bad) }
  180. if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }
  181. if (t == null) { return fail(404, 'no such ticket') }
  182. let who = ''
  183. if (rb.body.assignee.trim() != '') {
  184. let f = userByRef(rb.body.assignee, t.project)
  185. if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }
  186. who = f.user.id
  187. }
  188. let r = assignTicket(t.id, u, who)
  189. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  190. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  191. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  192. return reply(201, r)
  193. }
  194. // POST …/edit { subject?, summary? } (ticket #8): the token's user must be the ticket's author
  195. editOfTicket = (t, req) => {
  196. if (req.method != 'POST') { return fail(405, 'POST only') }
  197. let u = apiUser(req)
  198. if (u == null) { return unauthorized() }
  199. let rb = readBody(req, [], ['subject' 'summary'])
  200. if (rb.bad != null) { return refuse(rb.bad) }
  201. if (t == null) { return fail(404, 'no such ticket') }
  202. let b = rb.body
  203. let r = editTicket(t.id, u, b.subject, b.summary)
  204. if (r.error == 'no such ticket') { return fail(404, r.error) }
  205. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  206. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  207. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  208. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  209. return reply(201, r)
  210. }
  211. // RELATIONS (ticket #4, mission 017; store.hl "relations"): a ticket is named as
  212. // `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.
  213. // POST …/parent { parent }: set the parent; `"parent": ""` removes it.
  214. // POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.
  215. // 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.
  216. linkReply = (r) => {
  217. if (r.error == 'no such ticket') { return fail(404, r.error) }
  218. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  219. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  220. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  221. emit client ticketsRelated(r.related)
  222. return reply(201, { ticket = r.ticket event = r.event })
  223. }
  224. parentOfTicket = (t, req) => {
  225. if (req.method != 'POST') { return fail(405, 'POST only') }
  226. let u = apiUser(req)
  227. if (u == null) { return unauthorized() }
  228. let rb = readBody(req, [], ['parent'])
  229. if (rb.bad != null) { return refuse(rb.bad) }
  230. if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }
  231. if (t == null) { return fail(404, 'no such ticket') }
  232. return linkReply(setParent(t.id, u, rb.body.parent))
  233. }
  234. blockersOfTicket = (t, req) => {
  235. if (req.method != 'POST') { return fail(405, 'POST only') }
  236. let u = apiUser(req)
  237. if (u == null) { return unauthorized() }
  238. let rb = readBody(req, [], ['add' 'remove'])
  239. if (rb.bad != null) { return refuse(rb.bad) }
  240. let b = rb.body
  241. if ((b.add == null) == (b.remove == null)) { return refuse({ error = "give exactly one of 'add' or 'remove' (<project>#<number>)" field = b.add == null ? 'add' : 'remove' }) }
  242. if (t == null) { return fail(404, 'no such ticket') }
  243. let adding = b.add != null
  244. return linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))
  245. }
  246. getTicket = (route, req) => { return getOne(byRef(route), req) }
  247. postComment = (route, req) => { return commentOnTicket(byRef(route), req) }
  248. postState = (route, req) => { return stateOfTicket(byRef(route), req) }
  249. getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }
  250. postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }
  251. postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }
  252. postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }
  253. postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }
  254. postEdit = (route, req) => { return editOfTicket(byRef(route), req) }
  255. postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }
  256. postParent = (route, req) => { return parentOfTicket(byRef(route), req) }
  257. postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }
  258. postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }
  259. postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }
  260. // ---- PROJECTS AND MEMBERS (ticket #20; store.hl) ---------------------------------------------------
  261. // GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }
  262. // POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens
  263. // a project and is its first admin; the slug is generated from the title unless given
  264. // GET /api/projects/:slug → { project, members } (the slug may be an old one)
  265. // POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working
  266. // POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone
  267. // who logged in here; sets (adds / changes) the role: use | edit | admin
  268. // POST /api/projects/:slug/members/remove { user } — admin only
  269. // POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }
  270. // GET /api/inbox — the token's user: { pending, review }, the tickets assigned to them
  271. getProjects = (route, req) => {
  272. if (req.method == 'POST') { return postNewProject(req) }
  273. if (req.method != 'GET') { return fail(405, 'GET or POST') }
  274. let details = []
  275. for (p of projectRecords()) { details.push(projectRowOf(p)) }
  276. return { projects = projectNames() details = details states = states roles = roles }
  277. }
  278. // a refusal of the store: 403 when the role is missing, else 400
  279. denied = (r) => {
  280. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  281. return refuse({ error = r.error field = r.field })
  282. }
  283. postNewProject = (req) => {
  284. let u = apiUser(req)
  285. if (u == null) { return unauthorized() }
  286. let rb = readBody(req, ['title'], ['slug' 'description'])
  287. if (rb.bad != null) { return refuse(rb.bad) }
  288. let r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)
  289. if (r.error != null) { return denied(r) }
  290. return reply(201, r)
  291. }
  292. getProject = (route, req) => {
  293. let p = projectByAnySlug(route.params.slug)
  294. if (req.method == 'GET') {
  295. if (p == null) { return fail(404, 'no such project') }
  296. return { project = projectRowOf(p) members = memberRows(p.id) }
  297. }
  298. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  299. let u = apiUser(req)
  300. if (u == null) { return unauthorized() }
  301. let rb = readBody(req, [], ['title' 'slug' 'description'])
  302. if (rb.bad != null) { return refuse(rb.bad) }
  303. if (p == null) { return fail(404, 'no such project') }
  304. let r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)
  305. if (r.error != null) { return denied(r) }
  306. return reply(200, r)
  307. }
  308. postMembers = (route, req) => {
  309. if (req.method != 'POST') { return fail(405, 'POST only') }
  310. let p = projectByAnySlug(route.params.slug)
  311. let u = apiUser(req)
  312. if (u == null) { return unauthorized() }
  313. let rb = readBody(req, ['user' 'role'], [])
  314. if (rb.bad != null) { return refuse(rb.bad) }
  315. if (p == null) { return fail(404, 'no such project') }
  316. let f = userByRef(rb.body.user, null)
  317. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  318. let r = setMemberRole(p.id, u, f.user.id, rb.body.role)
  319. if (r.error != null) { return denied(r) }
  320. return reply(200, r)
  321. }
  322. postMemberRemove = (route, req) => {
  323. if (req.method != 'POST') { return fail(405, 'POST only') }
  324. let p = projectByAnySlug(route.params.slug)
  325. let u = apiUser(req)
  326. if (u == null) { return unauthorized() }
  327. let rb = readBody(req, ['user'], [])
  328. if (rb.bad != null) { return refuse(rb.bad) }
  329. if (p == null) { return fail(404, 'no such project') }
  330. let f = userByRef(rb.body.user, p.id)
  331. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  332. let r = removeMember(p.id, u, f.user.id)
  333. if (r.error != null) { return denied(r) }
  334. return reply(200, r)
  335. }
  336. // an invite is only for an admin; ident makes the link (invites.hl)
  337. makeInvite = (p, u, role, uses, days, email) => {
  338. if (!isAdminOf(p.id, u)) { return { error = 'only an admin of the project can invite' field = '' forbidden = true } }
  339. if (!roles.includes(role)) { return { error = 'role must be one of: ' + roles.join(', ') field = 'role' } }
  340. let n = uses == null ? 1 : toNumber(uses)
  341. let d = days == null ? 7 : toNumber(days)
  342. if (n == null || n < 1 || n > 1000) { return { error = "'uses' must be a number from 1 to 1000" field = 'uses' } }
  343. if (d == null || d < 1 || d > 90) { return { error = "'days' must be a number from 1 to 90" field = 'days' } }
  344. return createInvite(p.id, role, n, d, email)
  345. }
  346. postInvites = (route, req) => {
  347. if (req.method != 'POST') { return fail(405, 'POST only') }
  348. let p = projectByAnySlug(route.params.slug)
  349. let u = apiUser(req)
  350. if (u == null) { return unauthorized() }
  351. let rb = readBody(req, ['role'], ['uses' 'days' 'email'])
  352. if (rb.bad != null) { return refuse(rb.bad) }
  353. if (p == null) { return fail(404, 'no such project') }
  354. let b = rb.body
  355. let r = makeInvite(p, u, b.role, b.uses, b.days, b.email)
  356. if (r.error != null) { return denied(r) }
  357. return reply(201, r)
  358. }
  359. getInbox = (route, req) => {
  360. if (req.method != 'GET') { return fail(405, 'GET only') }
  361. let u = apiUser(req)
  362. if (u == null) { return unauthorized() }
  363. return inboxRows(u)
  364. }
  365. // ---- CONNECTING AN APP (ticket #21; connections.hl) --------------------------------------------------
  366. // GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to
  367. // the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.
  368. // POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:
  369. // 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.
  370. // GET /api/projects/:slug/connections — who is connected (public, like the project page)
  371. // POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at once
  372. connectStart = (route, req) => {
  373. if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }
  374. let q = req.query != null ? req.query : {}
  375. let r = createRequest(q.app, q.label, q['return'], q.state)
  376. if (r.error != null) { return htmlPage(400, 'Connect', r.error) }
  377. let to = '/connect/' + r.nonce
  378. return new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  379. }
  380. connectExchange = (route, req) => {
  381. if (req.method != 'POST') { return fail(405, 'POST only') }
  382. let rb = readBody(req, ['code'], [])
  383. if (rb.bad != null) { return refuse(rb.bad) }
  384. let r = exchangeConnectCode(rb.body.code)
  385. if (r.error != null) { return refuse({ error = r.error field = 'code' }) }
  386. emit client connectionsChanged(r.project)
  387. return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })
  388. }
  389. projectConnections = (route, req) => {
  390. if (req.method != 'GET') { return fail(405, 'GET only') }
  391. let p = projectByAnySlug(route.params.slug)
  392. if (p == null) { return fail(404, 'no such project') }
  393. return { connections = connectionRows(p.id) }
  394. }
  395. projectDisconnect = (route, req) => {
  396. if (req.method != 'POST') { return fail(405, 'POST only') }
  397. let p = projectByAnySlug(route.params.slug)
  398. let u = apiUser(req)
  399. if (u == null) { return unauthorized() }
  400. let rb = readBody(req, ['id'], [])
  401. if (rb.bad != null) { return refuse(rb.bad) }
  402. if (p == null) { return fail(404, 'no such project') }
  403. let r = disconnect(p.id, u, rb.body.id)
  404. if (r.error != null) { return denied(r) }
  405. emit client connectionsChanged(p.slug)
  406. return reply(200, r)
  407. }
  408. // OLD PAGE URLS (`/tickets/<old global number>`, also `/tickets/<uuid>`) move for good
  409. // to `/projects/<slug>/<number>`
  410. oldTicketPage = (route, req) => {
  411. let t = ticketByRef(route.params.ref)
  412. if (t == null) { return new Response('no such ticket', { status = 404 headers = { 'Content-Type' = 'text/plain; charset=utf-8' } }) }
  413. let to = ticketHref(projectSlugOf(t), t.number)
  414. return new Response('moved to ' + to, { status = 301 headers = { 'Location' = to 'Content-Type' = 'text/plain; charset=utf-8' } })
  415. }
  416. // ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------
  417. // The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=
  418. // <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.
  419. // The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app
  420. // identity id; its tickets user (made at the first login) goes into THIS browser's hl:web
  421. // session (`req.session`, the cookie's — hybriel#11; minted here when the browser brought none), then → the
  422. // page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks
  423. // for a display name if there is none yet.
  424. // (The identity selector logs in through the shell's face `identLogin` instead — no reload.)
  425. htmlPage = (status, title, text) => {
  426. 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;') + '</p><p><a id="home" href="/">back to the tickets</a></p></main></body></html>'
  427. return new Response(body, { status = status headers = { 'Content-Type' = 'text/html; charset=utf-8' 'Cache-Control' = 'no-store' } })
  428. }
  429. // BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the
  430. // button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no
  431. // backslash — `/\host` is another host to some browsers), only URL-safe characters (no
  432. // scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.
  433. // Anything else → `/`.
  434. nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  435. safeNext = (want) => {
  436. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  437. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  438. let i = 0
  439. while (i < want.length) {
  440. if (!nextChars.includes(want[i])) { return '/' }
  441. i = i + 1
  442. }
  443. return want
  444. }
  445. loginCallback = (route, req) => {
  446. if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }
  447. let q = req.query != null ? req.query : {}
  448. let code = q.ident_code
  449. if (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }
  450. let x = exchangeCode(code)
  451. if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }
  452. let u = ensureUser(x.identity)
  453. if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }
  454. // an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the code; only when ident says this
  455. // identity accepted it does the person join the project, with the invite's role
  456. let joined = null
  457. if (q.invite != null && q.invite != '') {
  458. let a = acceptedInvite(q.invite, x.identity)
  459. if (a.error != null) { return htmlPage(400, 'Invite failed', a.error) }
  460. let jp = projectByRef(a.project)
  461. 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') }
  462. addMember(jp.id, u.id, a.role)
  463. joined = jp
  464. }
  465. let s = req.session
  466. let fresh = s == null
  467. if (fresh) { s = server.sessions.mint() }
  468. s.user = { id = u.id }
  469. s.data.tag = randomBytes(16)
  470. server.sessions.save(s)
  471. let to = joined != null ? '/projects/' + joined.slug : safeNext(q.next)
  472. let res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  473. if (fresh) { res.headers['Set-Cookie'] = server.sessions.cookieHeader(s.id) }
  474. return res
  475. }
  476. routes = [
  477. { pattern = "/favicon.ico" direct = "" }
  478. { pattern = "/login/callback" function = loginCallback }
  479. { pattern = "/login.js" file = "./login.js" headers = { 'Cache-Control' = 'no-cache' } }
  480. // ticket #12 (mission 024): the Markdown editor <md-editor>, vendored from worldapi-components
  481. { pattern = "/md-editor.js" file = "./shared/md-editor.js" headers = { 'Cache-Control' = 'no-cache' } }
  482. { pattern = "/api/tickets" function = postTicket }
  483. { pattern = "/api/tickets/:ref" function = getTicket }
  484. { pattern = "/api/tickets/:ref/comments" function = postComment }
  485. { pattern = "/api/tickets/:ref/state" function = postState }
  486. { pattern = "/api/tickets/:ref/edit" function = postEdit }
  487. { pattern = "/api/tickets/:ref/assign" function = postAssign }
  488. { pattern = "/api/tickets/:ref/parent" function = postParent }
  489. { pattern = "/api/tickets/:ref/blocked-by" function = postBlockers }
  490. { pattern = "/api/projects" function = getProjects }
  491. { pattern = "/api/inbox" function = getInbox }
  492. { pattern = "/api/projects/:slug" function = getProject }
  493. { pattern = "/api/projects/:slug/members" function = postMembers }
  494. { pattern = "/api/projects/:slug/members/remove" function = postMemberRemove }
  495. { pattern = "/api/projects/:slug/invites" function = postInvites }
  496. { pattern = "/api/connect/exchange" function = connectExchange }
  497. { pattern = "/api/projects/:slug/connections" function = projectConnections }
  498. { pattern = "/api/projects/:slug/connections/remove" function = projectDisconnect }
  499. { pattern = "/connect" function = connectStart }
  500. { pattern = "/api/projects/:slug/tickets" function = projectTickets }
  501. { pattern = "/api/projects/:slug/tickets/:number" function = getProjectTicket }
  502. { pattern = "/api/projects/:slug/tickets/:number/comments" function = postProjectComment }
  503. { pattern = "/api/projects/:slug/tickets/:number/state" function = postProjectState }
  504. { pattern = "/api/projects/:slug/tickets/:number/edit" function = postProjectEdit }
  505. { pattern = "/api/projects/:slug/tickets/:number/assign" function = postProjectAssign }
  506. { pattern = "/api/projects/:slug/tickets/:number/parent" function = postProjectParent }
  507. { pattern = "/api/projects/:slug/tickets/:number/blocked-by" function = postProjectBlockers }
  508. { pattern = "/" component = TicketList }
  509. { pattern = "/project/:projectName" component = TicketList }
  510. { pattern = "/projects/:projectName" component = TicketList }
  511. { pattern = "/state/:stateSlug" component = TicketList }
  512. { pattern = "/project/:projectName/state/:stateSlug" component = TicketList }
  513. { pattern = "/inbox" component = Inbox }
  514. { pattern = "/new-project" component = NewProject }
  515. { pattern = "/connect/:nonce" component = Connect }
  516. { pattern = "/projects/:projectSlug/settings" component = Settings }
  517. { pattern = "/you" component = You }
  518. { pattern = "/projects/:projectSlug/:ticketNumber" component = Ticket }
  519. { pattern = "/tickets/:ref" function = oldTicketPage }
  520. ]
  521. // WHO GETS THE PUSH: tickets and events are public (reading needs no login). A frame
  522. // reaches a connection only if a mounted component listens for it.
  523. // `signedIn` / `signedOut` (ticket #7) go to the tabs of ONE session: the one whose login
  524. // carries that random tag (session.data.tag, set at login) — every page of it switches to
  525. // logged in / out without a reload.
  526. tagOf = (session) => { return session != null && session.data != null ? session.data.tag : null }
  527. audience = {
  528. ticketCreated = (row, session) => { return true }
  529. ticketEvent = (ticketId, ev, row, session) => { return true }
  530. // ticket #4: the relation views (store.hl relationViewOf) of a ticket and its related ones
  531. ticketsRelated = (views, session) => { return true }
  532. // ticket #21: an app was connected / disconnected — the project page refreshes its line
  533. connectionsChanged = (slug, session) => { return true }
  534. signedIn = (tag, info, session) => { return tag != null && tagOf(session) == tag }
  535. signedOut = (tag, session) => { return tag != null && tagOf(session) == tag }
  536. }
  537. sessionDir = env('TICKETS_SESSIONS') != null ? env('TICKETS_SESSIONS') : null
  538. port = env('TICKETS_PORT') != null ? toNumber(env('TICKETS_PORT')) : 8350
  539. // THE LISTENER's interface (mission 010, deploy to Byrodin): hl:web reads HL_HOST (or HOST)
  540. // itself (hybriel#24): 127.0.0.1 on Byrodin behind nginx; unset = 0.0.0.0 (dev on Loreana).
  541. // TICKETS_WATCH=0 turns the dev watcher off (the container: a deploy is an rsync + restart,
  542. // half-copied .hl files must not be re-analysed); unset = on, as before.
  543. watching = env('TICKETS_WATCH') != '0'
  544. // THE MIGRATION of ticket #20 (migrate.hl): idempotent, runs at every start
  545. for (line of migrateAll()) { console.log('migrated: ' + line) }
  546. server = new WebFramework(routes = routes, styles = styles, minify = true, port = port, watchMode = watching)
  547. // THE GLOBAL ERROR HANDLER (ticket #7): a plugin's failure — above all hl:fetch's refused /
  548. // timed-out exchange with ident (users.hl exchangeCode) — arrives here instead of aborting the
  549. // request with a 500 that shows source paths; the failed call yields null and the caller answers
  550. // it ("ident did not answer"). Every absorbed error is logged.
  551. on Error(e) { console.log('error absorbed: ' + e.message) }

Branches

Latest commits

  • f12fa1bcState of 2026-09-27, before the move to gitoriamre