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.
- package/js/src/server/github-service.js +22 -2
- package/js/src/server/index.js +16 -12
- package/js/src/server/routes-media.js +179 -0
- package/js/src/server/routes-pull.js +30 -2
- package/js/src/server/routes-vk.js +93 -0
- package/js/src/server/routes-whatsapp.js +19 -4
- package/js/src/sources/pull-runner.js +15 -1
- package/js/src/sources/vk-hosts.js +51 -0
- package/js/src/sources/vk-longpoll.js +12 -1
- package/js/src/sources/vk-older.js +75 -0
- package/js/src/sources/vk-sync.js +6 -0
- package/js/src/sources/vk.js +27 -0
- package/js/src/sources/whatsapp-archive.js +14 -2
- package/js/src/sources/whatsapp-file.js +93 -20
- package/js/src/web/app.css +51 -0
- package/js/src/web/archive-media-model.js +28 -0
- package/js/src/web/chat-archive-media.js +143 -0
- package/js/src/web/chat-media.js +3 -0
- package/js/src/web/chat-message.js +5 -1
- package/js/src/web/chat-remote-media.js +53 -2
- package/js/src/web/chat-view.js +10 -1
- package/js/src/web/chat-window.js +62 -4
- package/js/src/web/connections/fetch-now.js +9 -2
- package/js/src/web/connections/github-inbox.js +4 -0
- package/js/src/web/connections/github-notifications.js +133 -0
- package/js/src/web/dom.js +13 -0
- package/js/src/web/locales/en-chat-account.js +16 -0
- package/js/src/web/locales/en-github.js +11 -0
- package/js/src/web/locales/hi-chat-account.js +16 -0
- package/js/src/web/locales/hi-github.js +12 -0
- package/js/src/web/locales/ru-chat-account.js +16 -0
- package/js/src/web/locales/ru-github.js +13 -0
- package/js/src/web/locales/zh-chat-account.js +14 -0
- package/js/src/web/locales/zh-github.js +11 -0
- package/js/src/web/message-parts.js +35 -6
- package/js/src/web/vk-captcha.js +95 -0
- package/package.json +5 -1
- 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 });
|
package/js/src/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
}
|