meta-sovereign 0.49.9 → 0.49.10
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/index.js +3 -0
- package/js/src/server/routes-media.js +179 -0
- package/js/src/server/routes-whatsapp.js +19 -4
- package/js/src/sources/whatsapp-archive.js +14 -2
- package/js/src/sources/whatsapp-file.js +93 -20
- package/js/src/web/app.css +11 -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/dom.js +6 -0
- package/js/src/web/locales/en-chat-account.js +4 -0
- package/js/src/web/locales/hi-chat-account.js +4 -0
- package/js/src/web/locales/ru-chat-account.js +4 -0
- package/js/src/web/locales/zh-chat-account.js +4 -0
- package/js/src/web/message-parts.js +35 -6
- package/package.json +3 -1
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,6 +96,7 @@ 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
101
|
import { createEmailPoller } from './email-poller.js';
|
|
100
102
|
import { setAttachmentSink } from '../sources/email.js';
|
|
@@ -586,6 +588,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
|
|
|
586
588
|
() => handleTelegramRoutes(req, res, p, ctx),
|
|
587
589
|
() => handleMessageRoutes(store, req, res, p, url, ctx),
|
|
588
590
|
() => handleEmailGets(store, req, res, p, url, ctx),
|
|
591
|
+
() => handleMediaGets(req, res, p, ctx),
|
|
589
592
|
() => handleDeletedRoutes(store, req, res, p, url, ctx),
|
|
590
593
|
() => handleAudienceSyncRoute(store, req, res, p, ctx),
|
|
591
594
|
() => handleChatRoutes(req, res, p, url, ctx),
|
|
@@ -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
|
+
};
|
|
@@ -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
|
|
@@ -154,6 +154,11 @@ const KINDS = [
|
|
|
154
154
|
const kindOf = (name) =>
|
|
155
155
|
KINDS.find(([, re]) => re.test(name))?.[0] ?? 'document';
|
|
156
156
|
|
|
157
|
+
// The page's network-neutral attachment kinds (R-Z15); `type` stays as
|
|
158
|
+
// stored by R-Z9 so earlier imports and later ones read alike.
|
|
159
|
+
const PAGE_KIND = { image: 'photo', document: 'file', contact: 'file' };
|
|
160
|
+
const pageKind = (type) => PAGE_KIND[type] ?? type;
|
|
161
|
+
|
|
157
162
|
const lookupFile = (files, name) => {
|
|
158
163
|
const base = (f) => String(f).split(/[\\/]/).pop();
|
|
159
164
|
return (
|
|
@@ -169,7 +174,13 @@ const mediaOf = (body, files) => {
|
|
|
169
174
|
const line = first.trim();
|
|
170
175
|
if (OMITTED.test(line)) {
|
|
171
176
|
return {
|
|
172
|
-
attachment: {
|
|
177
|
+
attachment: {
|
|
178
|
+
type: 'media',
|
|
179
|
+
kind: 'file',
|
|
180
|
+
name: null,
|
|
181
|
+
file: null,
|
|
182
|
+
omitted: true,
|
|
183
|
+
},
|
|
173
184
|
caption: more.join('\n').trim(),
|
|
174
185
|
};
|
|
175
186
|
}
|
|
@@ -179,8 +190,9 @@ const mediaOf = (body, files) => {
|
|
|
179
190
|
return null;
|
|
180
191
|
}
|
|
181
192
|
const file = lookupFile(files, name);
|
|
193
|
+
const type = kindOf(name);
|
|
182
194
|
return {
|
|
183
|
-
attachment: { type:
|
|
195
|
+
attachment: { type, kind: pageKind(type), name, file, omitted: false },
|
|
184
196
|
caption: more.join('\n').trim(),
|
|
185
197
|
};
|
|
186
198
|
};
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Import one WhatsApp "Export chat" file that arrived as bytes (R-Z9
|
|
3
|
-
* the `.txt`, or the `.zip` of an export with media. The text is
|
|
4
|
-
* by ./whatsapp-archive.js
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Import one WhatsApp "Export chat" file that arrived as bytes (R-Z9,
|
|
3
|
+
* R-Z15): the `.txt`, or the `.zip` of an export with media. The text is
|
|
4
|
+
* parsed by ./whatsapp-archive.js. A zip is written to a private temp file
|
|
5
|
+
* and read with the streaming reader (./archive/zip-file.js); each file
|
|
6
|
+
* the chat names (`<attached: f>`, `f (file attached)`) is copied into
|
|
7
|
+
* `<store>/media/whatsapp/<sha256>.<ext>` (0600) and the attachment gets
|
|
8
|
+
* `path`, `sha256` and `size`, so the page can show it. Without a
|
|
9
|
+
* `mediaDir` the names are mapped but no bytes are kept.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
|
-
import {
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createArchiveContext } from './archive/context.js';
|
|
16
|
+
import { mediaSaver } from './archive/media-store.js';
|
|
17
|
+
import { openZipFile } from './archive/zip-file.js';
|
|
10
18
|
import { getSource, stampSourceLink } from './index.js';
|
|
11
19
|
|
|
12
20
|
const ZIP_LOCAL_HEADER = 0x04034b50;
|
|
@@ -19,23 +27,88 @@ const textEntry = (entries) =>
|
|
|
19
27
|
entries.find((e) => /(^|\/)_chat\.txt$/i.test(e.name)) ??
|
|
20
28
|
entries.find((e) => /\.txt$/i.test(e.name));
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// Copy each attachment's file into media/ and say where it went.
|
|
31
|
+
const withMedia = async (links, ctx, counts) => {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const link of links) {
|
|
34
|
+
const attachments = [];
|
|
35
|
+
for (const attachment of link.attachments ?? []) {
|
|
36
|
+
const copy = attachment.file ? await ctx.media(attachment.file) : null;
|
|
37
|
+
counts[copy ? 'saved' : 'missing'] += attachment.omitted ? 0 : 1;
|
|
38
|
+
attachments.push(
|
|
39
|
+
copy
|
|
40
|
+
? {
|
|
41
|
+
...attachment,
|
|
42
|
+
path: copy.path,
|
|
43
|
+
sha256: copy.sha256,
|
|
44
|
+
size: copy.size,
|
|
45
|
+
}
|
|
46
|
+
: attachment
|
|
47
|
+
);
|
|
29
48
|
}
|
|
30
|
-
|
|
31
|
-
text: chat.read().toString('utf8'),
|
|
32
|
-
fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
|
|
33
|
-
files: entries.map((e) => e.name),
|
|
34
|
-
};
|
|
49
|
+
out.push(attachments.length ? { ...link, attachments } : link);
|
|
35
50
|
}
|
|
36
|
-
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const readZip = async (name, bytes, mediaDir) => {
|
|
55
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-whatsapp-'));
|
|
56
|
+
const file = path.join(dir, 'export.zip');
|
|
57
|
+
try {
|
|
58
|
+
await fs.writeFile(file, bytes, { mode: 0o600 });
|
|
59
|
+
const zip = await openZipFile(file);
|
|
60
|
+
try {
|
|
61
|
+
const chat = textEntry(zip.entries);
|
|
62
|
+
if (!chat) {
|
|
63
|
+
throw new Error('no chat .txt found in this zip');
|
|
64
|
+
}
|
|
65
|
+
const archive = {
|
|
66
|
+
text: (await chat.read()).toString('utf8'),
|
|
67
|
+
fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
|
|
68
|
+
files: zip.entries.map((e) => e.name),
|
|
69
|
+
};
|
|
70
|
+
const ctx = createArchiveContext({
|
|
71
|
+
network: 'whatsapp',
|
|
72
|
+
entries: zip.entries,
|
|
73
|
+
save: mediaDir ? mediaSaver(mediaDir) : null,
|
|
74
|
+
});
|
|
75
|
+
const counts = { saved: 0, missing: 0 };
|
|
76
|
+
const links = await withMedia(
|
|
77
|
+
await getSource('whatsapp').parseArchive(archive),
|
|
78
|
+
ctx,
|
|
79
|
+
counts
|
|
80
|
+
);
|
|
81
|
+
return { links, media: { ...counts, warnings: ctx.warnings } };
|
|
82
|
+
} finally {
|
|
83
|
+
await zip.close();
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const importWhatsAppFile = async (
|
|
91
|
+
store,
|
|
92
|
+
name,
|
|
93
|
+
bytes,
|
|
94
|
+
{ mediaDir = null } = {}
|
|
95
|
+
) => {
|
|
96
|
+
const { links, media } = isZip(name, bytes)
|
|
97
|
+
? await readZip(name, bytes, mediaDir)
|
|
98
|
+
: {
|
|
99
|
+
links: await getSource('whatsapp').parseArchive({
|
|
100
|
+
text: bytes.toString('utf8'),
|
|
101
|
+
fileName: name,
|
|
102
|
+
files: [],
|
|
103
|
+
}),
|
|
104
|
+
media: null,
|
|
105
|
+
};
|
|
37
106
|
for (const link of links) {
|
|
38
107
|
await store.put(stampSourceLink(link, 'whatsapp'));
|
|
39
108
|
}
|
|
40
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
imported: links.length,
|
|
111
|
+
chat: links[0]?.chat ?? null,
|
|
112
|
+
...(media ? { media } : {}),
|
|
113
|
+
};
|
|
41
114
|
};
|
package/js/src/web/app.css
CHANGED
|
@@ -12556,3 +12556,14 @@ a.remote-chip:hover {
|
|
|
12556
12556
|
main[data-view='chat'] > .api-error-toast {
|
|
12557
12557
|
margin: 0;
|
|
12558
12558
|
}
|
|
12559
|
+
|
|
12560
|
+
/* R-Z15: attachments of imported archive messages (pictures, players,
|
|
12561
|
+
file chips, "media not imported"). */
|
|
12562
|
+
.archive-media audio,
|
|
12563
|
+
.archive-media video {
|
|
12564
|
+
max-width: 100%;
|
|
12565
|
+
}
|
|
12566
|
+
.remote-chip.is-missing {
|
|
12567
|
+
color: var(--text-muted);
|
|
12568
|
+
border-style: dashed;
|
|
12569
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// What the page needs to know about an imported archive attachment
|
|
2
|
+
// (R-Z15); pure, so it is tested without a browser.
|
|
3
|
+
|
|
4
|
+
// `media/<network>/<64 hex>[.ext]`, the only paths the importers write.
|
|
5
|
+
const STORED = /^media\/([a-z]{1,16})\/([0-9a-f]{64}(?:\.[a-z0-9]{1,8})?)$/;
|
|
6
|
+
|
|
7
|
+
// What older WhatsApp imports stored: `type`, no `kind`.
|
|
8
|
+
const LEGACY_KIND = { image: 'photo', document: 'file', contact: 'file' };
|
|
9
|
+
|
|
10
|
+
/** The server path (`/api/media/...`) of a stored attachment, or null. */
|
|
11
|
+
export const mediaPathOf = (attachment) => {
|
|
12
|
+
const m = STORED.exec(String(attachment?.path ?? ''));
|
|
13
|
+
return m ? `/api/media/${m[1]}/${m[2]}` : null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** One attachment as the page draws it. */
|
|
17
|
+
export const archiveItem = (attachment) => {
|
|
18
|
+
const a = attachment ?? {};
|
|
19
|
+
return {
|
|
20
|
+
kind: a.kind ?? LEGACY_KIND[a.type] ?? a.type ?? 'file',
|
|
21
|
+
name: a.name ?? null,
|
|
22
|
+
size: Number(a.size) || 0,
|
|
23
|
+
path: mediaPathOf(a),
|
|
24
|
+
url: /^https?:\/\//.test(a.url ?? '') ? a.url : null,
|
|
25
|
+
omitted: Boolean(a.omitted),
|
|
26
|
+
caption: a.caption ?? null,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Attachments of imported archive messages (R-Z15): Facebook/Messenger,
|
|
2
|
+
// Instagram, X, LinkedIn and WhatsApp exports. The importers copy each
|
|
3
|
+
// file to `media/<network>/<sha256>.<ext>` and store `{kind, name, path,
|
|
4
|
+
// sha256, size}` on the message; the server shows it at
|
|
5
|
+
// /api/media/<network>/<file> (../server/routes-media.js). Pictures are
|
|
6
|
+
// drawn inline, audio and video get a player, other files are chips with
|
|
7
|
+
// name and size; a file that was not imported (the archive did not hold
|
|
8
|
+
// it, or WhatsApp omitted it) is a "media not imported" chip, never a
|
|
9
|
+
// broken picture.
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useState } from 'react';
|
|
12
|
+
import { useT } from './i18n.js';
|
|
13
|
+
import { mediaSrc } from './media-src.js';
|
|
14
|
+
import { formatSize } from './download/model.js';
|
|
15
|
+
|
|
16
|
+
const el = React.createElement;
|
|
17
|
+
|
|
18
|
+
// The address the page loads the file from (the app's server).
|
|
19
|
+
const useMediaUrl = (api, path) => {
|
|
20
|
+
const [url, setUrl] = useState(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let live = true;
|
|
23
|
+
if (!path) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const wanted =
|
|
27
|
+
typeof api?.archiveMediaUrl === 'function'
|
|
28
|
+
? api.archiveMediaUrl(path)
|
|
29
|
+
: Promise.resolve(path);
|
|
30
|
+
wanted.then((got) => live && setUrl(got ?? null)).catch(() => {});
|
|
31
|
+
return () => {
|
|
32
|
+
live = false;
|
|
33
|
+
};
|
|
34
|
+
}, [api, path]);
|
|
35
|
+
return url;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const Missing = ({ item }) => {
|
|
39
|
+
const t = useT();
|
|
40
|
+
return el(
|
|
41
|
+
'span',
|
|
42
|
+
{
|
|
43
|
+
className: 'remote-chip is-missing',
|
|
44
|
+
'data-archive-missing': item.kind,
|
|
45
|
+
title: t('chat.archive.notImportedShort'),
|
|
46
|
+
},
|
|
47
|
+
t('chat.archive.notImported', { name: item.name ?? t('chat.remote.file') })
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const FileChip = ({ item, url }) => {
|
|
52
|
+
const t = useT();
|
|
53
|
+
const label = [item.name ?? t('chat.remote.file'), formatSize(item.size)]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(' · ');
|
|
56
|
+
return el(
|
|
57
|
+
'a',
|
|
58
|
+
{
|
|
59
|
+
className: 'remote-chip',
|
|
60
|
+
'data-archive-file': item.kind,
|
|
61
|
+
href: url,
|
|
62
|
+
download: item.name ?? '',
|
|
63
|
+
title: t('chat.archive.save', { name: item.name ?? '' }),
|
|
64
|
+
},
|
|
65
|
+
`📎 ${label}`
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const PICTURES = new Set(['photo', 'gif', 'sticker']);
|
|
70
|
+
|
|
71
|
+
const Stored = ({ item, url }) => {
|
|
72
|
+
const [broken, setBroken] = useState(false);
|
|
73
|
+
if (!url) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const label = item.name ?? item.kind;
|
|
77
|
+
if (PICTURES.has(item.kind) && !broken) {
|
|
78
|
+
return el(
|
|
79
|
+
'a',
|
|
80
|
+
{ href: url, target: '_blank', rel: 'noopener noreferrer', title: label },
|
|
81
|
+
el('img', {
|
|
82
|
+
...mediaSrc(url),
|
|
83
|
+
className: `remote-img remote-${item.kind}`,
|
|
84
|
+
alt: label,
|
|
85
|
+
loading: 'lazy',
|
|
86
|
+
decoding: 'async',
|
|
87
|
+
'data-archive-image': '',
|
|
88
|
+
onError: () => setBroken(true),
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (item.kind === 'video' && !broken) {
|
|
93
|
+
return el('video', {
|
|
94
|
+
...mediaSrc(url),
|
|
95
|
+
className: 'remote-img',
|
|
96
|
+
controls: true,
|
|
97
|
+
preload: 'metadata',
|
|
98
|
+
'data-archive-video': '',
|
|
99
|
+
onError: () => setBroken(true),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if ((item.kind === 'audio' || item.kind === 'voice') && !broken) {
|
|
103
|
+
return el('audio', {
|
|
104
|
+
...mediaSrc(url),
|
|
105
|
+
controls: true,
|
|
106
|
+
preload: 'none',
|
|
107
|
+
'data-archive-audio': '',
|
|
108
|
+
onError: () => setBroken(true),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return el(FileChip, { item, url });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const Item = ({ item, api }) => {
|
|
115
|
+
const url = useMediaUrl(api, item.path);
|
|
116
|
+
if (item.kind === 'link' && item.url) {
|
|
117
|
+
return el(
|
|
118
|
+
'a',
|
|
119
|
+
{
|
|
120
|
+
className: 'remote-chip',
|
|
121
|
+
href: item.url,
|
|
122
|
+
target: '_blank',
|
|
123
|
+
rel: 'noopener noreferrer',
|
|
124
|
+
},
|
|
125
|
+
item.caption || item.url
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (item.omitted || !item.path) {
|
|
129
|
+
return el(Missing, { item });
|
|
130
|
+
}
|
|
131
|
+
return el(Stored, { item, url });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** All the attachments of one imported message, in order. */
|
|
135
|
+
export const ArchiveAttachments = ({ media, api }) =>
|
|
136
|
+
el(
|
|
137
|
+
'div',
|
|
138
|
+
{
|
|
139
|
+
className: 'remote-media archive-media',
|
|
140
|
+
'data-archive-media': String(media.items.length),
|
|
141
|
+
},
|
|
142
|
+
media.items.map((item, index) => el(Item, { key: index, item, api }))
|
|
143
|
+
);
|
package/js/src/web/chat-media.js
CHANGED
|
@@ -25,6 +25,7 @@ import { useNowSeconds } from './live-clock.js';
|
|
|
25
25
|
import { useAutoLoad } from './auto-download.js';
|
|
26
26
|
import { LottieSticker } from './lottie-sticker.js';
|
|
27
27
|
import { RemoteAttachments } from './chat-remote-media.js';
|
|
28
|
+
import { ArchiveAttachments } from './chat-archive-media.js';
|
|
28
29
|
import {
|
|
29
30
|
Transcribable,
|
|
30
31
|
TranscribeButton,
|
|
@@ -913,6 +914,8 @@ const LiveStreamCard = ({ media }) => {
|
|
|
913
914
|
const CARDS = {
|
|
914
915
|
// All the attachments of a message at another network (R-Z8).
|
|
915
916
|
remote: RemoteAttachments,
|
|
917
|
+
// Files kept from an imported archive (R-Z15).
|
|
918
|
+
archive: ArchiveAttachments,
|
|
916
919
|
'live-stream': LiveStreamCard,
|
|
917
920
|
place: PlaceCard,
|
|
918
921
|
contact: ContactCard,
|
|
@@ -292,7 +292,11 @@ const Reactions = ({ reactions, paid, api, chat, msgId }) => {
|
|
|
292
292
|
...(reactions ?? []).map((r) =>
|
|
293
293
|
el(
|
|
294
294
|
'span',
|
|
295
|
-
{
|
|
295
|
+
{
|
|
296
|
+
key: r.emoticon,
|
|
297
|
+
className: 'bubble-reaction',
|
|
298
|
+
title: r.by?.length ? r.by.join(', ') : undefined,
|
|
299
|
+
},
|
|
296
300
|
`${r.emoticon} ${r.count}`
|
|
297
301
|
)
|
|
298
302
|
),
|
package/js/src/web/dom.js
CHANGED
|
@@ -1334,6 +1334,12 @@ const rawApi = {
|
|
|
1334
1334
|
return scopedUrl(origin ? new URL(path, origin).toString() : path);
|
|
1335
1335
|
},
|
|
1336
1336
|
|
|
1337
|
+
// An imported archive file's address (R-Z15): `/api/media/<network>/<file>`.
|
|
1338
|
+
archiveMediaUrl: async (path) => {
|
|
1339
|
+
const { origin } = await ensure();
|
|
1340
|
+
return scopedUrl(origin ? new URL(path, origin).toString() : path);
|
|
1341
|
+
},
|
|
1342
|
+
|
|
1337
1343
|
// Fetch new mail from the saved connection (R-Z5): { ok, imported, … }
|
|
1338
1344
|
// or { ok: false, error, code }.
|
|
1339
1345
|
emailPull: async (body = {}) =>
|
|
@@ -446,4 +446,8 @@ export const enChatAccount = {
|
|
|
446
446
|
'{members} members read from {networks} networks.',
|
|
447
447
|
'automations.audiences.syncFailed':
|
|
448
448
|
'The members could not be read. Try again.',
|
|
449
|
+
// Imported archive media (R-Z15).
|
|
450
|
+
'chat.archive.notImported': 'Media not imported: {name}',
|
|
451
|
+
'chat.archive.notImportedShort': 'Media not imported',
|
|
452
|
+
'chat.archive.save': 'Save {name}',
|
|
449
453
|
};
|
|
@@ -450,4 +450,8 @@ export const hiChatAccount = {
|
|
|
450
450
|
'automations.audiences.synced':
|
|
451
451
|
'{networks} नेटवर्क से {members} सदस्य पढ़े गए।',
|
|
452
452
|
'automations.audiences.syncFailed': 'सदस्य नहीं पढ़े जा सके। फिर कोशिश करें।',
|
|
453
|
+
// Imported archive media (R-Z15).
|
|
454
|
+
'chat.archive.notImported': 'मीडिया आयात नहीं हुआ: {name}',
|
|
455
|
+
'chat.archive.notImportedShort': 'मीडिया आयात नहीं हुआ',
|
|
456
|
+
'chat.archive.save': '{name} सहेजें',
|
|
453
457
|
};
|
|
@@ -471,4 +471,8 @@ export const ruChatAccount = {
|
|
|
471
471
|
'Прочитано участников: {members}, сетей: {networks}.',
|
|
472
472
|
'automations.audiences.syncFailed':
|
|
473
473
|
'Не удалось прочитать участников. Попробуйте ещё раз.',
|
|
474
|
+
// Imported archive media (R-Z15).
|
|
475
|
+
'chat.archive.notImported': 'Медиа не импортировано: {name}',
|
|
476
|
+
'chat.archive.notImportedShort': 'Медиа не импортировано',
|
|
477
|
+
'chat.archive.save': 'Сохранить {name}',
|
|
474
478
|
};
|
|
@@ -410,4 +410,8 @@ export const zhChatAccount = {
|
|
|
410
410
|
'automations.audiences.synced':
|
|
411
411
|
'已从 {networks} 个网络读取 {members} 名成员。',
|
|
412
412
|
'automations.audiences.syncFailed': '无法读取成员,请重试。',
|
|
413
|
+
// Imported archive media (R-Z15).
|
|
414
|
+
'chat.archive.notImported': '媒体未导入:{name}',
|
|
415
|
+
'chat.archive.notImportedShort': '媒体未导入',
|
|
416
|
+
'chat.archive.save': '保存 {name}',
|
|
413
417
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// code copied — the algorithm is Telegram's own spec).
|
|
9
9
|
|
|
10
10
|
import { parsePoll, parseTodo } from './poll-parts.js';
|
|
11
|
+
import { archiveItem } from './archive-media-model.js';
|
|
11
12
|
import { quoteRichText, richOf } from './rich-message-model.js';
|
|
12
13
|
import { richServiceWords } from './rich-service-model.js';
|
|
13
14
|
import { inTopicOnly } from './forum-topics.js';
|
|
@@ -683,7 +684,12 @@ const parseReactions = (raw, link) =>
|
|
|
683
684
|
.map((r) => ({ emoticon: r.reaction.emoticon, count: r.count ?? 1 }))
|
|
684
685
|
: (Array.isArray(link?.reactions) ? link.reactions : [])
|
|
685
686
|
.filter((r) => r?.emoji)
|
|
686
|
-
.map((r) => ({
|
|
687
|
+
.map((r) => ({
|
|
688
|
+
emoticon: r.emoji,
|
|
689
|
+
count: r.count ?? 1,
|
|
690
|
+
// Who reacted, when the import kept it (R-Z15).
|
|
691
|
+
by: Array.isArray(r.by) ? r.by.map(String) : [],
|
|
692
|
+
}));
|
|
687
693
|
|
|
688
694
|
// A label for an attachment that is not a file (a place, a contact…).
|
|
689
695
|
const ATTACHMENT_LABELS = {
|
|
@@ -706,6 +712,22 @@ const remoteMedia = (list) => ({
|
|
|
706
712
|
})),
|
|
707
713
|
});
|
|
708
714
|
|
|
715
|
+
// Imported archives keep their files in media/ (R-Z15): all attachments
|
|
716
|
+
// of such a message are drawn together (./chat-archive-media.js).
|
|
717
|
+
const ARCHIVE_SOURCES = new Set([
|
|
718
|
+
'facebook',
|
|
719
|
+
'instagram',
|
|
720
|
+
'x',
|
|
721
|
+
'linkedin',
|
|
722
|
+
'whatsapp',
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
const archiveMedia = (list) => ({
|
|
726
|
+
kind: 'archive',
|
|
727
|
+
type: list[0].kind ?? 'file',
|
|
728
|
+
items: list.map(archiveItem),
|
|
729
|
+
});
|
|
730
|
+
|
|
709
731
|
// One attachment as a link or a file.
|
|
710
732
|
const singleAttachmentMedia = (a) => {
|
|
711
733
|
if (a.kind === 'link' && a.url) {
|
|
@@ -734,13 +756,17 @@ const singleAttachmentMedia = (a) => {
|
|
|
734
756
|
* attachments together as `remote` (all of them), else the first one as a
|
|
735
757
|
* link or a file.
|
|
736
758
|
*/
|
|
737
|
-
export const attachmentMedia = (attachments) => {
|
|
759
|
+
export const attachmentMedia = (attachments, source = null) => {
|
|
760
|
+
const archive = ARCHIVE_SOURCES.has(source);
|
|
738
761
|
const list = (Array.isArray(attachments) ? attachments : []).filter(
|
|
739
|
-
(a) => a?.kind
|
|
762
|
+
(a) => a?.kind || (archive && a?.type)
|
|
740
763
|
);
|
|
741
764
|
if (list.length === 0) {
|
|
742
765
|
return null;
|
|
743
766
|
}
|
|
767
|
+
if (archive) {
|
|
768
|
+
return archiveMedia(list);
|
|
769
|
+
}
|
|
744
770
|
return list.length > 1 || REMOTE_KINDS.has(list[0].kind)
|
|
745
771
|
? remoteMedia(list)
|
|
746
772
|
: singleAttachmentMedia(list[0]);
|
|
@@ -751,6 +777,11 @@ export const attachmentMedia = (attachments) => {
|
|
|
751
777
|
const bodyOf = (link, rich) =>
|
|
752
778
|
typeof link?.body === 'string' && !rich && !link.bodyTree ? link.body : '';
|
|
753
779
|
|
|
780
|
+
const linkMedia = (link) =>
|
|
781
|
+
link?.source === 'email'
|
|
782
|
+
? null
|
|
783
|
+
: attachmentMedia(link?.attachments, link?.source);
|
|
784
|
+
|
|
754
785
|
/**
|
|
755
786
|
* Parse a message link into all display parts.
|
|
756
787
|
*
|
|
@@ -761,9 +792,7 @@ const bodyOf = (link, rich) =>
|
|
|
761
792
|
export const parseMessageParts = (link, msgById = new Map()) => {
|
|
762
793
|
const raw = link?.raw ?? {};
|
|
763
794
|
// A mail lists its attachments itself (chat-email-body.js).
|
|
764
|
-
const media =
|
|
765
|
-
parseMedia(raw) ??
|
|
766
|
-
(link?.source === 'email' ? null : attachmentMedia(link?.attachments));
|
|
795
|
+
const media = parseMedia(raw) ?? linkMedia(link);
|
|
767
796
|
const rich = richOf(raw);
|
|
768
797
|
const body = bodyOf(link, rich);
|
|
769
798
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meta-sovereign",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.10",
|
|
4
4
|
"description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "js/src/index.js",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"test:e2e:browser:webkit": "E2E_BROWSER=webkit node js/tests/e2e-browser-spa.mjs",
|
|
44
44
|
"test:e2e:pages": "node js/tests/e2e-pages.mjs",
|
|
45
45
|
"test:pack": "node js/tests/pack-smoke.mjs",
|
|
46
|
+
"test:e2e:archive-media": "node js/tests/e2e-archive-media-browser.mjs",
|
|
47
|
+
"test:e2e:archive-media:webkit": "E2E_BROWSER=webkit node js/tests/e2e-archive-media-browser.mjs",
|
|
46
48
|
"test:e2e:email": "node js/tests/e2e-email-browser.mjs",
|
|
47
49
|
"test:e2e:email:webkit": "E2E_BROWSER=webkit node js/tests/e2e-email-browser.mjs",
|
|
48
50
|
"test:e2e:cv": "node js/tests/e2e-cv-browser.mjs",
|