gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commite9d5c618e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmree9d5c618/lib/api.hl

22.9 KB

  1. // lib/api.hl — THE FUNCTION ROUTES (project.hl `routes`): the JSON API for the scheduler, a CLI and connected apps,
  2. // and the three page routes that are not components (/login/callback, /connect, /tickets/:ref). Thin wrappers:
  3. // check the method, the token / key and the body (lib/api-helpers.hl), call the topic function, push what
  4. // changed (the same frames the web faces push, so an open browser follows an API write live), answer.
  5. // See README.md "API" for the shapes.
  6. //
  7. // Every POST body goes through readBody: invalid JSON, unknown field, missing/empty required field, non-string
  8. // value → 400. WRITES NEED A TOKEN (ticket #7): no / bad / revoked token → 401, checked BEFORE the body. The
  9. // author is the token's user; a body with `author` → 400 naming it. Reads stay public.
  10. // THE MARKDOWN READ VIEW (ticket #6): `Accept: text/markdown` on the ticket GETs (the two lists,
  11. // the two single-ticket forms) answers a compact Markdown document (mdview.hl); JSON otherwise, unchanged.
  12. // TWO WAYS TO NAME A TICKET (ticket #38): `/api/tickets/:ref` — :ref is the OLD global
  13. // number of a migrated ticket (docs say "#38") or the ticket's UUID (`id`) — and the
  14. // per-project form `/api/projects/:slug/tickets/:number`. Both answer the same shapes.
  15. import { Response } from 'hl:http1'
  16. import { randomBytes } from 'hl:crypto'
  17. import { reply, fail, queryOf, readBody, refuse, unauthorized, denied, apiUser, apiAuth, actorIn, stateFilter, filterLabel, wantsMarkdown, markdownReply, htmlPage } from './api-helpers.hl'
  18. import { exchangeCode, ensureUser } from './users.hl'
  19. import { projectNames, projectByAnySlug, projectByRef, projectRecords, projectRowOf, projectSlugOf, createProject, updateProject, memberRows, setMemberRole, userByRef, roles } from './projects.hl'
  20. import { states } from './tickets-helpers.hl'
  21. import { pageEventOf } from './events.hl'
  22. import { ticketRows, ticketWithEvents, ticketByRef, ticketAt, createTicket, addComment, changeState, assignTicket, editTicket, setParent, changeBlocker, relatedViews, removeMember, inboxRows } from './tickets.hl'
  23. import { mayInvite, createInvite, joinByInvite } from './invites.hl'
  24. import { createRequest, exchangeConnectCode, connectionRows, disconnect } from './connections.hl'
  25. import { listDocument, ticketDocument } from './mdview.hl'
  26. import { ticketHref } from './util.hl'
  27. // ---- tickets ------------------------------------------------------------------------------------
  28. static byRef = (route) => { return ticketByRef(route.params.ref) }
  29. static byNumber = (route) => { return ticketAt(route.params.slug, route.params.number) }
  30. static listTickets = (route, req) => {
  31. if (req.method != 'GET') { return fail(405, 'GET only') }
  32. // hl:http1 hands the parsed query string as `req.query`; the path carries none
  33. let q = req.query != null ? req.query : queryOf(req.path)
  34. let sf = stateFilter(q)
  35. if (sf.bad != null) { return sf.bad }
  36. let pr = q.project != null && q.project != '' ? q.project : null
  37. let rows = ticketRows(pr, sf.state)
  38. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(pr, sf.state))) }
  39. return { tickets = rows }
  40. }
  41. // POST /api/tickets { project, … } and POST /api/projects/:slug/tickets { … }
  42. static createFromApi = (project, b, auth, req) => {
  43. let pr = projectByRef(project)
  44. let w = actorIn(auth, req, pr != null ? pr.id : null)
  45. if (w.response != null) { return w.response }
  46. let user = w.user
  47. let r = createTicket(project, b.subject, b.summary, user, b.source, b.assignee)
  48. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  49. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  50. if (!r.existed) { emit client ticketCreated(r.ticket) }
  51. return reply(r.existed ? 200 : 201, { ticket = r.ticket existed = r.existed })
  52. }
  53. static postTicket = (route, req) => {
  54. if (req.method == 'GET') { return listTickets(route, req) }
  55. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  56. let u = apiAuth(req)
  57. if (u == null) { return unauthorized() }
  58. let rb = readBody(req, ['project' 'subject'], ['summary' 'source' 'assignee'])
  59. if (rb.bad != null) { return refuse(rb.bad) }
  60. return createFromApi(rb.body.project, rb.body, u, req)
  61. }
  62. static projectTickets = (route, req) => {
  63. let slug = route.params.slug
  64. if (req.method == 'GET') {
  65. if (projectByAnySlug(slug) == null) { return fail(404, 'no such project') }
  66. let q = req.query != null ? req.query : queryOf(req.path)
  67. let sf = stateFilter(q)
  68. if (sf.bad != null) { return sf.bad }
  69. let rows = ticketRows(slug, sf.state)
  70. if (wantsMarkdown(req)) { return markdownReply(listDocument(rows, filterLabel(slug, sf.state))) }
  71. return { tickets = rows }
  72. }
  73. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  74. let u = apiAuth(req)
  75. if (u == null) { return unauthorized() }
  76. let rb = readBody(req, ['subject'], ['summary' 'source' 'assignee'])
  77. if (rb.bad != null) { return refuse(rb.bad) }
  78. return createFromApi(slug, rb.body, u, req)
  79. }
  80. static getOne = (t, req) => {
  81. if (req.method != 'GET') { return fail(405, 'GET only') }
  82. if (t == null) { return fail(404, 'no such ticket') }
  83. let data = ticketWithEvents(t)
  84. if (wantsMarkdown(req)) { return markdownReply(ticketDocument(data)) }
  85. return data
  86. }
  87. static commentOnTicket = (t, req) => {
  88. if (req.method != 'POST') { return fail(405, 'POST only') }
  89. let auth = apiAuth(req)
  90. if (auth == null) { return unauthorized() }
  91. let rb = readBody(req, ['text'], [])
  92. if (rb.bad != null) { return refuse(rb.bad) }
  93. if (t == null) { return fail(404, 'no such ticket') }
  94. let w = actorIn(auth, req, t.project)
  95. if (w.response != null) { return w.response }
  96. let u = w.user
  97. let b = rb.body
  98. let r = addComment(t.id, u, b.text)
  99. if (r.error == 'no such ticket') { return fail(404, r.error) }
  100. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  101. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  102. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  103. return reply(201, r)
  104. }
  105. static stateOfTicket = (t, req) => {
  106. if (req.method != 'POST') { return fail(405, 'POST only') }
  107. let auth = apiAuth(req)
  108. if (auth == null) { return unauthorized() }
  109. let rb = readBody(req, ['state'], ['text'])
  110. if (rb.bad != null) { return refuse(rb.bad) }
  111. if (t == null) { return fail(404, 'no such ticket') }
  112. let w = actorIn(auth, req, t.project)
  113. if (w.response != null) { return w.response }
  114. let u = w.user
  115. let b = rb.body
  116. let r = changeState(t.id, b.state, u, b.text)
  117. if (r.error == 'no such ticket') { return fail(404, r.error) }
  118. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  119. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  120. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  121. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  122. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  123. return reply(201, r)
  124. }
  125. // POST …/assign { assignee } (ticket #20): a member's display name (or users id, or ident id); '' = nobody.
  126. // Needs the role edit or admin → else 403.
  127. static assignOfTicket = (t, req) => {
  128. if (req.method != 'POST') { return fail(405, 'POST only') }
  129. let u = apiUser(req)
  130. if (u == null) { return unauthorized() }
  131. let rb = readBody(req, [], ['assignee'])
  132. if (rb.bad != null) { return refuse(rb.bad) }
  133. if (rb.body.assignee == null) { return refuse({ error = "field 'assignee' is required: a member's name, or an empty string for nobody" field = 'assignee' }) }
  134. if (t == null) { return fail(404, 'no such ticket') }
  135. let who = ''
  136. if (rb.body.assignee.trim() != '') {
  137. let f = userByRef(rb.body.assignee, t.project)
  138. if (f.error != null) { return refuse({ error = f.error field = 'assignee' }) }
  139. who = f.user.id
  140. }
  141. let r = assignTicket(t.id, u, who)
  142. if (r.forbidden == true) { return reply(403, { error = r.error field = r.field }) }
  143. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  144. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  145. return reply(201, r)
  146. }
  147. // POST …/edit { subject?, summary? } (ticket #8): a member with the role edit or admin → else 403;
  148. // the history keeps the previous values
  149. static editOfTicket = (t, req) => {
  150. if (req.method != 'POST') { return fail(405, 'POST only') }
  151. let u = apiUser(req)
  152. if (u == null) { return unauthorized() }
  153. let rb = readBody(req, [], ['subject' 'summary'])
  154. if (rb.bad != null) { return refuse(rb.bad) }
  155. if (t == null) { return fail(404, 'no such ticket') }
  156. let b = rb.body
  157. let r = editTicket(t.id, u, b.subject, b.summary)
  158. if (r.error == 'no such ticket') { return fail(404, r.error) }
  159. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  160. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  161. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  162. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  163. return reply(201, r)
  164. }
  165. // RELATIONS (ticket #4, mission 017; tickets.hl "relations"): a ticket is named as
  166. // `<project>#<number>` or by its UUID. Who may: a member with the role edit or admin in either ticket's project → else 403.
  167. // POST …/parent { parent }: set the parent; `"parent": ""` removes it.
  168. // POST …/blocked-by { add } or { remove }: this ticket is (no longer) blocked by that one.
  169. // 201 { ticket, event (kind link) }; the ticket rows and every open page of the tickets involved follow.
  170. static linkReply = (r) => {
  171. if (r.error == 'no such ticket') { return fail(404, r.error) }
  172. if (r.forbidden == true) { return reply(403, { error = r.error }) }
  173. if (r.error != null) { return refuse({ error = r.error field = r.field }) }
  174. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  175. emit client ticketsRelated(r.related)
  176. return reply(201, { ticket = r.ticket event = r.event })
  177. }
  178. static parentOfTicket = (t, req) => {
  179. if (req.method != 'POST') { return fail(405, 'POST only') }
  180. let u = apiUser(req)
  181. if (u == null) { return unauthorized() }
  182. let rb = readBody(req, [], ['parent'])
  183. if (rb.bad != null) { return refuse(rb.bad) }
  184. if (rb.body.parent == null) { return refuse({ error = "field 'parent' is required: <project>#<number>, or an empty string to remove the parent" field = 'parent' }) }
  185. if (t == null) { return fail(404, 'no such ticket') }
  186. return linkReply(setParent(t.id, u, rb.body.parent))
  187. }
  188. static blockersOfTicket = (t, req) => {
  189. if (req.method != 'POST') { return fail(405, 'POST only') }
  190. let u = apiUser(req)
  191. if (u == null) { return unauthorized() }
  192. let rb = readBody(req, [], ['add' 'remove'])
  193. if (rb.bad != null) { return refuse(rb.bad) }
  194. let b = rb.body
  195. 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' }) }
  196. if (t == null) { return fail(404, 'no such ticket') }
  197. let adding = b.add != null
  198. return linkReply(changeBlocker(t.id, u, adding ? b.add : b.remove, adding, adding ? 'add' : 'remove'))
  199. }
  200. static getTicket = (route, req) => { return getOne(byRef(route), req) }
  201. static postComment = (route, req) => { return commentOnTicket(byRef(route), req) }
  202. static postState = (route, req) => { return stateOfTicket(byRef(route), req) }
  203. static getProjectTicket = (route, req) => { return getOne(byNumber(route), req) }
  204. static postProjectComment = (route, req) => { return commentOnTicket(byNumber(route), req) }
  205. static postProjectState = (route, req) => { return stateOfTicket(byNumber(route), req) }
  206. static postAssign = (route, req) => { return assignOfTicket(byRef(route), req) }
  207. static postProjectAssign = (route, req) => { return assignOfTicket(byNumber(route), req) }
  208. static postEdit = (route, req) => { return editOfTicket(byRef(route), req) }
  209. static postProjectEdit = (route, req) => { return editOfTicket(byNumber(route), req) }
  210. static postParent = (route, req) => { return parentOfTicket(byRef(route), req) }
  211. static postProjectParent = (route, req) => { return parentOfTicket(byNumber(route), req) }
  212. static postBlockers = (route, req) => { return blockersOfTicket(byRef(route), req) }
  213. static postProjectBlockers = (route, req) => { return blockersOfTicket(byNumber(route), req) }
  214. // ---- projects and members (ticket #20; projects.hl) ----------------------------------------------
  215. // GET /api/projects → { projects (the slugs), details (title, slug, description, id …), states, roles }
  216. // POST /api/projects { title, slug?, description? } → 201 { project, members } — any logged-in user opens
  217. // a project and is its first admin; the slug is generated from the title unless given
  218. // GET /api/projects/:slug → { project, members } (the slug may be an old one)
  219. // POST /api/projects/:slug { title?, slug?, description? } → { project } — admin only; a new slug keeps the old one working
  220. // POST /api/projects/:slug/members { user, role } — admin only: `user` = a display name, an ident id or a user id of someone
  221. // who logged in here; sets (adds / changes) the role: use | edit | admin
  222. // POST /api/projects/:slug/members/remove { user } — admin only
  223. // POST /api/projects/:slug/invites { role, uses?, days?, email? } — admin only: an ident invite link → { url, id, expires, mailed }
  224. // GET /api/inbox — the token's user: { pending, review }, the tickets assigned to them
  225. static getProjects = (route, req) => {
  226. if (req.method == 'POST') { return postNewProject(req) }
  227. if (req.method != 'GET') { return fail(405, 'GET or POST') }
  228. let details = []
  229. for (p of projectRecords()) { details.push(projectRowOf(p)) }
  230. return { projects = projectNames() details = details states = states roles = roles }
  231. }
  232. static postNewProject = (req) => {
  233. let u = apiUser(req)
  234. if (u == null) { return unauthorized() }
  235. let rb = readBody(req, ['title'], ['slug' 'description'])
  236. if (rb.bad != null) { return refuse(rb.bad) }
  237. let r = createProject(u, rb.body.title, rb.body.slug, rb.body.description)
  238. if (r.error != null) { return denied(r) }
  239. return reply(201, r)
  240. }
  241. static getProject = (route, req) => {
  242. let p = projectByAnySlug(route.params.slug)
  243. if (req.method == 'GET') {
  244. if (p == null) { return fail(404, 'no such project') }
  245. return { project = projectRowOf(p) members = memberRows(p.id) }
  246. }
  247. if (req.method != 'POST') { return fail(405, 'GET or POST') }
  248. let u = apiUser(req)
  249. if (u == null) { return unauthorized() }
  250. let rb = readBody(req, [], ['title' 'slug' 'description'])
  251. if (rb.bad != null) { return refuse(rb.bad) }
  252. if (p == null) { return fail(404, 'no such project') }
  253. let r = updateProject(p.id, u, rb.body.title, rb.body.slug, rb.body.description)
  254. if (r.error != null) { return denied(r) }
  255. return reply(200, r)
  256. }
  257. static postMembers = (route, req) => {
  258. if (req.method != 'POST') { return fail(405, 'POST only') }
  259. let p = projectByAnySlug(route.params.slug)
  260. let u = apiUser(req)
  261. if (u == null) { return unauthorized() }
  262. let rb = readBody(req, ['user' 'role'], [])
  263. if (rb.bad != null) { return refuse(rb.bad) }
  264. if (p == null) { return fail(404, 'no such project') }
  265. let f = userByRef(rb.body.user, null)
  266. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  267. let r = setMemberRole(p.id, u, f.user.id, rb.body.role)
  268. if (r.error != null) { return denied(r) }
  269. return reply(200, r)
  270. }
  271. static postMemberRemove = (route, req) => {
  272. if (req.method != 'POST') { return fail(405, 'POST only') }
  273. let p = projectByAnySlug(route.params.slug)
  274. let u = apiUser(req)
  275. if (u == null) { return unauthorized() }
  276. let rb = readBody(req, ['user'], [])
  277. if (rb.bad != null) { return refuse(rb.bad) }
  278. if (p == null) { return fail(404, 'no such project') }
  279. let f = userByRef(rb.body.user, p.id)
  280. if (f.error != null) { return refuse({ error = f.error field = 'user' }) }
  281. let r = removeMember(p.id, u, f.user.id)
  282. if (r.error != null) { return denied(r) }
  283. return reply(200, r)
  284. }
  285. // an invite is only for an admin (invites.hl mayInvite); ident makes the link
  286. static postInvites = (route, req) => {
  287. if (req.method != 'POST') { return fail(405, 'POST only') }
  288. let p = projectByAnySlug(route.params.slug)
  289. let u = apiUser(req)
  290. if (u == null) { return unauthorized() }
  291. let rb = readBody(req, ['role'], ['uses' 'days' 'email'])
  292. if (rb.bad != null) { return refuse(rb.bad) }
  293. if (p == null) { return fail(404, 'no such project') }
  294. let b = rb.body
  295. let no = mayInvite(p, u, b.role)
  296. if (no != null) { return denied(no) }
  297. let n = b.uses == null ? 1 : toNumber(b.uses)
  298. let d = b.days == null ? 7 : toNumber(b.days)
  299. if (n == null || n < 1 || n > 1000) { return denied({ error = "'uses' must be a number from 1 to 1000" field = 'uses' }) }
  300. if (d == null || d < 1 || d > 90) { return denied({ error = "'days' must be a number from 1 to 90" field = 'days' }) }
  301. let r = createInvite(p.id, b.role, n, d, b.email)
  302. if (r.error != null) { return denied(r) }
  303. return reply(201, r)
  304. }
  305. static getInbox = (route, req) => {
  306. if (req.method != 'GET') { return fail(405, 'GET only') }
  307. let u = apiUser(req)
  308. if (u == null) { return unauthorized() }
  309. return inboxRows(u)
  310. }
  311. // ---- connecting an app (ticket #21; connections.hl) ------------------------------------------------
  312. // GET /connect?app=&label=&return=&state= — the app sends the person here; the request is stored and the browser goes to
  313. // the page /connect/<nonce> (components/connect.hl), where they pick or make a project and confirm.
  314. // POST /api/connect/exchange { code } — the app's SERVER swaps the one-time code for the project's key:
  315. // 200 { key, project (slug), title, api }; 400 for an unknown, used or expired code. The key is shown here ONCE.
  316. // GET /api/projects/:slug/connections — who is connected (public, like the project page)
  317. // POST /api/projects/:slug/connections/remove { id } — an admin disconnects; the key is dead at once
  318. static connectStart = (route, req) => {
  319. if (req.method != 'GET') { return htmlPage(405, 'Connect', 'GET only') }
  320. let q = req.query != null ? req.query : {}
  321. let r = createRequest(q.app, q.label, q['return'], q.state)
  322. if (r.error != null) { return htmlPage(400, 'Connect', r.error) }
  323. let to = '/connect/' + r.nonce
  324. return new Response('continue at ' + to, { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  325. }
  326. static connectExchange = (route, req) => {
  327. if (req.method != 'POST') { return fail(405, 'POST only') }
  328. let rb = readBody(req, ['code'], [])
  329. if (rb.bad != null) { return refuse(rb.bad) }
  330. let r = exchangeConnectCode(rb.body.code)
  331. if (r.error != null) { return refuse({ error = r.error field = 'code' }) }
  332. emit client connectionsChanged(r.project)
  333. return new Response(JSON.stringify(r), { status = 200 headers = { 'Content-Type' = 'application/json; charset=utf-8' 'Cache-Control' = 'no-store' } })
  334. }
  335. static projectConnections = (route, req) => {
  336. if (req.method != 'GET') { return fail(405, 'GET only') }
  337. let p = projectByAnySlug(route.params.slug)
  338. if (p == null) { return fail(404, 'no such project') }
  339. return { connections = connectionRows(p.id) }
  340. }
  341. static projectDisconnect = (route, req) => {
  342. if (req.method != 'POST') { return fail(405, 'POST only') }
  343. let p = projectByAnySlug(route.params.slug)
  344. let u = apiUser(req)
  345. if (u == null) { return unauthorized() }
  346. let rb = readBody(req, ['id'], [])
  347. if (rb.bad != null) { return refuse(rb.bad) }
  348. if (p == null) { return fail(404, 'no such project') }
  349. let r = disconnect(p.id, u, rb.body.id)
  350. if (r.error != null) { return denied(r) }
  351. emit client connectionsChanged(p.slug)
  352. return reply(200, r)
  353. }
  354. // ---- old page URLs -----------------------------------------------------------------------------
  355. // `/tickets/<old global number>` (also `/tickets/<uuid>`) move for good to `/projects/<slug>/<number>`
  356. static oldTicketPage = (route, req) => {
  357. let t = ticketByRef(route.params.ref)
  358. if (t == null) { return new Response('no such ticket', { status = 404 headers = { 'Content-Type' = 'text/plain; charset=utf-8' } }) }
  359. let to = ticketHref(projectSlugOf(t), t.number)
  360. return new Response('moved to ' + to, { status = 301 headers = { 'Location' = to 'Content-Type' = 'text/plain; charset=utf-8' } })
  361. }
  362. // ---- THE LOGIN BUTTON'S RETURN (ticket #7; ident README "How apps use ident") -------------
  363. // The shell's "Log in with ident" goes to <ident>/login?key=<IDENT_API_KEY>&return=
  364. // <TICKETS_PUBLIC_URL>/login/callback; ident sends the browser back here with ?ident_code=.
  365. // The code is exchanged SERVER SIDE (users.hl exchangeCode, key + secret) for the per-app
  366. // identity id; its tickets user (made at the first login) goes into THIS browser's hl:web
  367. // session (`req.session`, the cookie's — hybriel#11; minted from `sessions` when the browser brought none), then →
  368. // the page the login started from (`?next=`, ticket #10, safeNext) or `/`, where the shell asks for a display name
  369. // if there is none yet. project.hl hands `req` AND the framework's session store BY REFERENCE: copied into this
  370. // second call, the session inside `req` would be a copy too and the login would never reach the session the
  371. // framework keeps (mission 010: the gate's logins failed that way).
  372. // (The identity selector logs in through the shell's face `identLogin` instead — no reload.)
  373. static loginCallback = (route, &req, &sessions) => {
  374. if (req.method != 'GET') { return htmlPage(405, 'Login failed', 'GET only') }
  375. let q = req.query != null ? req.query : {}
  376. let code = q.ident_code
  377. if (code == null || code == '') { return htmlPage(400, 'Login failed', 'ident sent no login code') }
  378. let x = exchangeCode(code)
  379. if (x.error != null) { return htmlPage(400, 'Login failed', x.error) }
  380. let u = ensureUser(x.identity)
  381. if (u == null) { return htmlPage(500, 'Login failed', 'could not store the user') }
  382. // an INVITE (ident#22, invites.hl): ident sends `invite=<id>` with the code
  383. let joined = null
  384. if (q.invite != null && q.invite != '') {
  385. let j = joinByInvite(q.invite, x.identity, u.id)
  386. if (j.error != null) { return htmlPage(400, 'Invite failed', j.error) }
  387. joined = j.project
  388. }
  389. let s = req.session
  390. let fresh = s == null
  391. if (fresh) { s = sessions.mint() }
  392. s.user = { id = u.id }
  393. s.data.tag = randomBytes(16)
  394. sessions.save(s)
  395. let to = joined != null ? '/projects/' + joined.slug : safeNext(q.next)
  396. let res = new Response('logged in', { status = 302 headers = { 'Location' = to 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  397. if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }
  398. return res
  399. }
  400. // BACK TO THE PAGE (ticket #10): login.js puts `?next=<path + query of the page>` into the
  401. // button's return URL. Only a same-origin PATH goes: it starts with ONE `/` (not `//`, no
  402. // backslash — `/\host` is another host to some browsers), only URL-safe characters (no
  403. // scheme, no spaces, no control characters), at most 500 chars, never /login/… itself.
  404. // Anything else → `/`.
  405. static nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  406. static safeNext = (want) => {
  407. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  408. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  409. let i = 0
  410. while (i < want.length) {
  411. if (!nextChars.includes(want[i])) { return '/' }
  412. i = i + 1
  413. }
  414. return want
  415. }

Branches

Latest commits

  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre