meta-sovereign 0.49.8 → 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/archive/media-store.js +2 -1
- package/js/src/sources/auto-import/sign-ins.js +2 -1
- package/js/src/sources/auto-import/telegram-creds.js +2 -1
- package/js/src/sources/telegram-export-run.js +2 -1
- package/js/src/sources/whatsapp-archive.js +14 -2
- package/js/src/sources/whatsapp-file.js +93 -20
- package/js/src/storage/durable.js +43 -3
- package/js/src/storage/lino-store.js +34 -13
- 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
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createHash, randomBytes } from 'node:crypto';
|
|
9
9
|
import { createWriteStream, promises as fs } from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { renameDurable } from '../../storage/durable.js';
|
|
11
12
|
import { pipeline } from 'node:stream/promises';
|
|
12
13
|
|
|
13
14
|
const extensionOf = (name) => {
|
|
@@ -39,7 +40,7 @@ export const saveMedia = async ({ mediaDir, network, stream, name }) => {
|
|
|
39
40
|
);
|
|
40
41
|
const sha256 = hash.digest('hex');
|
|
41
42
|
const file = `${sha256}${extensionOf(name)}`;
|
|
42
|
-
await
|
|
43
|
+
await renameDurable(temp, path.join(dir, file)); // (R-Z19)
|
|
43
44
|
await fs.chmod(path.join(dir, file), 0o600);
|
|
44
45
|
return { path: `media/${network}/${file}`, sha256, size };
|
|
45
46
|
} catch (error) {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { promises as fs } from 'node:fs';
|
|
29
29
|
import os from 'node:os';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
+
import { renameDurable } from '../../storage/durable.js';
|
|
31
32
|
import { execFile } from 'node:child_process';
|
|
32
33
|
import { autoImportRecipes } from './recipes.js';
|
|
33
34
|
|
|
@@ -229,7 +230,7 @@ export const createSignIns = ({
|
|
|
229
230
|
const file = fileOf(provider);
|
|
230
231
|
const temp = `${file}.${process.pid}.tmp`;
|
|
231
232
|
await fs.writeFile(temp, JSON.stringify(seed), { mode: 0o600 });
|
|
232
|
-
await
|
|
233
|
+
await renameDurable(temp, file); // (R-Z19)
|
|
233
234
|
};
|
|
234
235
|
|
|
235
236
|
const access = () => probeFullDiskAccess({ platform, homeDir, env });
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { promises as fs } from 'node:fs';
|
|
33
33
|
import os from 'node:os';
|
|
34
34
|
import path from 'node:path';
|
|
35
|
+
import { renameDurable } from '../../storage/durable.js';
|
|
35
36
|
import crypto from 'node:crypto';
|
|
36
37
|
import { timedRequest } from '../http.js';
|
|
37
38
|
|
|
@@ -765,7 +766,7 @@ export const createTelegramCreds = ({
|
|
|
765
766
|
typeof v === 'bigint' ? v.toString() : v
|
|
766
767
|
);
|
|
767
768
|
await fs.writeFile(temp, text, { mode: 0o600 });
|
|
768
|
-
await
|
|
769
|
+
await renameDurable(temp, cacheFile); // (R-Z19)
|
|
769
770
|
};
|
|
770
771
|
|
|
771
772
|
const verifyAll = (records) =>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { renameDurable } from '../storage/durable.js';
|
|
13
14
|
import { rawMessage, floodSeconds } from './telegram-history.js';
|
|
14
15
|
import { bigSize, locationOf, fileLocation } from './telegram-media.js';
|
|
15
16
|
import { failureOf } from './telegram-stickers.js';
|
|
@@ -149,7 +150,7 @@ const download = async (r, info, target) => {
|
|
|
149
150
|
outputFile: part,
|
|
150
151
|
})
|
|
151
152
|
);
|
|
152
|
-
await
|
|
153
|
+
await renameDurable(part, target); // (R-Z19)
|
|
153
154
|
} catch (error) {
|
|
154
155
|
await fs.rm(part, { force: true });
|
|
155
156
|
throw error;
|
|
@@ -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
|
};
|
|
@@ -45,6 +45,46 @@ export const fsyncDir = async (dir) => {
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const WINDOWS_BUSY = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
49
|
+
const defaultSleep = (ms) =>
|
|
50
|
+
new Promise((resolve) => {
|
|
51
|
+
globalThis.setTimeout(resolve, ms);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rename `from` over `to`, retrying while another handle holds the target
|
|
56
|
+
* (R-Z19). On Windows a rename fails with EPERM/EBUSY/EACCES while any
|
|
57
|
+
* process (antivirus, the search indexer, our own snapshot worker) has the
|
|
58
|
+
* target open, usually for a few milliseconds: retried up to `attempts`
|
|
59
|
+
* times with a growing pause (about a second in all, like graceful-fs).
|
|
60
|
+
* Elsewhere rename is atomic and those codes are real errors, so only
|
|
61
|
+
* EBUSY is retried. After the last attempt the error is thrown and the
|
|
62
|
+
* target is untouched. `rename`, `platform` and `sleep` are injectable.
|
|
63
|
+
*/
|
|
64
|
+
export const renameDurable = async (
|
|
65
|
+
from,
|
|
66
|
+
to,
|
|
67
|
+
{
|
|
68
|
+
rename = fs.rename,
|
|
69
|
+
platform = process.platform,
|
|
70
|
+
sleep = defaultSleep,
|
|
71
|
+
attempts = 10,
|
|
72
|
+
delayMs = 20,
|
|
73
|
+
} = {}
|
|
74
|
+
) => {
|
|
75
|
+
const codes = platform === 'win32' ? WINDOWS_BUSY : new Set(['EBUSY']);
|
|
76
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
77
|
+
try {
|
|
78
|
+
return await rename(from, to);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (attempt >= attempts || !codes.has(error?.code)) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
await sleep(delayMs * attempt);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
48
88
|
/** Create `dir` (and parents) private to the user. */
|
|
49
89
|
export const ensurePrivateDir = (dir) =>
|
|
50
90
|
fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
|
|
@@ -74,7 +114,7 @@ export const tighten = async (target, mode = FILE_MODE) => {
|
|
|
74
114
|
export const writeFileDurable = async (
|
|
75
115
|
file,
|
|
76
116
|
data,
|
|
77
|
-
{ utime, tag = '' } = {}
|
|
117
|
+
{ utime, tag = '', rename } = {}
|
|
78
118
|
) => {
|
|
79
119
|
const dir = path.dirname(file);
|
|
80
120
|
await ensurePrivateDir(dir);
|
|
@@ -90,7 +130,7 @@ export const writeFileDurable = async (
|
|
|
90
130
|
} finally {
|
|
91
131
|
await handle.close();
|
|
92
132
|
}
|
|
93
|
-
await
|
|
133
|
+
await renameDurable(tmp, file, { rename });
|
|
94
134
|
} catch (error) {
|
|
95
135
|
// A full disk: the half-written temp file goes at once, not at the
|
|
96
136
|
// next start (R-Z14).
|
|
@@ -116,7 +156,7 @@ export const writeStreamDurable = async (file, ...streams) => {
|
|
|
116
156
|
createWriteStream(tmp, { flags: 'w', mode: FILE_MODE })
|
|
117
157
|
);
|
|
118
158
|
await fsyncFile(tmp);
|
|
119
|
-
await
|
|
159
|
+
await renameDurable(tmp, file);
|
|
120
160
|
} catch (error) {
|
|
121
161
|
await fs.unlink(tmp).catch(() => {});
|
|
122
162
|
throw error;
|
|
@@ -28,7 +28,13 @@ import { createInterface } from 'node:readline';
|
|
|
28
28
|
import { decodeLine, encodeLine } from 'lino-objects-codec';
|
|
29
29
|
import { createMemoryStore } from './universal.js';
|
|
30
30
|
import { parseLino } from './lino.js';
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
FILE_MODE,
|
|
33
|
+
fsyncDir,
|
|
34
|
+
fsyncFile,
|
|
35
|
+
renameDurable,
|
|
36
|
+
tighten,
|
|
37
|
+
} from './durable.js';
|
|
32
38
|
import { enoughSpace, skippedEvent } from './disk-space.js';
|
|
33
39
|
import { createWriteQueue, storeError, writeFailed } from './write-queue.js';
|
|
34
40
|
|
|
@@ -380,7 +386,7 @@ export const readLinoFile = async (filePath) => {
|
|
|
380
386
|
// Write every link to a temp file in chunks, flush it to disk, swap it
|
|
381
387
|
// in, and flush the directory: after a power cut the file is the old log
|
|
382
388
|
// or the whole new one, never an empty or half-written one (R-Z2).
|
|
383
|
-
const rewriteFile = async (filePath, links) => {
|
|
389
|
+
const rewriteFile = async (filePath, links, rename) => {
|
|
384
390
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
385
391
|
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
386
392
|
try {
|
|
@@ -393,7 +399,7 @@ const rewriteFile = async (filePath, links) => {
|
|
|
393
399
|
} finally {
|
|
394
400
|
await handle.close();
|
|
395
401
|
}
|
|
396
|
-
await
|
|
402
|
+
await renameDurable(tmp, filePath, { rename });
|
|
397
403
|
} catch (error) {
|
|
398
404
|
// The half-written copy goes at once: on a full disk it is what
|
|
399
405
|
// keeps the disk full (R-Z14).
|
|
@@ -461,6 +467,9 @@ const SYNC_MS = 200;
|
|
|
461
467
|
|
|
462
468
|
// A compaction skipped for lack of space is asked again after this long (R-Z14).
|
|
463
469
|
const COMPACT_RETRY_MS = 10 * 60_000;
|
|
470
|
+
// A compaction that failed (a busy file on Windows) leaves the old log intact
|
|
471
|
+
// and is asked again after this, not on every batch (R-Z19).
|
|
472
|
+
const COMPACT_FAILED_RETRY_MS = 30_000;
|
|
464
473
|
|
|
465
474
|
// Flushes appends to disk together, `syncMs` after the first (R-Z2).
|
|
466
475
|
const createSyncer = (filePath, syncMs, enqueue) => {
|
|
@@ -506,7 +515,15 @@ const compactionFailed = (onEvent) => (error) =>
|
|
|
506
515
|
// The log rewritten with one line per live link, after the unreadable
|
|
507
516
|
// lines were set aside (R-Z2); skipped when the disk has no room for the
|
|
508
517
|
// copy (R-Z14). `{ skipped }`.
|
|
509
|
-
const compactLog = async ({
|
|
518
|
+
const compactLog = async ({
|
|
519
|
+
filePath,
|
|
520
|
+
bad,
|
|
521
|
+
mem,
|
|
522
|
+
onCorrupt,
|
|
523
|
+
onEvent,
|
|
524
|
+
free,
|
|
525
|
+
rename,
|
|
526
|
+
}) => {
|
|
510
527
|
if (bad.overflow) {
|
|
511
528
|
return { skipped: false }; // more unreadable lines than can be kept
|
|
512
529
|
}
|
|
@@ -522,7 +539,7 @@ const compactLog = async ({ filePath, bad, mem, onCorrupt, onEvent, free }) => {
|
|
|
522
539
|
bad.lines = [];
|
|
523
540
|
bad.count = 0;
|
|
524
541
|
}
|
|
525
|
-
await rewriteFile(filePath, await mem.query());
|
|
542
|
+
await rewriteFile(filePath, await mem.query(), rename);
|
|
526
543
|
return { skipped: false };
|
|
527
544
|
};
|
|
528
545
|
|
|
@@ -588,6 +605,14 @@ const createHold = () => {
|
|
|
588
605
|
};
|
|
589
606
|
};
|
|
590
607
|
|
|
608
|
+
// A compaction skipped for lack of space, as an error for the write queue.
|
|
609
|
+
const throwingWhenSkipped = (compact) => async () => {
|
|
610
|
+
const done = await compact();
|
|
611
|
+
if (done?.skipped) {
|
|
612
|
+
throw storeError('ENOSPC', 'not enough free space to rewrite the log');
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
591
616
|
const readOnlyError = () =>
|
|
592
617
|
Object.assign(new Error('the store was opened read-only'), {
|
|
593
618
|
code: 'STORE_READ_ONLY',
|
|
@@ -607,6 +632,7 @@ export const createLinoTextStore = async (
|
|
|
607
632
|
// (tests inject an append handle that fails).
|
|
608
633
|
queue: queueOptions = {},
|
|
609
634
|
measureFree,
|
|
635
|
+
rename, // injectable fs.rename (R-Z19)
|
|
610
636
|
} = {}
|
|
611
637
|
) => {
|
|
612
638
|
const mem = createMemoryStore();
|
|
@@ -642,6 +668,7 @@ export const createLinoTextStore = async (
|
|
|
642
668
|
onCorrupt,
|
|
643
669
|
onEvent,
|
|
644
670
|
free: measureFree,
|
|
671
|
+
rename,
|
|
645
672
|
});
|
|
646
673
|
if (done.skipped) {
|
|
647
674
|
compactAfterAt = Date.now() + COMPACT_RETRY_MS;
|
|
@@ -676,16 +703,9 @@ export const createLinoTextStore = async (
|
|
|
676
703
|
attempt: () => (rewrite ? compactOrThrow() : wq.write()),
|
|
677
704
|
onRecovered: syncer.soon,
|
|
678
705
|
});
|
|
679
|
-
const compactOrThrow =
|
|
680
|
-
const done = await compact();
|
|
681
|
-
if (done?.skipped) {
|
|
682
|
-
throw storeError('ENOSPC', 'not enough free space to rewrite the log');
|
|
683
|
-
}
|
|
684
|
-
};
|
|
706
|
+
const compactOrThrow = throwingWhenSkipped(compact);
|
|
685
707
|
const hold = createHold();
|
|
686
708
|
const maintain = async () => {
|
|
687
|
-
// The batch is on disk by now; a failed compaction costs nothing but
|
|
688
|
-
// the rewrite, so it never fails the writes (R-Z14).
|
|
689
709
|
try {
|
|
690
710
|
if (
|
|
691
711
|
!hold.active() &&
|
|
@@ -695,6 +715,7 @@ export const createLinoTextStore = async (
|
|
|
695
715
|
await compact();
|
|
696
716
|
}
|
|
697
717
|
} catch (error) {
|
|
718
|
+
compactAfterAt = Date.now() + COMPACT_FAILED_RETRY_MS;
|
|
698
719
|
compactionFailed(onEvent)(error);
|
|
699
720
|
}
|
|
700
721
|
};
|
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",
|