meta-sovereign 0.49.9 → 0.49.11

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 (38) hide show
  1. package/js/src/server/github-service.js +22 -2
  2. package/js/src/server/index.js +16 -12
  3. package/js/src/server/routes-media.js +179 -0
  4. package/js/src/server/routes-pull.js +30 -2
  5. package/js/src/server/routes-vk.js +93 -0
  6. package/js/src/server/routes-whatsapp.js +19 -4
  7. package/js/src/sources/pull-runner.js +15 -1
  8. package/js/src/sources/vk-hosts.js +51 -0
  9. package/js/src/sources/vk-longpoll.js +12 -1
  10. package/js/src/sources/vk-older.js +75 -0
  11. package/js/src/sources/vk-sync.js +6 -0
  12. package/js/src/sources/vk.js +27 -0
  13. package/js/src/sources/whatsapp-archive.js +14 -2
  14. package/js/src/sources/whatsapp-file.js +93 -20
  15. package/js/src/web/app.css +51 -0
  16. package/js/src/web/archive-media-model.js +28 -0
  17. package/js/src/web/chat-archive-media.js +143 -0
  18. package/js/src/web/chat-media.js +3 -0
  19. package/js/src/web/chat-message.js +5 -1
  20. package/js/src/web/chat-remote-media.js +53 -2
  21. package/js/src/web/chat-view.js +10 -1
  22. package/js/src/web/chat-window.js +62 -4
  23. package/js/src/web/connections/fetch-now.js +9 -2
  24. package/js/src/web/connections/github-inbox.js +4 -0
  25. package/js/src/web/connections/github-notifications.js +133 -0
  26. package/js/src/web/dom.js +13 -0
  27. package/js/src/web/locales/en-chat-account.js +16 -0
  28. package/js/src/web/locales/en-github.js +11 -0
  29. package/js/src/web/locales/hi-chat-account.js +16 -0
  30. package/js/src/web/locales/hi-github.js +12 -0
  31. package/js/src/web/locales/ru-chat-account.js +16 -0
  32. package/js/src/web/locales/ru-github.js +13 -0
  33. package/js/src/web/locales/zh-chat-account.js +14 -0
  34. package/js/src/web/locales/zh-github.js +11 -0
  35. package/js/src/web/message-parts.js +35 -6
  36. package/js/src/web/vk-captcha.js +95 -0
  37. package/package.json +5 -1
  38. package/js/src/server/email-poller.js +0 -76
@@ -100,6 +100,16 @@ const errorEntry = (error) => ({
100
100
  ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
101
101
  });
102
102
 
