gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain5fdbb6b2ident mission 009: report — scratch folder notemremain/lib/identities-helpers.hl

4.9 KB

  1. // lib/identities-helpers.hl — what lib/identities.hl looks up: the strict identity fields, the avatar check, the
  2. // short id's alphabet, the label a list shows. No table access. Statics only.
  3. import { randomBytes } from 'hl:crypto'
  4. import { str, hasControl } from './util.hl'
  5. static maxField = 60 // chars per identity field
  6. static fieldNames = ['identityName' 'nickname' 'firstname' 'lastname']
  7. // THE AVATAR (ticket #15): an uploaded picture, stored as a small data URL (the page scales it to
  8. // 128×128) — not a text field, so it is checked and stored separately from fieldNames/maxField
  9. static maxAvatarUrl = 60000
  10. static avatarChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
  11. // AN AVATAR: empty (none) or the uploaded picture as a data URL — data:image/(png|jpeg|webp);base64,
  12. // with the file's own magic bytes at the start of the base64 text (the page scales the upload to
  13. // 128×128 first, avatar.js), ≤ maxAvatarUrl characters, base64 characters only
  14. static checkAvatarUrl = (v) => {
  15. if (v == null || hlTypeName(v) != 'String') { return { error = 'field avatar must be a string' } }
  16. t = v.trim()
  17. if (t == '') { return { avatar = '' } }
  18. if (t.length > maxAvatarUrl) { return { error = 'field avatar is longer than ' + maxAvatarUrl + ' characters' } }
  19. let kind = ''
  20. let start = 0
  21. if (t.startsWith('data:image/png;base64,')) { kind = 'iVBORw0KGgo' start = 22 }
  22. else if (t.startsWith('data:image/jpeg;base64,')) { kind = '/9j/' start = 23 }
  23. else if (t.startsWith('data:image/webp;base64,')) { kind = 'UklGR' start = 23 }
  24. if (kind == '') { return { error = 'field avatar must be an uploaded PNG, JPEG or WebP picture' } }
  25. data = t.slice(start, t.length)
  26. if (!data.startsWith(kind)) { return { error = 'field avatar is not a valid picture' } }
  27. let i = 0
  28. while (i < data.length) {
  29. if (!avatarChars.includes(data[i])) { return { error = 'field avatar is not valid base64' } }
  30. i = i + 1
  31. }
  32. return { avatar = t }
  33. }
  34. // the avatar to show: only an uploaded picture (a data URL). Before the upload existed an avatar
  35. // was an http(s) URL; those are no longer shown (never fetched from a third-party address).
  36. static shownAvatar = (r) => { return str(r.avatar).startsWith('data:image/') ? str(r.avatar) : '' }
  37. // THE IDENTITY FIELDS, STRICT: `fields` is an object whose keys are among fieldNames plus
  38. // `avatar`. A name field is a string (trimmed, at most 60 chars, no control characters);
  39. // `avatar` is checked by checkAvatarUrl. Absent keys are absent from the answer.
  40. // Answers { error, field } or { fields }.
  41. static checkFields = (fields) => {
  42. if (fields == null || hlTypeName(fields) != 'Hybrid' || fields.length != null) { return { error = 'the identity fields must be an object' field = '' } }
  43. out = {}
  44. for (k of fields.keys()) {
  45. if (k == 'avatar') {
  46. let c = checkAvatarUrl(fields[k])
  47. if (c.error != null) { return { error = c.error field = 'avatar' } }
  48. out.avatar = c.avatar
  49. } else if (!fieldNames.includes(k)) { return { error = 'unknown field: ' + k field = k } }
  50. else {
  51. let v = fields[k]
  52. if (v == null || hlTypeName(v) != 'String') { return { error = 'field ' + k + ' must be a string' field = k } }
  53. let t = v.trim()
  54. if (t.length > maxField) { return { error = 'field ' + k + ' is longer than ' + maxField + ' characters' field = k } }
  55. if (hasControl(t)) { return { error = 'field ' + k + ' contains a control character' field = k } }
  56. out[k] = t
  57. }
  58. }
  59. return { fields = out }
  60. }
  61. // WHAT A LIST SHOWS: the identity name; without one, nickname, then first + last name,
  62. // then "Identity <n>" (n = its place in the list)
  63. static labelOf = (r, n) => {
  64. if (str(r.identityName) != '') { return r.identityName }
  65. if (str(r.nickname) != '') { return r.nickname }
  66. full = (str(r.firstname) + ' ' + str(r.lastname)).trim()
  67. if (full != '') { return full }
  68. return 'Identity ' + n
  69. }
  70. // ---- THE SHORT ID's shape (ident#23; lib/identities.hl makes and finds them) -------------------
  71. // 5 characters from an alphabet without look-alikes — digits 2-9 and the letters minus i, l, o
  72. // (no 0/O, 1/l/I): 31 characters, 28.6 million ids
  73. static shortAlphabet = '23456789abcdefghjkmnpqrstuvwxyz'
  74. static shortLength = 5
  75. static newShortId = () => {
  76. let out = ''
  77. while (out.length < shortLength) {
  78. let hex = randomBytes(16)
  79. let i = 0
  80. while (i < 32 && out.length < shortLength) {
  81. let b = '0123456789abcdef'.indexOf(hex[i]) * 16 + '0123456789abcdef'.indexOf(hex[i + 1])
  82. // 248 = 8 * 31: bytes above it are dropped so every character is equally likely
  83. if (b < 248) { out = out + shortAlphabet[b % 31] }
  84. i = i + 2
  85. }
  86. }
  87. return out
  88. }
  89. // what a person typed → the lowercase form, or null when it cannot be a short id
  90. static normShortId = (v) => {
  91. if (v == null || hlTypeName(v) != 'String') { return null }
  92. t = v.trim().toLowerCase()
  93. if (t.length != shortLength) { return null }
  94. let i = 0
  95. while (i < t.length) {
  96. if (shortAlphabet.indexOf(t[i]) < 0) { return null }
  97. i = i + 1
  98. }
  99. return t
  100. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
  • fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
  • d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
  • 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre