meta-sovereign 0.49.15 → 0.49.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/js/src/server/background-jobs.js +5 -2
  2. package/js/src/server/backup-job.js +1 -0
  3. package/js/src/server/index.js +11 -3
  4. package/js/src/server/routes-archive-upload.js +220 -0
  5. package/js/src/server/routes-automations.js +16 -3
  6. package/js/src/server/routes-pull.js +2 -4
  7. package/js/src/server/routes-telegram-contact-extras.js +102 -0
  8. package/js/src/server/routes-telegram-security-extras.js +64 -0
  9. package/js/src/server/routes-telegram.js +4 -0
  10. package/js/src/sources/browser-sender.js +35 -15
  11. package/js/src/sources/pull-runner.js +13 -1
  12. package/js/src/sources/secret-chat-crypto.js +275 -0
  13. package/js/src/sources/secret-chat-session.js +178 -0
  14. package/js/src/sources/secret-chat-tl.js +418 -0
  15. package/js/src/sources/telegram-actions.js +8 -1
  16. package/js/src/sources/telegram-chat-info.js +3 -0
  17. package/js/src/sources/telegram-contact-extras.js +290 -0
  18. package/js/src/sources/telegram-security-extras.js +208 -0
  19. package/js/src/sources/telegram-security.js +5 -0
  20. package/js/src/sources/telegram-write-outcome.js +40 -0
  21. package/js/src/sources/vk-longpoll.js +11 -4
  22. package/js/src/sources/whatsapp-file.js +79 -42
  23. package/js/src/storage/backup.js +17 -3
  24. package/js/src/web/app.css +76 -0
  25. package/js/src/web/app.js +3 -0
  26. package/js/src/web/app.min.js +93 -93
  27. package/js/src/web/chat-actions.js +49 -28
  28. package/js/src/web/chat-contact-extras.js +396 -0
  29. package/js/src/web/chat-contact-share.js +109 -0
  30. package/js/src/web/chat-contacts-synced.js +64 -0
  31. package/js/src/web/chat-contacts-window.js +24 -1
  32. package/js/src/web/chat-drafts-clear.js +52 -24
  33. package/js/src/web/chat-failure.js +5 -0
  34. package/js/src/web/chat-invite.js +2 -1
  35. package/js/src/web/chat-message.js +3 -0
  36. package/js/src/web/chat-paid-send.js +14 -8
  37. package/js/src/web/chat-privacy.js +19 -0
  38. package/js/src/web/chat-profile-extras.js +1 -1
  39. package/js/src/web/chat-replies-block.js +91 -0
  40. package/js/src/web/chat-security-extras.js +303 -0
  41. package/js/src/web/chat-security-forgot.js +240 -0
  42. package/js/src/web/chat-security.js +9 -0
  43. package/js/src/web/chat-selection.js +26 -7
  44. package/js/src/web/chat-sign-in-code.js +62 -0
  45. package/js/src/web/chat-view.js +3 -0
  46. package/js/src/web/connection-guides.js +9 -9
  47. package/js/src/web/connections/index.js +14 -11
  48. package/js/src/web/contact-links.js +171 -0
  49. package/js/src/web/dom-telegram-calls.js +26 -0
  50. package/js/src/web/dom.js +19 -4
  51. package/js/src/web/locales/en-chat-account.js +120 -0
  52. package/js/src/web/locales/en-chat.js +2 -0
  53. package/js/src/web/locales/en.js +5 -4
  54. package/js/src/web/locales/hi-chat-account.js +116 -0
  55. package/js/src/web/locales/hi-chat.js +2 -0
  56. package/js/src/web/locales/hi.js +5 -4
  57. package/js/src/web/locales/ru-chat-account.js +116 -0
  58. package/js/src/web/locales/ru-chat.js +2 -0
  59. package/js/src/web/locales/ru.js +5 -4
  60. package/js/src/web/locales/zh-chat-account.js +107 -0
  61. package/js/src/web/locales/zh-chat.js +1 -0
  62. package/js/src/web/locales/zh.js +5 -4
  63. package/js/src/web/message-text.js +5 -2
  64. package/js/src/web/passkey-codec.js +100 -0
  65. package/js/src/web/person-panel.js +3 -0
  66. package/js/src/web/sign-in-code-guard.js +91 -0
  67. package/package.json +5 -1
@@ -78,14 +78,17 @@ export const backgroundJobs = ({ ctx, store, log, later, backups }) => {
78
78
  });
79
79
  const purge = cachePurgeFor({ store, log, later });
