gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain5cb85d75deploy.sh: a backup taken while a background job writes (tar exit 1) is a warning; archive checked with gzip -tmremain/lib/users.hl

8.2 KB

  1. // lib/users.hl — WHO IS SIGNED IN (tracker.worldapi.org#1; copied from calendar.worldapi.org's users.hl unchanged in
  2. // shape, per the architect: "Copy calendar.worldapi.org's login unchanged"). Login is ident's LOGIN BUTTON flow
  3. // (ident README "How apps use ident", way 2): <ident>/login?key=&return=<public url>/login/callback → ?ident_code=
  4. // → the server exchanges it (key + secret) for the per-app identity id. Way 3, the IDENTITY SELECTOR, sits beside the
  5. // button (login.js hands its code to the shell).
  6. //
  7. // usersTable pk @id index !identity { identity, created } storage/mpackdb/users.db
  8. // identity = what ident's exchange answers: the identity's public SHORT id (ident#23, `a68sz`) — stays SERVER
  9. // SIDE, never sent to a page.
  10. // The session (hl:web) carries `user = { id = <users @id> }` only. No display name: nothing else is stored.
  11. //
  12. // Config (environment, or `.env` beside project.hl — never read or printed by workers):
  13. // IDENT_URL, IDENT_EXCHANGE_URL, TRACKER_KEY, TRACKER_SECRET as calendar's IDENT_API_KEY/IDENT_API_SECRET
  14. // TRACKER_URL the app's own address, default https://tracker.worldapi.org
  15. // TRACKER_STORAGE table directory, default ./storage/mpackdb
  16. import { MPackDB } from 'hl:mpackdb'
  17. import { Response } from 'hl:http1'
  18. import { randomBytes } from 'hl:crypto'
  19. import { now } from 'hl:time'
  20. import { fetch } from 'hl:fetch'
  21. import { envOr, storageDir, firstOf } from './util.hl'
  22. static identUrl = envOr('IDENT_URL', 'https://ident.worldapi.org')
  23. static identExchangeUrl = envOr('IDENT_EXCHANGE_URL', identUrl)
  24. static identKey = envOr('TRACKER_KEY', '')
  25. static identSecret = envOr('TRACKER_SECRET', '')
  26. static publicUrl = envOr('TRACKER_URL', 'https://tracker.worldapi.org')
  27. static usersTable = new MPackDB(file = storageDir + '/users.db', primaryKey = '@id', indexes = ['!identity'])
  28. static selectorScript = identUrl + '/selector.js'
  29. static callbackUrl = publicUrl.replaceAll('/', '') == '' ? '' : publicUrl + '/login/callback'
  30. static loginHref = identUrl + '/login?key=' + identKey + '&return=' + encodeURIComponent(callbackUrl)
  31. // only lowercase hex (ident's one-time codes are 48 hex)
  32. static isHex = (&s, &max) => {
  33. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > max) { return false }
  34. let i = 0
  35. while (i < s.length) {
  36. let c = s.charCodeAt(i)
  37. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102))) { return false }
  38. i = i + 1
  39. }
  40. return true
  41. }
  42. // an identity id as ident answers it: its public SHORT ID since ident#23 (5 characters like `a68sz`: 2-9 and a-z),
  43. // before that the old per-app id (32 hex) — lower case letters and digits, at most 64
  44. static isIdentId = (&s) => {
  45. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > 64) { return false }
  46. let i = 0
  47. while (i < s.length) {
  48. let c = s.charCodeAt(i)
  49. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122))) { return false }
  50. i = i + 1
  51. }
  52. return true
  53. }
  54. // THE EXCHANGE: POST <ident>/api/exchange { key, secret, code } → { identity } | { error }
  55. // (a failed fetch is an `Error` event, absorbed by project.hl's `on Error`; the fetch then yields null)
  56. static exchangeCode = (code) => {
  57. if (identKey == '' || identSecret == '') { return { error = 'login is not set up on this server (TRACKER_KEY / TRACKER_SECRET missing)' } }
  58. if (!isHex(code, 200)) { return { error = 'that is not an ident login code' } }
  59. r = fetch(identExchangeUrl + '/api/exchange', { method = 'POST' json = { key = identKey secret = identSecret code = code } headers = { 'user-agent' = 'tracker.worldapi.org (ident exchange)' } timeoutMs = 10000 })
  60. if (r == null || r.status == null || r.status == 0) { return { error = 'ident did not answer' } }
  61. j = r.status == 200 ? r.json() : null
  62. if (j == null || j.identity == null || !isIdentId(j.identity)) {
  63. let why = ''
  64. if (r.status != 200) {
  65. e = r.json()
  66. why = e != null && e.error != null ? ': ' + e.error : ''
  67. }
  68. return { error = 'ident refused the login (' + r.status + why + ')' }
  69. }
  70. return { identity = j.identity }
  71. }
  72. // ---- users --------------------------------------------------------------------------------
  73. // a face's trailing `session` is always the server's since hybriel #16 (a peer's extra argument is refused)
  74. static userRecord = (&userId) => {
  75. if (userId == null || hlTypeName(userId) != 'String' || userId == '') { return null }
  76. return usersTable.fetch(userId)
  77. }
  78. // the user of an identity id, made at its first login
  79. static ensureUser = (identity) => {
  80. u = firstOf(usersTable.find('identity', identity))
  81. if (u != null) { return u }
  82. id = usersTable.put({ identity = identity created = now() })
  83. if (id == null) { return null }
  84. return usersTable.fetch(id)
  85. }
  86. // an ident login code → this app's user (made at its first login): { user } or { error } — both ways in (the header's
  87. // selector, components/main.hl face trackerLogin; the button's return, loginCallbackOf below)
  88. static userOfCode = (code) => {
  89. x = exchangeCode(code)
  90. if (x.error != null) { return { error = x.error } }
  91. u = ensureUser(x.identity)
  92. if (u == null) { return { error = 'could not store the user' } }
  93. return { user = u }
  94. }
  95. static userOfSession = (&session) => {
  96. if (session == null || session.user == null) { return null }
  97. return userRecord(session.user.id)
  98. }
  99. // the users @id of a session, or null (a page may know it: it is not the identity id)
  100. static userIdOfSession = (&session) => {
  101. u = userOfSession(session)
  102. return u == null ? null : u.id
  103. }
  104. // ---- THE LOGIN BUTTON'S RETURN (ident README "How apps use ident"), copied from calendar.worldapi.org's project.hl ---------
  105. // (mission 028: out of project.hl, whose route `/login/callback` calls `loginCallbackOf` with the server's sessions)
  106. // /login/callback?ident_code=<code> the app's server exchanges the code for the identity's short id
  107. // (POST <ident>/api/exchange) and signs this app's OWN session in
  108. // (session.user = { id = <users @id> }); the header's "Log out" signs it out again
  109. // (components/main.hl face trackerLogOut) — ident's own session is untouched
  110. // BACK TO THE PAGE: /login.js puts `?next=` into the button's return URL at the click. Only a same-origin PATH
  111. // goes (one `/`, URL-safe characters, ≤ 500). Anything else → `/`.
  112. static nextChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~/?&=%+,;@!$()*:'
  113. static safePath = (&want) => {
  114. if (want == null || hlTypeName(want) != 'String' || want == '' || want.length > 500) { return '/' }
  115. if (want.slice(0, 1) != '/' || want.slice(0, 2) == '//' || want.slice(0, 7) == '/login/') { return '/' }
  116. let i = 0
  117. while (i < want.length) {
  118. if (!nextChars.includes(want[i])) { return '/' }
  119. i = i + 1
  120. }
  121. return want
  122. }
  123. // A FAILED LOGIN is a page (components/loginfailed.hl): the reason is parked in the session, then → /login/failed
  124. static loginFailedOf = (&sessions, &req, why) => {
  125. let s = req.session
  126. fresh = s == null
  127. if (fresh) { s = sessions.mint() }
  128. s.data.loginError = why
  129. sessions.save(s)
  130. let res = new Response('login failed: ' + why, { status = 302 headers = { 'Location' = '/login/failed' 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  131. if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }
  132. return res
  133. }
  134. // the function route gets the cookie's session as req.session (hybriel #11); none yet → minted here. `sessions` = the
  135. // server's (hl:web `server.sessions`)
  136. static loginCallbackOf = (&sessions, &req) => {
  137. if (req.method != 'GET') { return loginFailedOf(sessions, req, 'GET only') }
  138. q = req.query != null ? req.query : {}
  139. code = q.ident_code
  140. if (code == null || code == '') { return loginFailedOf(sessions, req, 'ident sent no login code') }
  141. x = userOfCode(code)
  142. if (x.error != null) { return loginFailedOf(sessions, req, x.error) }
  143. u = x.user
  144. let s = req.session
  145. fresh = s == null
  146. if (fresh) { s = sessions.mint() }
  147. s.user = { id = u.id }
  148. s.data.tag = randomBytes(16)
  149. s.data.loginError = null
  150. sessions.save(s)
  151. let res = new Response('logged in', { status = 302 headers = { 'Location' = safePath(q.next) 'Cache-Control' = 'no-store' 'Content-Type' = 'text/plain; charset=utf-8' } })
  152. if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }
  153. return res
  154. }

Branches

Latest commits

  • 5cb85d75deploy.sh: a backup taken while a background job writes (tar exit 1) is a warning; archive checked with gzip -tmre
  • 9abda75dtracker: report 035mre
  • 12595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre
  • e7305014tracker: report 032 (art + photos)mre
  • fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
  • 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
  • e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
  • b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
  • 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
  • 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
  • 8751adb8tracker: report 032mre
  • 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
  • 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
  • 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
  • 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre