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.
- package/js/src/server/background-jobs.js +5 -2
- package/js/src/server/backup-job.js +1 -0
- package/js/src/server/index.js +11 -3
- package/js/src/server/routes-archive-upload.js +220 -0
- package/js/src/server/routes-automations.js +16 -3
- package/js/src/server/routes-pull.js +2 -4
- package/js/src/server/routes-telegram-contact-extras.js +102 -0
- package/js/src/server/routes-telegram-security-extras.js +64 -0
- package/js/src/server/routes-telegram.js +4 -0
- package/js/src/sources/browser-sender.js +35 -15
- package/js/src/sources/pull-runner.js +13 -1
- package/js/src/sources/secret-chat-crypto.js +275 -0
- package/js/src/sources/secret-chat-session.js +178 -0
- package/js/src/sources/secret-chat-tl.js +418 -0
- package/js/src/sources/telegram-actions.js +8 -1
- package/js/src/sources/telegram-chat-info.js +3 -0
- package/js/src/sources/telegram-contact-extras.js +290 -0
- package/js/src/sources/telegram-security-extras.js +208 -0
- package/js/src/sources/telegram-security.js +5 -0
- package/js/src/sources/telegram-write-outcome.js +40 -0
- package/js/src/sources/vk-longpoll.js +11 -4
- package/js/src/sources/whatsapp-file.js +79 -42
- package/js/src/storage/backup.js +17 -3
- package/js/src/web/app.css +76 -0
- package/js/src/web/app.js +3 -0
- package/js/src/web/app.min.js +93 -93
- package/js/src/web/chat-actions.js +49 -28
- package/js/src/web/chat-contact-extras.js +396 -0
- package/js/src/web/chat-contact-share.js +109 -0
- package/js/src/web/chat-contacts-synced.js +64 -0
- package/js/src/web/chat-contacts-window.js +24 -1
- package/js/src/web/chat-drafts-clear.js +52 -24
- package/js/src/web/chat-failure.js +5 -0
- package/js/src/web/chat-invite.js +2 -1
- package/js/src/web/chat-message.js +3 -0
- package/js/src/web/chat-paid-send.js +14 -8
- package/js/src/web/chat-privacy.js +19 -0
- package/js/src/web/chat-profile-extras.js +1 -1
- package/js/src/web/chat-replies-block.js +91 -0
- package/js/src/web/chat-security-extras.js +303 -0
- package/js/src/web/chat-security-forgot.js +240 -0
- package/js/src/web/chat-security.js +9 -0
- package/js/src/web/chat-selection.js +26 -7
- package/js/src/web/chat-sign-in-code.js +62 -0
- package/js/src/web/chat-view.js +3 -0
- package/js/src/web/connection-guides.js +9 -9
- package/js/src/web/connections/index.js +14 -11
- package/js/src/web/contact-links.js +171 -0
- package/js/src/web/dom-telegram-calls.js +26 -0
- package/js/src/web/dom.js +19 -4
- package/js/src/web/locales/en-chat-account.js +120 -0
- package/js/src/web/locales/en-chat.js +2 -0
- package/js/src/web/locales/en.js +5 -4
- package/js/src/web/locales/hi-chat-account.js +116 -0
- package/js/src/web/locales/hi-chat.js +2 -0
- package/js/src/web/locales/hi.js +5 -4
- package/js/src/web/locales/ru-chat-account.js +116 -0
- package/js/src/web/locales/ru-chat.js +2 -0
- package/js/src/web/locales/ru.js +5 -4
- package/js/src/web/locales/zh-chat-account.js +107 -0
- package/js/src/web/locales/zh-chat.js +1 -0
- package/js/src/web/locales/zh.js +5 -4
- package/js/src/web/message-text.js +5 -2
- package/js/src/web/passkey-codec.js +100 -0
- package/js/src/web/person-panel.js +3 -0
- package/js/src/web/sign-in-code-guard.js +91 -0
- 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
|
};
|
package/js/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
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(),
|