80
80
  return {
81
+ // Returns a promise settled once a backup and the pulls in flight
82
+ // have finished.
81
83
  stopJobs: () => {
82
- backup.stopBackups();
84
+ const backupDone = backup.stopBackups();
83
85
  audience.stopAudienceSync();
84
86
  purge.stopCachePurge();
85
87
  // The other timers and pollers the server started with the port.
86
88
  ctx.autoImport?.close();
87
89
  ctx.whatsappSignIn?.stop().catch(() => {});
88
- ctx.stopPulls?.();
90
+ const pullsDone = ctx.stopPulls?.();
91
+ return Promise.all([backupDone, pullsDone]).then(() => {});
89
92
  },
90
93
  };
91
94
  };
@@ -87,5 +87,6 @@ export const backupJobFor = ({ ctx, store, log, later, backups }) => {
87
87
  });
88
88
  });
89
89
  }
90
+ // Resolves once a backup in flight has finished (and pruned).
90
91
  return { stopBackups: () => scheduler?.stop() };
91
92
  };
@@ -40,6 +40,7 @@
40
40
  * POST /api/email/send -> local-server email send proxy
41
41
  * GET /api/email/attachment -> a mail attachment from the media folder (routes-email.js)
42
42
  * GET /api/media/<network>/<sha256>[.ext] -> imported archive media (routes-media.js, R-Z15)
43
+ * POST /api/archive/upload?source=&name= -> import an export file (routes-archive-upload.js)
43
44
  * GET /api/backups -> list archives (gated on archiveDir)
44
45
  * POST /api/backups -> { passphrase?, keep? } -> { file }
45
46
  * POST /api/backups/restore -> { file, passphrase? } -> { restored }
@@ -97,6 +98,10 @@ import { handleTelegramRoutes } from './routes-telegram.js';
97
98
  import { handleMessageRoutes } from './routes-messages.js';
98
99
  import { handleEmailGets } from './routes-email.js';
99
100
  import { handleMediaGets } from './routes-media.js';
101
+ import {
102
+ handleArchiveUpload,
103
+ UPLOAD_TIMEOUT_MS,
104
+ } from './routes-archive-upload.js';
100
105
  import { createAttachmentStore } from './email-attachments.js';