103
+ // A write that may have been delivered: reported as unknown, never repeated.
104
+ const unknownIfAmbiguous = (error) =>
105
+ error?.ambiguous === true && error.code !== 'send-unknown'
106
+ ? Object.assign(error, {
107
+ code: 'send-unknown',
108
+ outcome: 'unknown',
109
+ fallback: false,
110
+ })
111
+ : error;
112
+
103
113
  const putAll = async (store, links) => {
104
114
  for (const link of links) {
105
115
  await store.put(stampSourceLink(link, 'github'));
@@ -307,16 +317,26 @@ export const createGithubService = ({
307
317
  const notifications = () => storedNotifications(store);
308
318
 
309
319
  // Marking read changes GitHub: only on an explicit call (never by fetching).
320
+ // A failure that may have reached GitHub is `send-unknown` and is not
321
+ // repeated; the stored copy stays unread until the next fetch shows what
322
+ // GitHub says (R-Z4, R-Z20).
310
323
  const markRead = async ({ threadId = null, all = false } = {}) => {
311
324
  const { token, live } = await withLive();
325
+ try {
326
+ if (all) {
327
+ await live.markAllRead({ token });
328
+ } else {
329
+ await live.markThreadRead(threadId, { token });
330
+ }
331
+ } catch (error) {
332
+ throw unknownIfAmbiguous(error);
333
+ }
312
334
  if (all) {
313
- await live.markAllRead({ token });
314
335
  for (const link of await notifications()) {
315
336
  await store.put({ ...link, unread: false });
316
337
  }
317
338
  return { ok: true, all: true };
318
339
  }
319
- await live.markThreadRead(threadId, { token });
320
340
  const link = await store.get(`notification:github:${threadId}`);
321
341
  if (link) {
322
342
  await store.put({ ...link, unread: false });
@@ -39,6 +39,7 @@
39
39
  * POST /api/email/pull -> local-server email API/protocol proxy
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
+ * GET /api/media/<network>/<sha256>[.ext] -> imported archive media (routes-media.js, R-Z15)
42
43
  * GET /api/backups -> list archives (gated on archiveDir)
43
44
  * POST /api/backups -> { passphrase?, keep? } -> { file }
44
45
  * POST /api/backups/restore -> { file, passphrase? } -> { restored }
@@ -95,8 +96,14 @@ import { profileDirIn } from '../sources/whatsapp-web-signin.js';
95
96
  import { handleTelegramRoutes } from './routes-telegram.js';
96
97
  import { handleMessageRoutes } from './routes-messages.js';
97
98
  import { handleEmailGets } from './routes-email.js';
99
+ import { handleMediaGets } from './routes-media.js';
98
100
  import { createAttachmentStore } from './email-attachments.js';
99
- import { createEmailPoller } from './email-poller.js';
101
+ import {
102
+ VK_CAPTCHA_HOSTS,
103
+ VK_IMAGE_HOSTS,
104
+ VK_MEDIA_HOSTS,
105
+ cspSources,
106
+ } from '../sources/vk-hosts.js';
100
107
  import { setAttachmentSink } from '../sources/email.js';
101
108
  import { handleEventRoutes } from './routes-events.js';
102
109
  import { createPasscodeLock } from './passcode.js';
@@ -154,7 +161,9 @@ export const CSP =
154
161
  "style-src 'self'; " +
155
162
  "img-src 'self' data: blob: https://vk.com " +
156
163
  // VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
157
- 'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
164
+ `${cspSources(VK_IMAGE_HOSTS)} ${cspSources(VK_CAPTCHA_HOSTS)}; ` +
165
+ // Direct VK audio and video files (R-Z20); a VK video page is a link.
166
+ `media-src 'self' ${cspSources(VK_MEDIA_HOSTS)}; ` +
158
167
  'frame-src https:; ' +
159
168
  "connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
160
169
  "worker-src 'self'; " +
@@ -389,17 +398,12 @@ const mediaDirOf = (storeDir) =>
389
398
 
390
399
  // Mail (R-Z5): where attachments are kept next to the store, and the
391
400
  // periodic pull of a connected mailbox (`emailPoll: false` turns it off).
392
- const emailContext = (store, storeDir, poll, log, later) => {
401
+ const emailContext = (storeDir) => {
393
402
  const emailFiles = storeDir
394
403
  ? createAttachmentStore(path.join(storeDir, 'media', 'email'))
395
404
  : null;
396
405
  setAttachmentSink(emailFiles ? (bytes) => emailFiles.save(bytes) : null);
397
- const emailPoller =
398
- poll === false
399
- ? { start() {}, stop() {} }
400
- : createEmailPoller({ store, log, ...poll });
401
- later.push(() => emailPoller.start());
402
- return { emailFiles, emailPoller };
406
+ return { emailFiles };
403
407
  };
404
408
 
405
409
  // Listen (with HTTPS beside HTTP when asked, ./local-tls.js) and track
@@ -586,6 +590,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
586
590
  () => handleTelegramRoutes(req, res, p, ctx),
587
591
  () => handleMessageRoutes(store, req, res, p, url, ctx),
588
592
  () => handleEmailGets(store, req, res, p, url, ctx),
593
+ () => handleMediaGets(req, res, p, ctx),
589
594
  () => handleDeletedRoutes(store, req, res, p, url, ctx),
590
595
  () => handleAudienceSyncRoute(store, req, res, p, ctx),
591
596
  () => handleChatRoutes(req, res, p, url, ctx),
@@ -836,7 +841,7 @@ export const startServer = async ({
836
841
  signaling: null,
837
842
  secretPassphrase,
838
843
  emailLiveFactory,
839
- ...emailContext(store, storeDir, emailPoll, log, later),
844
+ ...emailContext(storeDir),
840
845
  ...uiContext(allowOrigins, uiUrl, localUi),
841
846
  ...cvContext(cv),
842
847
  replies: replyService(store, storeDir, replies),
@@ -863,7 +868,7 @@ export const startServer = async ({
863
868
  later
864
869
  ),
865
870
  chatIndex,
866
- ...pullContext(store, log, pulls, later),
871
+ ...pullContext(store, log, pulls, later, emailPoll),
867
872
  };
868
873
  Object.assign(ctx, backgroundJobs({ ctx, store, log, later, backups }));
869
874
  const server = createHttpServer(store, ctx, log);
@@ -909,7 +914,6 @@ export const startServer = async ({
909
914
  close: () =>
910
915
  new Promise((resolve) => {
911
916
  bus?.close();
912
- ctx.emailPoller.stop();
913
917
  ctx.replies.close().catch(() => {});
914
918
  ctx.closeAutomations().catch(() => {});
915
919
  ctx.stopJobs();
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Imported archive media (R-Z15):
3
+ * GET /api/media/<network>/<sha256>[.<ext>]
4
+ * serves a file that an archive import (R-Z10 Facebook/Messenger,
5
+ * Instagram, X, LinkedIn; R-Z15 WhatsApp) copied to
6
+ * `<store>/media/<network>/<sha256><ext>`. The path is built only from
7
+ * validated parts (a fixed network list, 64 hex digits, a fixed extension
8
+ * list), so nothing outside that folder can be named. Pictures, audio and
9
+ * video are shown inline; everything else, svg and html included, is
10
+ * offered as a download with a sandboxing CSP. Audio and video answer
11
+ * `Range`. The file is streamed, never read whole.
12
+ */
13
+
14
+ import { createReadStream, promises as fs } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fail } from './util.js';
17
+ import { fromApp } from './routes-telegram-files.js';
18
+
19
+ export const MEDIA_NETWORKS = Object.freeze([
20
+ 'facebook',
21
+ 'messenger',
22
+ 'instagram',
23
+ 'x',
24
+ 'linkedin',
25
+ 'whatsapp',
26
+ ]);
27
+
28
+ const INLINE = {
29
+ jpg: 'image/jpeg',
30
+ jpeg: 'image/jpeg',
31
+ png: 'image/png',
32
+ gif: 'image/gif',
33
+ webp: 'image/webp',
34
+ mp4: 'video/mp4',
35
+ m4v: 'video/mp4',
36
+ mov: 'video/quicktime',
37
+ webm: 'video/webm',
38
+ mp3: 'audio/mpeg',
39
+ m4a: 'audio/mp4',
40
+ aac: 'audio/aac',
41
+ ogg: 'audio/ogg',
42
+ opus: 'audio/ogg',
43
+ oga: 'audio/ogg',
44
+ wav: 'audio/wav',
45
+ };
46
+ // Downloaded, never shown: documents, archives, and the types a browser
47
+ // would run (svg, html), which are sent as opaque bytes.
48
+ const OPAQUE = 'application/octet-stream';
49
+ const DOWNLOAD = {
50
+ pdf: 'application/pdf',
51
+ txt: 'text/plain; charset=utf-8',
52
+ vcf: 'text/vcard; charset=utf-8',
53
+ csv: 'text/csv; charset=utf-8',
54
+ json: 'application/json',
55
+ zip: 'application/zip',
56
+ ...Object.fromEntries(
57
+ [
58
+ 'doc',
59
+ 'docx',
60
+ 'xls',
61
+ 'xlsx',
62
+ 'ppt',
63
+ 'pptx',
64
+ 'heic',
65
+ 'heif',
66
+ '3gp',
67
+ 'amr',
68
+ 'mkv',
69
+ 'svg',
70
+ 'html',
71
+ 'htm',
72
+ 'xml',
73
+ 'js',
74
+ ].map((ext) => [ext, OPAQUE])
75
+ ),
76
+ };
77
+
78
+ export const MEDIA_EXTENSIONS = Object.freeze([
79
+ ...Object.keys(INLINE),
80
+ ...Object.keys(DOWNLOAD),
81
+ ]);
82
+
83
+ const URL_SHAPE =
84
+ /^\/api\/media\/([a-z]{1,16})\/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$/;
85
+ const IN_MEDIA = /^\/api\/media(\/|$)/;
86
+
87
+ const refuse = (res, status, code, error) =>
88
+ fail(res, status, code, error) ?? true;
89
+ const notFound = (res) => refuse(res, 404, 'not-found', 'no such media file');
90
+
91
+ /** `{network, sha256, ext}` of a valid media path, else null. */
92
+ export const parseMediaPath = (p) => {
93
+ const m = URL_SHAPE.exec(p);
94
+ if (!m || !MEDIA_NETWORKS.includes(m[1])) {
95
+ return null;
96
+ }
97
+ const ext = m[3] ?? '';
98
+ return ext && !MEDIA_EXTENSIONS.includes(ext)
99
+ ? null
100
+ : { network: m[1], sha256: m[2], ext };
101
+ };
102
+
103
+ const rangeOf = (header, size) => {
104
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? ''));
105
+ if (!m || (m[1] === '' && m[2] === '')) {
106
+ return null;
107
+ }
108
+ const start = Math.max(0, m[1] === '' ? size - Number(m[2]) : Number(m[1]));
109
+ const end =
110
+ m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
111
+ return start <= end && start < size
112
+ ? { start, end }
113
+ : { unsatisfiable: true };
114
+ };
115
+
116
+ const send = ({ req, res, file, ext, size }) => {
117
+ const inline = ext in INLINE;
118
+ const headers = {
119
+ 'content-type': INLINE[ext] ?? DOWNLOAD[ext] ?? OPAQUE,
120
+ 'x-content-type-options': 'nosniff',
121
+ 'content-disposition': inline
122
+ ? 'inline'
123
+ : `attachment; filename="media${ext ? `.${ext}` : ''}"`,
124
+ 'cache-control': 'private, max-age=3600',
125
+ 'content-security-policy': "sandbox; default-src 'none'",
126
+ 'accept-ranges': inline ? 'bytes' : 'none',
127
+ };
128
+ const range = inline ? rangeOf(req.headers.range, size) : null;
129
+ if (range?.unsatisfiable) {
130
+ res.writeHead(416, { ...headers, 'content-range': `bytes */${size}` });
131
+ res.end();
132
+ return true;
133
+ }
134
+ const part = range ?? { start: 0, end: size - 1 };
135
+ res.writeHead(range ? 206 : 200, {
136
+ ...headers,
137
+ 'content-length': size === 0 ? 0 : part.end - part.start + 1,
138
+ ...(range
139
+ ? { 'content-range': `bytes ${part.start}-${part.end}/${size}` }
140
+ : {}),
141
+ });
142
+ if (req.method === 'HEAD' || size === 0) {
143
+ res.end();
144
+ return true;
145
+ }
146
+ const stream = createReadStream(file, part);
147
+ stream.on('error', () => res.destroy());
148
+ stream.pipe(res);
149
+ return true;
150
+ };
151
+
152
+ /** GET /api/media/... (see the header). */
153
+ export const handleMediaGets = async (req, res, p, ctx) => {
154
+ if (!IN_MEDIA.test(p) || (req.method !== 'GET' && req.method !== 'HEAD')) {
155
+ return false;
156
+ }
157
+ if (!fromApp(req, ctx?.allowOrigins)) {
158
+ return refuse(res, 403, 'forbidden', 'media loads only in the app');
159
+ }
160
+ const wanted = parseMediaPath(p);
161
+ if (!wanted || !ctx?.storeDir) {
162
+ return notFound(res);
163
+ }
164
+ const { network, sha256, ext } = wanted;
165
+ const file = path.join(
166
+ ctx.storeDir,
167
+ 'media',
168
+ network,
169
+ `${sha256}${ext ? `.${ext}` : ''}`
170
+ );
171
+ try {
172
+ const info = await fs.lstat(file);
173
+ return info.isFile()
174
+ ? send({ req, res, file, ext, size: info.size })
175
+ : notFound(res);
176
+ } catch {
177
+ return notFound(res);
178
+ }
179
+ };
@@ -23,6 +23,8 @@ import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
23
23
  import { createUpworkLive } from '../sources/upwork.js';
24
24
  import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
25
25
  import { createGithubService } from './github-service.js';
26
+ import { handleVkRoutes } from './routes-vk.js';
27
+ import { emailConfigured } from '../sources/email-store.js';
26
28
  import { fail, json, readBody } from './util.js';
27
29
 
28
30
  const MINUTE = 60_000;
@@ -34,9 +36,24 @@ const connected = (store, source) => async () =>
34
36
  // still comes from the store.
35
37
  const liveOf = (make, given) => (given ? make(given) : undefined);
36
38
 
39
+ // Mail (R-Z5, on the shared runner since R-Z20): every ten minutes, the first
40
+ // pull 30 s after the server listens, never faster than a minute, backed off
41
+ // after a failure like every job. `emailPoll: false` keeps only the button.
42
+ // `email.pull` (tests) replaces the real pull.
43
+ const MIN_EMAIL_MS = MINUTE;
44
+ const emailJob = (store, poll = {}) => ({
45
+ ready: async () => emailConfigured(await liveOptionsFrom(store, 'email')),
46
+ run: () =>
47
+ (poll.pull ?? ((s) => pullLiveInto(s, 'email', { limit: 20 })))(store),
48
+ intervalMs:
49
+ poll === false ? 0 : Math.max(MIN_EMAIL_MS, poll.intervalMs ?? 10 * MINUTE),
50
+ firstDelayMs: poll === false ? 0 : (poll.firstMs ?? 30_000),
51
+ });
52
+
37
53
  /** The jobs: network -> { ready, run, intervalMs }. */
38
54
  export const pullJobs = (store, { vkInbox, github, options = {} }) => ({
39
55
  ...(github ? { github: github.pullJob() } : {}),
56
+ email: emailJob(store, options.email),
40
57
  vk: {
41
58
  ready: vkInbox.ready,
42
59
  run: vkInbox.fetch,
@@ -103,6 +120,8 @@ const statusOf = async (store, ctx) => {
103
120
  connected: await ctx.pullRunner.connected(source),
104
121
  lastOkAt: state.lastOkAt ?? null,
105
122
  lastError: state.lastError ?? null,
123
+ // A captcha, or anything else only the user can clear (R-Z20).
124
+ needsAttention: state.lastError?.needsAttention === true,
106
125
  };
107
126
  }
108
127
  return { ok: true, sources };
@@ -117,6 +136,9 @@ export const handlePullRoutes = async (store, req, res, p, ctx) => {
117
136
  if (!ctx.pullRunner) {
118
137
  return false;
119
138
  }
139
+ if (p.startsWith('/api/vk/')) {
140
+ return handleVkRoutes(req, res, p, ctx);
141
+ }
120
142
  if (p === '/api/pull/status' && req.method === 'GET') {
121
143
  return json(res, 200, await statusOf(store, ctx)) ?? true;
122
144
  }
@@ -139,7 +161,7 @@ export const handlePullRoutes = async (store, req, res, p, ctx) => {
139
161
  };
140
162
 
141
163
  /** What a server keeps for the pulls; they start with the other `later` services. */
142
- export const pullContext = (store, log, options, later) => {
164
+ export const pullContext = (store, log, options, later, emailPoll) => {
143
165
  // GitHub's picker routes work even when the pulls are off; its schedule is
144
166
  // one more job of the shared runner (R-Z11).
145
167
  // Tests give `pulls.githubLiveFactory`, a fake createGithubLive.
@@ -153,11 +175,17 @@ export const pullContext = (store, log, options, later) => {
153
175
  if (options === false) {
154
176
  return { ...given, pullRunner: null };
155
177
  }
156
- const services = createPullServices(store, log, options ?? {}, github);
178
+ const services = createPullServices(
179
+ store,
180
+ log,
181
+ { ...options, email: emailPoll },
182
+ github
183
+ );
157
184
  later.push(services.start);
158
185
  return {
159
186
  ...given,
160
187
  pullRunner: services.pullRunner,
188
+ vkInbox: services.vkInbox,
161
189
  stopPulls: services.stop,
162
190
  };
163
191
  };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * VK scroll-back (R-Z20), a user action from the page:
3
+ *
4
+ * POST /api/vk/history {chat, before?, captchaSid?, captchaKey?}
5
+ * -> { ok, imported, complete }
6
+ * or { ok: false, error, code } with `code` one of bad-request |
7
+ * not-connected | rate-limited (HTTP 429) | captcha (HTTP 409, plus
8
+ * `captcha: { sid, img }` -- `img` only when it is on VK's own API
9
+ * host) | the network's own code.
10
+ *
11
+ * `chat` is the VK peer id, `before` the VK id of the oldest message the
12
+ * page holds. A captcha is shown to the user by the page, which repeats
13
+ * this request once with `captchaSid` and `captchaKey`; the program never
14
+ * solves one, and pulls in the background never ask (they back off and mark
15
+ * the connection as needing attention: ../sources/pull-runner.js).
16
+ */
17
+
18
+ import { fail, json, readBody } from './util.js';
19
+ import { isTrustedMutation } from './routes-auto-import.js';
20
+ import { safeMessage } from './redact.js';
21
+ import { vkCaptchaImage } from '../sources/vk-hosts.js';
22
+
23
+ const PEER = /^-?\d{1,15}$/;
24
+ const MESSAGE = /^\d{1,12}$/;
25
+ const SID = /^[\w.-]{1,64}$/;
26
+ const KEY = /^\S{1,32}$/;
27
+ const STATUS = { 'rate-limited': 429, captcha: 409, 'not-connected': 409 };
28
+
29
+ const text = (value) => (value === undefined ? undefined : String(value));
30
+
31
+ /** The checked request, or a message saying what is wrong. */
32
+ export const historyRequest = (body) => {
33
+ const peerId = text(body.chat);
34
+ if (!PEER.test(peerId ?? '') || Number(peerId) === 0) {
35
+ return { error: 'a VK chat id is needed' };
36
+ }
37
+ const before = text(body.before);
38
+ if (before !== undefined && !MESSAGE.test(before)) {
39
+ return { error: '"before" must be a VK message id' };
40
+ }
41
+ const sid = text(body.captchaSid);
42
+ const key = text(body.captchaKey);
43
+ if ((sid === undefined) !== (key === undefined)) {
44
+ return { error: 'captchaSid and captchaKey go together' };
45
+ }
46
+ if (sid !== undefined && !(SID.test(sid) && KEY.test(key))) {
47
+ return { error: 'the captcha answer is not valid' };
48
+ }
49
+ return { peerId, before, captchaSid: sid, captchaKey: key };
50
+ };
51
+
52
+ const failure = (res, error) => {
53
+ const code = error.code ?? 'failed';
54
+ const captcha =
55
+ code === 'captcha'
56
+ ? {
57
+ captcha: {
58
+ sid: error.captchaSid ?? null,
59
+ img: error.captchaImg ? vkCaptchaImage(error.captchaImg) : null,
60
+ },
61
+ }
62
+ : {};
63
+ return (
64
+ json(res, STATUS[code] ?? 502, {
65
+ ok: false,
66
+ error: safeMessage(error),
67
+ code,
68
+ ...captcha,
69
+ }) ?? true
70
+ );
71
+ };
72
+
73
+ export const handleVkRoutes = async (req, res, p, ctx) => {
74
+ if (p !== '/api/vk/history' || req.method !== 'POST') {
75
+ return false;
76
+ }
77
+ if (!isTrustedMutation(req, ctx.allowOrigins)) {
78
+ return fail(res, 403, 'forbidden', 'use the meta-sovereign app') ?? true;
79
+ }
80
+ const request = historyRequest(await readBody(req));
81
+ if (request.error) {
82
+ return fail(res, 400, 'bad-request', request.error) ?? true;
83
+ }
84
+ if (!ctx.vkInbox) {
85
+ return fail(res, 404, 'unsupported', 'VK is not enabled') ?? true;
86
+ }
87
+ try {
88
+ const done = await ctx.vkInbox.older(request);
89
+ return json(res, 200, { ok: true, ...done }) ?? true;
90
+ } catch (error) {
91
+ return failure(res, error);
92
+ }
93
+ };
@@ -6,16 +6,19 @@
6
6
  * POST /api/whatsapp/web/close -> closes it
7
7
  * POST /api/whatsapp/archive {name, data(base64)}
8
8
  * -> imports an "Export chat" .txt or .zip
9
+ * (a zip's photos/videos/files are kept in media/whatsapp, R-Z15)
9
10
  *
10
11
  * The sign-in window is for the person to scan the QR code themselves
11
12
  * (see ../sources/whatsapp-web-signin.js); these routes never touch it.
12
13
  */
13
14
 
15
+ import path from 'node:path';
14
16
  import { json, readBody } from './util.js';
15
17
  import { isTrustedMutation } from './routes-auto-import.js';
16
18
  import { safeMessage } from './redact.js';
17
19
  import { createWhatsAppWebSignIn } from '../sources/whatsapp-web-signin.js';
18
20
  import { importWhatsAppFile } from '../sources/whatsapp-file.js';
21
+ import { isFatalWrite } from '../sources/archive/context.js';
19
22
 
20
23
  const PREFIX = '/api/whatsapp/';
21
24
 
@@ -43,7 +46,7 @@ const failure = (res, error) =>
43
46
  code: error.code ?? 'internal',
44
47
  });
45
48
 
46
- const archiveRoute = async (store, req, res) => {
49
+ const archiveRoute = async (store, req, res, ctx) => {
47
50
  const body = await readBody(req, { limit: 64 * 1024 * 1024 });
48
51
  const bytes = Buffer.from(String(body.data ?? ''), 'base64');
49
52
  if (bytes.length === 0 || typeof body.name !== 'string') {
@@ -53,9 +56,21 @@ const archiveRoute = async (store, req, res) => {
53
56
  });
54
57
  }
55
58
  try {
56
- return reply(res, 200, await importWhatsAppFile(store, body.name, bytes));
59
+ // Media files of a zip are kept next to the store (R-Z15).
60
+ const mediaDir = ctx?.storeDir ? path.join(ctx.storeDir, 'media') : null;
61
+ return reply(
62
+ res,
63
+ 200,
64
+ await importWhatsAppFile(store, body.name, bytes, { mediaDir })
65
+ );
57
66
  } catch (error) {
58
- return reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
67
+ // A full or unwritable disk is not a bad file (R-Z14).
68
+ return isFatalWrite(error)
69
+ ? reply(res, 507, {
70
+ error: safeMessage(error),
71
+ code: 'store-write-failed',
72
+ })
73
+ : reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
59
74
  }
60
75
  };
61
76
 
@@ -94,7 +109,7 @@ export const handleWhatsAppRoutes = async (store, req, res, p, ctx) => {
94
109
  });
95
110
  }
96
111
  if (isArchive) {
97
- return req.method === 'POST' ? archiveRoute(store, req, res) : false;
112
+ return req.method === 'POST' ? archiveRoute(store, req, res, ctx) : false;
98
113
  }
99
114
  const expected = route === 'web' ? 'GET' : 'POST';
100
115
  return req.method === expected
@@ -21,6 +21,10 @@
21
21
  * whose user turned the schedule off can still be fetched by hand) and its
22
22
  * `intervalMs` is then just how often `due()` is asked.
23
23
  *
24
+ * A pull that VK stops with a captcha (code `captcha`) is marked
25
+ * `needsAttention` in `lastError`: neither the schedule nor this runner
26
+ * shows or solves it (R-Z20; the page asks only for the user's own actions).
27
+ *
24
28
  * `pull-state:<source>` keeps `lastOkAt` and a job's own cursor.
25
29
  */
26
30
 
@@ -97,7 +101,13 @@ export const createPullRunner = ({
97
101
  const out = failure(error, { source });
98
102
  log?.({ level: 'warn', event: 'pull_failed', source, code: out.code });
99
103
  await savePullState(store, source, {
100
- lastError: { code: out.code, at: new Date(now()).toISOString() },
104
+ lastError: {
105
+ code: out.code,
106
+ at: new Date(now()).toISOString(),
107
+ // A captcha only the user can solve, in the app: the schedule
108
+ // backs off and never asks (R-Z20).
109
+ ...(out.code === 'captcha' ? { needsAttention: true } : {}),
110
+ },
101
111
  }).catch(() => {});
102
112
  return out;
103
113
  } finally {
@@ -160,6 +170,10 @@ export const createPullRunner = ({
160
170
  fetchNow,
161
171
  /** Start the schedules (call after the server listens). */
162
172
  start() {
173
+ // A second start must not run a second schedule (R-Z20).
174
+ if (!stopped) {
175
+ return;
176
+ }
163
177
  stopped = false;
164
178
  for (const [source, job] of Object.entries(jobs)) {
165
179
  if (job.intervalMs > 0) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The exact VK hosts the page may load from (R-Z20): one list for the CSP
3
+ * (`img-src`, `media-src`, ../server/index.js) and for the adapter, so a URL
4
+ * is only offered to a player when the CSP would let the browser fetch it.
5
+ * Pictures, audio and video come from VK's CDNs; the captcha picture comes
6
+ * from the API host. Nothing is framed: a VK video page is a link, never an
7
+ * iframe.
8
+ */
9
+
10
+ /** Hosts VK serves audio and video files from (`media-src`). */
11
+ export const VK_MEDIA_HOSTS = [
12
+ '*.userapi.com',
13
+ '*.vk-cdn.net',
14
+ '*.vkuseraudio.net',
15
+ '*.vkuservideo.net',
16
+ ];
17
+
18
+ /** Hosts of pictures (`img-src`): the media hosts and VK's photo CDN. */
19
+ export const VK_IMAGE_HOSTS = [...VK_MEDIA_HOSTS, '*.vkuserphoto.ru'];
20
+
21
+ /** Where VK's captcha picture is served from (`img-src`). */
22
+ export const VK_CAPTCHA_HOSTS = ['api.vk.com', 'api.vk.ru'];
23
+
24
+ /** A CSP source list for `hosts`: `https://a https://b`. */
25
+ export const cspSources = (hosts) =>
26
+ hosts.map((host) => `https://${host}`).join(' ');
27
+
28
+ const matches = (host, pattern) =>
29
+ pattern.startsWith('*.')
30
+ ? host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1
31
+ : host === pattern;
32
+
33
+ /** `url` when it is an https address on one of `hosts`, else null. */
34
+ export const onHosts = (url, hosts) => {
35
+ try {
36
+ const parsed = new URL(String(url));
37
+ return parsed.protocol === 'https:' &&
38
+ !parsed.username &&
39
+ hosts.some((pattern) => matches(parsed.hostname, pattern))
40
+ ? parsed.href
41
+ : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
46
+
47
+ /** A direct audio/video file address, only on VK's CDNs. */
48
+ export const vkMediaUrl = (url) => onHosts(url, VK_MEDIA_HOSTS);
49
+
50
+ /** VK's captcha picture address, only on VK's API hosts. */
51
+ export const vkCaptchaImage = (url) => onHosts(url, VK_CAPTCHA_HOSTS);
@@ -181,7 +181,7 @@ const linksOfBatch = async (live, batch) => {
181
181
  };
182
182
 
183
183
  const failureDelay = (error, fails) =>
184
- AUTH_CODES.has(Number(error?.vkCode))
184
+ AUTH_CODES.has(Number(error?.vkCode)) || error?.code === 'captcha'
185
185
  ? 5 * 60_000
186
186
  : Math.min(5 * 60_000, 5000 * 2 ** Math.min(fails - 1, 6));
187
187
 
@@ -307,6 +307,17 @@ export const createVkLongPoll = ({
307
307
  event: 'vk_longpoll_failed',
308
308
  code: error.code ?? error.vkCode ?? null,
309
309
  });
310
+ if (error.code === 'captcha') {
311
+ // Only the user can solve it, in the app; this loop just waits
312
+ // and says so (R-Z20).
313
+ await savePullState(store, SOURCE, {
314
+ lastError: {
315
+ code: 'captcha',
316
+ at: new Date(now()).toISOString(),
317
+ needsAttention: true,
318
+ },
319
+ }).catch(() => {});
320
+ }
310
321
  await sleep(failureDelay(error, fails));
311
322
  }
312
323
  }