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.
@@ -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
- return reply(res, 200, await importWhatsAppFile(store, body.name, bytes));
59
+ // Media files of a zip are kept next to the store (R-Z15).
60
+ const mediaDir = ctx?.storeDir ? path.join(ctx.storeDir, 'media') : null;
61
+ return reply(
62
+ res,
63
+ 200,
64
+ await importWhatsAppFile(store, body.name, bytes, { mediaDir })
65
+ );
57
66
  } catch (error) {
58
- return reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
67
+ // A full or unwritable disk is not a bad file (R-Z14).
68
+ return isFatalWrite(error)
69
+ ? reply(res, 507, {
70
+ error: safeMessage(error),
71
+ code: 'store-write-failed',
72
+ })
73
+ : reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
59
74
  }
60
75
  };
61
76
 
@@ -94,7 +109,7 @@ export const handleWhatsAppRoutes = async (store, req, res, p, ctx) => {
94
109
  });
95
110
  }
96
111
  if (isArchive) {
97
- return req.method === 'POST' ? archiveRoute(store, req, res) : false;
112
+ return req.method === 'POST' ? archiveRoute(store, req, res, ctx) : false;
98
113
  }
99
114
  const expected = route === 'web' ? 'GET' : 'POST';
100
115
  return req.method === expected
@@ -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 fs.rename(temp, path.join(dir, file));
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 fs.rename(temp, file);
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 fs.rename(temp, cacheFile);
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 fs.rename(part, target);
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: { type: 'media', name: null, file: null, omitted: true },
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: kindOf(name), name, file, omitted: false },
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 parsed
4
- * by ./whatsapp-archive.js; the zip's other entries are only used to map
5
- * `<attached: ...>` names to files inside the zip (the media bytes are
6
- * not copied into the store).
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 { readZipEntries } from './auto-import/archive.js';
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
- export const importWhatsAppFile = async (store, name, bytes) => {
23
- let archive = { text: bytes.toString('utf8'), fileName: name, files: [] };
24
- if (isZip(name, bytes)) {
25
- const entries = readZipEntries(bytes);
26
- const chat = textEntry(entries);
27
- if (!chat) {
28
- throw new Error('no chat .txt found in this zip');
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
- archive = {
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
- const links = await getSource('whatsapp').parseArchive(archive);
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 { imported: links.length, chat: links[0]?.chat ?? null };
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 fs.rename(tmp, file);
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 fs.rename(tmp, file);
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 { FILE_MODE, fsyncDir, fsyncFile, tighten } from './durable.js';
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 fs.rename(tmp, filePath);
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 ({ filePath, bad, mem, onCorrupt, onEvent, free }) => {
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 = async () => {
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
  };
@@ -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
+ );
@@ -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
- { key: r.emoticon, className: 'bubble-reaction' },
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) => ({ emoticon: r.emoji, count: r.count ?? 1 }));
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.8",
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",