101
106
  import {
102
107
  VK_CAPTCHA_HOSTS,
@@ -586,6 +591,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
586
591
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
587
592
  () => handleAutoImportRoutes(store, req, res, p, ctx),
588
593
  () => handleWhatsAppRoutes(store, req, res, p, ctx),
594
+ () => handleArchiveUpload(store, req, res, p, ctx),
589
595
  () => handlePullRoutes(store, req, res, p, ctx),
590
596
  () => handleTelegramRoutes(req, res, p, ctx),
591
597
  () => handleMessageRoutes(store, req, res, p, url, ctx),
@@ -659,7 +665,9 @@ const initStore = async ({
659
665
  // The HTTP server: each request is logged, and routed (./account-scope.js
660
666
  // picks the Telegram account it is for).
661
667
  const createHttpServer = (store, ctx, log) =>
662
- http.createServer((req, res) => {
668
+ // A big export uploaded over Wi-Fi takes longer than Node's default 5
669
+ // minutes to arrive (R-Z23); headers still time out at Node's 60 s.
670
+ http.createServer({ requestTimeout: UPLOAD_TIMEOUT_MS }, (req, res) => {
663
671
  const start = Date.now();
664
672
  res.on('finish', () => {
665
673
  log?.({
@@ -916,13 +924,13 @@ export const startServer = async ({
916
924
  bus?.close();
917
925
  ctx.replies.close().catch(() => {});
918
926
  ctx.closeAutomations().catch(() => {});
919
- ctx.stopJobs();
927
+ const jobsDone = ctx.stopJobs();
920
928
  ctx.chatIndex.close();
921
929
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
922
930
  wsHandle?.close();
923
931
  signaling?.close();
924
932
  closeFront();
925
- server.close(() => resolve());
933
+ server.close(() => Promise.resolve(jobsDone).then(resolve, resolve));
926
934
  for (const socket of sockets) {
927
935
  socket.destroy();
928
936
  }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Archive uploads (R-Z23):
3
+ * POST /api/archive/upload?source=<network>&name=<file name>
4
+ * (application/octet-stream body: the file's bytes)
5
+ * -> { ok, imported, files: [{ name, imported, error? }], media?, chat? }
6
+ *
7
+ * The page's import box sends an export it was given (a Facebook, X,
8
+ * LinkedIn or WhatsApp .zip, a Telegram result.json, ...) here. The bytes
9
+ * are streamed to a private temp folder in the store (never held in
10
+ * memory, never base64, no fixed size cap) and read by the same importer
11
+ * as auto-import (../sources/auto-import/files.js: a zip is opened in
12
+ * place, media copied to `<store>/media/<network>/`); a WhatsApp export
13
+ * keeps its own importer (../sources/whatsapp-file.js), which copies the
14
+ * attachments its chat names. The temp file is removed afterwards, and
15
+ * leftovers of a crash are swept on the next upload.
16
+ *
17
+ * Not JSON, so not isTrustedMutation's check: its own, as for the Telegram
18
+ * upload — the bytes' type is not one a form on another site can send
19
+ * without asking first (CORS), and another site's page is refused. A disk
20
+ * without room for the file twice over (the file and what it becomes)
21
+ * is refused before anything is written (507).
22
+ */
23
+
24
+ import { createWriteStream, promises as fs } from 'node:fs';
25
+ import { randomBytes } from 'node:crypto';
26
+ import path from 'node:path';
27
+ import { pipeline } from 'node:stream/promises';
28
+ import { json } from './util.js';
29
+ import { classifyOrigin } from './cors.js';
30
+ import { safeMessage } from './redact.js';
31
+ import { enoughSpace } from '../storage/disk-space.js';
32
+ import { ensurePrivateDir } from '../storage/durable.js';
33
+ import { autoImportRecipes } from '../sources/auto-import/recipes.js';
34
+ import { importDownloadedFile } from '../sources/auto-import/files.js';
35
+ import { importWhatsAppPath } from '../sources/whatsapp-file.js';
36
+ import { isFatalWrite } from '../sources/archive/context.js';
37
+
38
+ export const ARCHIVE_UPLOAD_ROUTE = '/api/archive/upload';
39
+ const UPLOADS = 'uploads';
40
+ // A temp folder this old is a crashed upload's: no request lives that long.
41
+ const STALE_MS = 6 * 60 * 60_000;
42
+ /** How long a request may take to arrive (the server's `requestTimeout`). */
43
+ export const UPLOAD_TIMEOUT_MS = 3 * 60 * 60_000;
44
+
45
+ const reply = (res, status, body) =>
46
+ json(res, status, { ok: status < 400, ...body }) ?? true;
47
+
48
+ // Refused before the body was read: drain it so the socket stays usable.
49
+ const refuse = (req, res, status, code, error) => {
50
+ req.resume();
51
+ return reply(res, status, { error, code });
52
+ };
53
+
54
+ /** A plain file name: no folders or control characters, at most 200 long. */
55
+ export const uploadName = (raw) => {
56
+ const base = path.posix.basename(String(raw ?? '').replace(/\\/g, '/'));
57
+ const clean = [...base]
58
+ .filter((ch) => ch.charCodeAt(0) >= 0x20 && ch.charCodeAt(0) !== 0x7f)
59
+ .join('')
60
+ .slice(-200);
61
+ return clean && clean !== '.' && clean !== '..' ? clean : 'archive';
62
+ };
63
+
64
+ const sweepStale = async (root, now = Date.now()) => {
65
+ for (const name of await fs.readdir(root).catch(() => [])) {
66
+ const dir = path.join(root, name);
67
+ const stat = await fs.stat(dir).catch(() => null);
68
+ if (stat?.isDirectory() && now - stat.mtimeMs > STALE_MS) {
69
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
70
+ }
71
+ }
72
+ };
73
+
74
+ const importUploaded = async (store, { source, name, file, mediaDir }) => {
75
+ if (source === 'whatsapp') {
76
+ const out = await importWhatsAppPath(store, name, file, { mediaDir });
77
+ return { ...out, files: [{ name, imported: out.imported }] };
78
+ }
79
+ const files = await importDownloadedFile({
80
+ store,
81
+ provider: source,
82
+ filePath: file,
83
+ mediaDir,
84
+ });
85
+ return {
86
+ imported: files.reduce((sum, row) => sum + (row.imported ?? 0), 0),
87
+ files,
88
+ };
89
+ };
90
+
91
+ // The answer's status: a full disk, a file with nothing to import, or done.
92
+ const statusOf = (out) => {
93
+ if (out.files.some((row) => row.aborted)) {
94
+ return 507;
95
+ }
96
+ return out.imported === 0 && out.files.every((row) => row.error) ? 400 : 200;
97
+ };
98
+
99
+ const failed = (out) => {
100
+ const status = statusOf(out);
101
+ if (status === 200) {
102
+ return {};
103
+ }
104
+ const row = out.files.find((r) => r.error);
105
+ return {
106
+ error: row?.error ?? 'nothing to import',
107
+ code: status === 507 ? 'store-write-failed' : 'bad-archive',
108
+ };
109
+ };
110
+
111
+ const NOT_WHOLE = Object.freeze([
112
+ 400,
113
+ { error: 'the file did not arrive whole; try again', code: 'upload-failed' },
114
+ ]);
115
+
116
+ // The bytes to `file`, then the import: `[status, body]`.
117
+ const importInto = async (req, ctx, file, { source, name, size }) => {
118
+ try {
119
+ await pipeline(req, createWriteStream(file, { flags: 'wx', mode: 0o600 }));
120
+ } catch {
121
+ return NOT_WHOLE;
122
+ }
123
+ if ((await fs.stat(file)).size !== size) {
124
+ return NOT_WHOLE;
125
+ }
126
+ try {
127
+ const out = await importUploaded(ctx.store, {
128
+ source,
129
+ name,
130
+ file,
131
+ mediaDir: path.join(ctx.storeDir, 'media'),
132
+ });
133
+ return [statusOf(out), { ...out, ...failed(out) }];
134
+ } catch (error) {
135
+ // A full or unwritable disk is not a bad file (R-Z14).
136
+ return isFatalWrite(error)
137
+ ? [507, { error: safeMessage(error), code: 'store-write-failed' }]
138
+ : [400, { error: safeMessage(error), code: 'bad-archive' }];
139
+ }
140
+ };
141
+
142
+ // The temp folder is gone before the answer is sent.
143
+ const receive = async (req, res, ctx, wanted) => {
144
+ const root = path.join(ctx.storeDir, UPLOADS);
145
+ await ensurePrivateDir(root);
146
+ await sweepStale(root);
147
+ const dir = path.join(root, randomBytes(8).toString('hex'));
148
+ await ensurePrivateDir(dir);
149
+ let answer;
150
+ try {
151
+ answer = await importInto(req, ctx, path.join(dir, wanted.name), wanted);
152
+ } finally {
153
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
154
+ }
155
+ return reply(res, ...answer);
156
+ };
157
+
158
+ /** POST /api/archive/upload (see the header). */
159
+ export const handleArchiveUpload = async (store, req, res, p, ctx) => {
160
+ if (p !== ARCHIVE_UPLOAD_ROUTE || req.method !== 'POST') {
161
+ return false;
162
+ }
163
+ const type = String(req.headers['content-type'] ?? '').toLowerCase();
164
+ if (
165
+ !type.startsWith('application/octet-stream') ||
166
+ classifyOrigin(req, ctx?.allowOrigins ?? []) === 'denied'
167
+ ) {
168
+ return refuse(
169
+ req,
170
+ res,
171
+ 403,
172
+ 'forbidden',
173
+ 'archives are sent from the app only'
174
+ );
175
+ }
176
+ const query = new URL(req.url, 'http://local').searchParams;
177
+ const source = query.get('source') ?? '';
178
+ if (!Object.hasOwn(autoImportRecipes, source)) {
179
+ return refuse(
180
+ req,
181
+ res,
182
+ 400,
183
+ 'bad-source',
184
+ `no archive import for "${source}"`
185
+ );
186
+ }
187
+ if (!ctx?.storeDir) {
188
+ return refuse(
189
+ req,
190
+ res,
191
+ 501,
192
+ 'no-store',
193
+ 'archive uploads need a store folder'
194
+ );
195
+ }
196
+ const size = Number(req.headers['content-length']);
197
+ if (!Number.isSafeInteger(size) || size <= 0) {
198
+ return refuse(req, res, 411, 'length-required', 'the file size is needed');
199
+ }
200
+ const space = await enoughSpace(ctx.storeDir, size * 2, ctx.diskFree);
201
+ if (!space.ok) {
202
+ return refuse(
203
+ req,
204
+ res,
205
+ 507,
206
+ 'low-disk',
207
+ `not enough free disk space for this file: about ${Math.ceil(space.needed / 1048576)} MB needed`
208
+ );
209
+ }
210
+ return receive(
211
+ req,
212
+ res,
213
+ { ...ctx, store },
214
+ {
215
+ source,
216
+ name: uploadName(query.get('name')),
217
+ size,
218
+ }
219
+ );
220
+ };
@@ -199,6 +199,20 @@ const sendOnce = (ctx, draft, send) => {
199
199
  return promise;
200
200
  };
201
201
 
202
+ // The network has it: a store that cannot take the copy (a full disk,
203
+ // R-Z14) does not make it unsent — answering "failed" would invite a
204
+ // second send. The sync brings the message once the store takes writes.
205
+ const keepSent = async (store, links) => {
206
+ try {
207
+ for (const link of links) {
208
+ await store.put(link);
209
+ }
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ };
215
+
202
216
  const handleSend = async (store, res, body, ctx) => {
203
217
  if (!validChat(body) || !String(body.text ?? '').trim()) {
204
218
  return fail(res, 400, 'bad-message', 'source, chat and text are needed');
@@ -232,14 +246,13 @@ const handleSend = async (store, res, body, ctx) => {
232
246
  const sent = await sendOnce(ctx, draft, async () => {
233
247
  const result = await ctx.send(draft);
234
248
  const links = sentLinks(draft, result);
235
- for (const link of links) {
236
- await store.put(link);
237
- }
249
+ const stored = await keepSent(store, links);
238
250
  return {
239
251
  status: 'sent',
240
252
  via: result?.via,
241
253
  link: links.at(-1) ?? null,
242
254
  links,
255
+ ...(stored ? {} : { stored: false }),
243
256
  ...(result?.scheduled ? { scheduled: true } : {}),
244
257
  };
245
258
  });
@@ -105,10 +105,8 @@ export const createPullServices = (store, log, options = {}, github = null) => {
105
105
  pullRunner.start();
106
106
  vkInbox.start();
107
107
  },
108
- stop: () => {
109
- pullRunner.stop();
110
- vkInbox.stop();
111
- },
108
+ // Resolves once the pulls in flight have stored what they fetched.
109
+ stop: () => Promise.all([vkInbox.stop(), pullRunner.stop()]).then(() => {}),
112
110
  };
113
111
  };
114
112
 
@@ -0,0 +1,102 @@
1
+ // Contact notes, share-my-contact links, phone links, photos for contacts,
2
+ // suggested birthdays, synced contacts and blocking from Replies (R-Y217):
3
+ // the routes of the POSTS table in routes-telegram.js, each validated
4
+ // strictly before the account is asked.
5
+ //
6
+ // Its routes:
7
+ // POST /api/telegram/contact-note {chat, note} -> { ok, note } the private
8
+ // note on a contact (at most 128 characters, "" clears)
9
+ // POST /api/telegram/contact-token {} -> { ok, url, expires } the link
10
+ // t.me/contact/<token> to share the user's contact
11
+ // POST /api/telegram/contact-token-open {token} -> { ok, chat, name } the
12
+ // person that link names
13
+ // POST /api/telegram/phone-resolve {phone} -> { ok, found, chat?, name? }
14
+ // the person with that number (t.me/+<digits>)
15
+ // POST /api/telegram/contact-photo {chat, mode, token?} -> { ok, mode }
16
+ // mode save (only the user sees it), suggest, or reset
17
+ // (the personal photo removed; no token)
18
+ // POST /api/telegram/birthday-suggest {chat, day, month, year?} -> { ok }
19
+ // POST /api/telegram/saved-contacts {} -> { ok, count } the imported
20
+ // contacts Telegram keeps
21
+ // POST /api/telegram/saved-contacts-reset {} -> { ok } those deleted
22
+ // POST /api/telegram/block-from-replies {msgId, deleteMessage?,
23
+ // deleteHistory?, reportSpam?} -> { ok } the sender
24
+ // of a message of the Replies chat blocked
25
+
26
+ import { route } from './routes-telegram-admin-extras.js';
27
+ import {
28
+ MODES,
29
+ NOTE_MAX,
30
+ PHONE_DIGITS,
31
+ TOKEN,
32
+ USER,
33
+ validMsgId,
34
+ } from '../sources/telegram-contact-extras.js';
35
+
36
+ const UPLOAD = /^[a-f0-9]{32}$/;
37
+ const user = (b) => USER.test(String(b.chat ?? ''));
38
+ const flag = (v) => v === undefined || typeof v === 'boolean';
39
+ const always = () => true;
40
+
41
+ export const CONTACT_EXTRAS_POSTS = {
42
+ 'contact-note': route(
43
+ 'contactNote',
44
+ 'chat and note required',
45
+ (b) => user(b) && typeof b.note === 'string' && b.note.length <= NOTE_MAX,
46
+ (b) => ({ chat: String(b.chat), note: b.note })
47
+ ),
48
+ 'contact-token': route('contactToken', 'unsupported', always, () => ({})),
49
+ 'contact-token-open': route(
50
+ 'contactTokenOpen',
51
+ 'token required',
52
+ (b) => typeof b.token === 'string' && TOKEN.test(b.token),
53
+ (b) => ({ token: b.token })
54
+ ),
55
+ 'phone-resolve': route(
56
+ 'phoneResolve',
57
+ 'phone required',
58
+ (b) =>
59
+ typeof b.phone === 'string' &&
60
+ PHONE_DIGITS.test(b.phone.replace(/^\+/, '')),
61
+ (b) => ({ phone: b.phone })
62
+ ),
63
+ 'contact-photo': route(
64
+ 'contactPhoto',
65
+ 'chat, mode and token required',
66
+ (b) =>
67
+ user(b) &&
68
+ MODES.includes(b.mode) &&
69
+ (b.mode === 'reset'
70
+ ? b.token === undefined
71
+ : typeof b.token === 'string' && UPLOAD.test(b.token)),
72
+ (b) => ({ chat: String(b.chat), mode: b.mode, token: b.token })
73
+ ),
74
+ 'birthday-suggest': route(
75
+ 'birthdaySuggest',
76
+ 'chat, day and month required',
77
+ (b) => user(b) && Number.isInteger(b.day) && Number.isInteger(b.month),
78
+ ({ chat, day, month, year }) => ({ chat: String(chat), day, month, year })
79
+ ),
80
+ 'saved-contacts': route('savedContacts', 'unsupported', always, () => ({})),
81
+ 'saved-contacts-reset': route(
82
+ 'savedContactsReset',
83
+ 'unsupported',
84
+ always,
85
+ () => ({})
86
+ ),
87
+ 'block-from-replies': route(
88
+ 'blockFromReplies',
89
+ 'msgId required',
90
+ (b) =>
91
+ validMsgId(b.msgId) &&
92
+ flag(b.deleteMessage) &&
93
+ flag(b.deleteHistory) &&
94
+ flag(b.reportSpam),
95
+ ({ msgId, deleteMessage, deleteHistory, reportSpam }) => ({
96
+ msgId,
97
+ deleteMessage,
98
+ deleteHistory,
99
+ reportSpam,
100
+ })
101
+ ),
102
+ };
@@ -0,0 +1,64 @@
1
+ // More of Privacy and Security (R-Y218): the routes of the POSTS table in
2
+ // routes-telegram.js, each validated strictly before the account is asked.
3
+ // A code in a body is passed on and never kept.
4
+ //
5
+ // Its routes:
6
+ // POST /api/telegram/login-email-send {email} -> { ok, pattern, length }
7
+ // POST /api/telegram/login-email-verify {code} -> { ok, email }
8
+ // POST /api/telegram/recovery-request -> { ok, pattern } a code mailed to
9
+ // the recovery email
10
+ // POST /api/telegram/recovery-confirm {code} -> { ok } the password gone
11
+ // POST /api/telegram/password-reset -> { ok, result: 'ok' | 'wait' |
12
+ // 'failed-wait', until }
13
+ // POST /api/telegram/password-reset-cancel -> { ok }
14
+ // POST /api/telegram/passkeys -> { ok, passkeys: [{ id, name, created,
15
+ // lastUsed }] }
16
+ // POST /api/telegram/passkey-options -> { ok, options } WebAuthn JSON
17
+ // POST /api/telegram/passkey-register {id, rawId, clientData,
18
+ // attestation} -> { ok, passkey } (base64url strings)
19
+ // POST /api/telegram/passkey-delete {id} -> { ok }
20
+ // POST /api/telegram/sign-in-codes -> { ok, codes } fresh codes from 777000
21
+ // POST /api/telegram/invalidate-codes {codes: ['12345']} -> { ok }
22
+
23
+ import { route } from './routes-telegram-admin-extras.js';
24
+
25
+ const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}$/;
26
+ const CODE = /^[0-9A-Za-z]{1,16}$/;
27
+ const str = (v, max) =>
28
+ typeof v === 'string' && v.length > 0 && v.length <= max;
29
+ const nothing = () => true;
30
+ const code = (b) => CODE.test(String(b.code ?? ''));
31
+
32
+ export const SECURITY_EXTRAS_POSTS = {
33
+ 'login-email-send': route('loginEmailSend', 'a valid email required', (b) =>
34
+ EMAIL.test(String(b.email ?? ''))
35
+ ),
36
+ 'login-email-verify': route('loginEmailVerify', 'the code required', code),
37
+ 'recovery-request': route('recoveryRequest', 'no body', nothing),
38
+ 'recovery-confirm': route('recoveryConfirm', 'the code required', code),
39
+ 'password-reset': route('passwordReset', 'no body', nothing),
40
+ 'password-reset-cancel': route('passwordResetCancel', 'no body', nothing),
41
+ passkeys: route('passkeys', 'no body', nothing),
42
+ 'passkey-options': route('passkeyOptions', 'no body', nothing),
43
+ 'passkey-register': route(
44
+ 'passkeyRegister',
45
+ 'id, rawId, clientData and attestation required',
46
+ (b) =>
47
+ ['id', 'rawId', 'clientData', 'attestation'].every((k) =>
48
+ str(b[k], 100_000)
49
+ )
50
+ ),
51
+ 'passkey-delete': route('passkeyDelete', 'the passkey id required', (b) =>
52
+ str(b.id, 1024)
53
+ ),
54
+ 'sign-in-codes': route('signInCodes', 'no body', nothing),
55
+ 'invalidate-codes': route(
56
+ 'invalidateCodes',
57
+ 'one to ten codes required',
58
+ (b) =>
59
+ Array.isArray(b.codes) &&
60
+ b.codes.length > 0 &&
61
+ b.codes.length <= 10 &&
62
+ b.codes.every((v) => /^\d{5,6}$/.test(String(v)))
63
+ ),
64
+ };
@@ -243,6 +243,7 @@ import { FOLDER_EDIT_POSTS } from './routes-telegram-folder-edit.js';
243
243
  import { CHATLIST_POSTS } from './routes-telegram-chatlists.js';
244
244
  import { STICKER_SETTINGS_POSTS } from './routes-telegram-sticker-settings.js';
245
245
  import { CHAT_THEME_POSTS } from './routes-telegram-chat-themes.js';
246
+ import { CONTACT_EXTRAS_POSTS } from './routes-telegram-contact-extras.js';
246
247
  import { CLOUD_THEME_POSTS } from './routes-telegram-cloud-themes.js';
247
248
  import { COMMON_CHATS_POSTS } from './routes-telegram-common-chats.js';
248
249
  import { STATS_POSTS } from './routes-telegram-stats.js';
@@ -267,6 +268,7 @@ import { EMOJI_STATUS_POSTS } from './routes-telegram-emoji-status.js';
267
268
  import { ROUND_POSTS } from './routes-telegram-round.js';
268
269
  import { PRIVACY_POSTS } from './routes-telegram-privacy.js';
269
270
  import { SECURITY_POSTS } from './routes-telegram-security.js';
271
+ import { SECURITY_EXTRAS_POSTS } from './routes-telegram-security-extras.js';
270
272
  import { SHARE_POSTS } from './routes-telegram-share.js';
271
273
  import { INLINE_POSTS } from './routes-telegram-inline.js';
272
274
  import { MESSAGE_LINK_POSTS } from './routes-telegram-message-links.js';
@@ -1022,6 +1024,7 @@ const POSTS = {
1022
1024
  ...ADMIN_POSTS,
1023
1025
  ...EPHEMERAL_POSTS,
1024
1026
  ...YEXTRAS_POSTS,
1027
+ ...CONTACT_EXTRAS_POSTS,
1025
1028
  ...GROUP_EXTRAS_POSTS,
1026
1029
  ...INSTANT_VIEW_POSTS,
1027
1030
  ...STICKER_EXTRAS_POSTS,
@@ -1071,6 +1074,7 @@ const POSTS = {
1071
1074
  ...ROUND_POSTS,
1072
1075
  ...PRIVACY_POSTS,
1073
1076
  ...SECURITY_POSTS,
1077
+ ...SECURITY_EXTRAS_POSTS,
1074
1078
  ...SHARE_POSTS,
1075
1079
  ...INLINE_POSTS,
1076
1080
  ...TTL_POSTS,
@@ -39,6 +39,7 @@ import { createAdapterSender } from '../crm/reengage.js';
39
39
  import { WEB_UIS } from './web-uis.js';
40
40
  import { createBrowserActions } from './browser-actions.js';
41
41
  import { createMessageActions } from './message-actions.js';
42
+ import { markSendOutcome } from './http.js';
42
43
 
43
44
  const isNumericId = (value) => /^-?\d+$/.test(String(value));
44
45
 
@@ -227,16 +228,29 @@ const typeLines = async (commander, text, delayMs) => {
227
228
  }
228
229
  };
229
230
 
231
+ // A failure while pressing Send may come after the press took effect: the
232
+ // message may be out, so it is never retried or sent another way (R-Z4).
233
+ const sentInDoubt = (error) =>
234
+ Object.assign(markSendOutcome(error, { ambiguous: true }), {
235
+ code: 'send-unknown',
236
+ outcome: 'unknown',
237
+ fallback: false,
238
+ });
239
+
230
240
  /** Click the send button when it is there, otherwise press the key. */
231
241
  const pressSend = async (ui, commander) => {
232
242
  const button = ui.send?.selector
233
243
  ? await firstMatch(commander, ui.send.selector)
234
244
  : null;
235
- if (button) {
236
- await commander.clickButton({ selector: button, scrollIntoView: true });
237
- return;
245
+ try {
246
+ if (button) {
247
+ await commander.clickButton({ selector: button, scrollIntoView: true });
248
+ return;
249
+ }
250
+ await commander.pressKey({ key: ui.send?.key ?? 'Enter' });
251
+ } catch (error) {
252
+ throw sentInDoubt(error);
238
253
  }
239
- await commander.pressKey({ key: ui.send?.key ?? 'Enter' });
240
254
  };
241
255
 
242
256
  /**
@@ -534,6 +548,22 @@ const markUnknown = (error) => {
534
548
  return error;
535
549
  };
536
550
 
551
+ // What a failed web-UI send raises after the API failed with `apiError`.
552
+ const browserFailure = (error, apiError, draft, action) => {
553
+ // "Not allowed to automate this" is the answer, not a pair of failures.
554
+ if (error?.code === 'unsupported') {
555
+ return error;
556
+ }
557
+ // Maybe delivered by the web UI: told as such, never "failed" (R-Z4).
558
+ if (!apiError || error?.ambiguous === true) {
559
+ return markUnknown(error);
560
+ }
561
+ return new SendFallbackError(
562
+ [apiError, error],
563
+ `${draft?.source}: ${action === 'send' ? 'sending' : action} failed through the API (${apiError.message}) and the web UI (${error.message})`
564
+ );
565
+ };
566
+
537
567
  /**
538
568
  * API first, browser second.
539
569
  *
@@ -584,17 +614,7 @@ export const createFallbackSender =
584
614
  apiError ? { apiError: apiError.message } : {}
585
615
  );
586
616
  } catch (error) {
587
- // "Not allowed to automate this" is the answer, not a pair of failures.
588
- if (error?.code === 'unsupported') {
589
- throw error;
590
- }
591
- if (!apiError) {
592
- throw error;
593
- }
594
- throw new SendFallbackError(
595
- [apiError, error],
596
- `${draft?.source}: ${action === 'send' ? 'sending' : action} failed through the API (${apiError.message}) and the web UI (${error.message})`
597
- );
617
+ throw browserFailure(error, apiError, draft, action);
598
618
  }
599
619
  };
600
620
 
@@ -86,9 +86,12 @@ export const createPullRunner = ({
86
86
  const lastStart = new Map();
87
87
  const running = new Set();
88
88
  const timers = new Map();
89
+ // Runs in flight, so that `stop()` can be waited for: a server that
90
+ // closed mid-run would otherwise store the pull after its last flush.
91
+ const flights = new Set();
89
92
  let stopped = true;
90
93
 
91
- const execute = async (source) => {
94
+ const runOnce = async (source) => {
92
95
  const job = jobs[source];
93
96
  running.add(source);
94
97
  lastStart.set(source, now());
@@ -115,6 +118,13 @@ export const createPullRunner = ({
115
118
  }
116
119
  };
117
120
 
121
+ const execute = (source) => {
122
+ const flight = runOnce(source);
123
+ flights.add(flight);
124
+ flight.finally(() => flights.delete(flight)).catch(() => {});
125
+ return flight;
126
+ };
127
+
118
128
  const fetchNow = async (source) => {
119
129
  const job = jobs[source];
120
130
  if (!job) {
@@ -181,12 +191,14 @@ export const createPullRunner = ({
181
191
  }
182
192
  }
183
193
  },
194
+ /** Stop the schedules; resolves once the runs in flight are done. */
184
195
  stop() {
185
196
  stopped = true;
186
197
  for (const timer of timers.values()) {
187
198
  clearTimer(timer);
188
199
  }
189
200
  timers.clear();
201
+ return Promise.allSettled([...flights]).then(() => {});
190
202
  },
191
203
  sources: () => Object.keys(jobs),
192
204
  connected: (source) => jobs[source].ready(),