gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8f1d45428f1d4542tracker#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 supersededmre8f1d4542/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

  • 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
  • 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
  • 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
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
  • 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
  • a386dc92tracker: reports 029 + 030mre
  • 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
  • d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
  • 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
  • 661c2592tracker: report 028mre
  • 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
  • d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
  • 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
  • 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre