atris 3.82.11 → 3.82.12
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/bin/atris.js +13 -0
- package/commands/memory-live.js +373 -0
- package/commands/memory.js +205 -0
- package/lib/known-commands.js +1 -1
- package/lib/memory-files.js +226 -0
- package/lib/memory-folder.js +602 -0
- package/lib/memory-live.js +356 -0
- package/lib/workspace-safety.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Real files in an Atris memory folder: photos, PDFs, recordings, anything
|
|
4
|
+
// under files/. The bytes go straight between this laptop and the private
|
|
5
|
+
// bucket, on short-lived links the Atris server signs for one file at a time.
|
|
6
|
+
// They never pass through the server, and no file is ever held whole in
|
|
7
|
+
// memory here: hashing, sending and fetching all stream in 1 MB pieces.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const { Transform, pipeline } = require('stream');
|
|
15
|
+
|
|
16
|
+
// Pages the server writes and reads as text, up to its 1 MB write limit.
|
|
17
|
+
// Everything else (any other type, or bigger) goes as a file.
|
|
18
|
+
const TEXT_ENDINGS = ['.md', '.txt', '.json', '.jsonl', '.csv', '.yaml', '.yml'];
|
|
19
|
+
const TEXT_MAX_BYTES = 1000000;
|
|
20
|
+
const LARGE_BYTES = 4 * 1024 * 1024; // files this big move one at a time, with a progress line
|
|
21
|
+
const PIECE = 1024 * 1024;
|
|
22
|
+
const IDLE_MS = 60000;
|
|
23
|
+
|
|
24
|
+
function goesAsFile(rel, size) {
|
|
25
|
+
const lower = String(rel).toLowerCase();
|
|
26
|
+
return !(Number(size) <= TEXT_MAX_BYTES) || !TEXT_ENDINGS.some((ending) => lower.endsWith(ending));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printOf(buffer) {
|
|
30
|
+
return {
|
|
31
|
+
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
|
32
|
+
md5: crypto.createHash('md5').update(buffer).digest('hex'),
|
|
33
|
+
bytes: buffer.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The fingerprints of a file on disk, read 1 MB at a time.
|
|
38
|
+
function hashFileSync(abs) {
|
|
39
|
+
const sha = crypto.createHash('sha256');
|
|
40
|
+
const md5 = crypto.createHash('md5');
|
|
41
|
+
const piece = Buffer.allocUnsafe(PIECE);
|
|
42
|
+
const fd = fs.openSync(abs, 'r');
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
try {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const n = fs.readSync(fd, piece, 0, PIECE, null);
|
|
47
|
+
if (!n) break;
|
|
48
|
+
sha.update(piece.subarray(0, n));
|
|
49
|
+
md5.update(piece.subarray(0, n));
|
|
50
|
+
bytes += n;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
fs.closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
return { sha256: sha.digest('hex'), md5: md5.digest('hex'), bytes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function size(n) {
|
|
59
|
+
const units = [['GB', 1024 ** 3], ['MB', 1024 ** 2], ['KB', 1024]];
|
|
60
|
+
for (const [unit, step] of units) {
|
|
61
|
+
if (n >= step) return `${(n / step).toFixed(n % step === 0 || n >= 10 * step ? 0 : 1)} ${unit}`;
|
|
62
|
+
}
|
|
63
|
+
return `${n} bytes`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Links come from the Atris server. Only https, or plain http to this machine (tests, a local server).
|
|
67
|
+
function transportFor(url) {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
if (parsed.protocol === 'https:') return { parsed, transport: https };
|
|
70
|
+
if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)) {
|
|
71
|
+
return { parsed, transport: http };
|
|
72
|
+
}
|
|
73
|
+
throw new Error('the Atris server sent a link that is not https');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What S3 said, without the link (it carries a signature).
|
|
77
|
+
function s3Said(status, body) {
|
|
78
|
+
const code = /<Code>([^<]{1,80})<\/Code>/.exec(body);
|
|
79
|
+
const message = /<Message>([^<]{1,200})<\/Message>/.exec(body);
|
|
80
|
+
if (code) return `storage refused it (${code[1]}${message ? `: ${message[1]}` : ''})`;
|
|
81
|
+
return `storage answered ${status}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readSmall(res, done) {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let kept = 0;
|
|
87
|
+
res.on('data', (chunk) => { if (kept < 16384) { chunks.push(chunk); kept += chunk.length; } });
|
|
88
|
+
res.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
|
|
89
|
+
res.on('error', () => done(''));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// POST one file to a signed S3 upload form: every field the server gave, the file last.
|
|
93
|
+
// Resolves with the fingerprints of exactly the bytes sent.
|
|
94
|
+
function sendFile({ url, fields, abs, bytes: expected, onProgress = null }) {
|
|
95
|
+
const { parsed, transport } = transportFor(url);
|
|
96
|
+
const boundary = `----atris-${crypto.randomBytes(16).toString('hex')}`;
|
|
97
|
+
let head = '';
|
|
98
|
+
for (const [name, value] of Object.entries(fields || {})) {
|
|
99
|
+
if (!/^[A-Za-z0-9_-]{1,100}$/.test(name)) throw new Error('the upload form has a field this tool does not send');
|
|
100
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
|
101
|
+
}
|
|
102
|
+
head += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="file"\r\n`
|
|
103
|
+
+ 'Content-Type: application/octet-stream\r\n\r\n';
|
|
104
|
+
const start = Buffer.from(head, 'utf8');
|
|
105
|
+
const end = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
let settled = false;
|
|
109
|
+
let sent = null;
|
|
110
|
+
const finish = (error, value) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
if (error) reject(error); else resolve(value);
|
|
114
|
+
};
|
|
115
|
+
const req = transport.request(parsed, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
119
|
+
'Content-Length': start.length + expected + end.length,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
req.setTimeout(IDLE_MS, () => req.destroy(new Error('the upload stalled for a minute')));
|
|
123
|
+
req.on('error', (error) => finish(error));
|
|
124
|
+
req.on('response', (res) => readSmall(res, (body) => {
|
|
125
|
+
if (res.statusCode >= 200 && res.statusCode < 300 && sent) finish(null, sent);
|
|
126
|
+
else finish(new Error(s3Said(res.statusCode, body)));
|
|
127
|
+
}));
|
|
128
|
+
const drained = () => new Promise((go) => {
|
|
129
|
+
const on = () => { req.off('drain', on); req.off('close', on); go(); };
|
|
130
|
+
req.on('drain', on);
|
|
131
|
+
req.on('close', on);
|
|
132
|
+
});
|
|
133
|
+
(async () => {
|
|
134
|
+
const sha = crypto.createHash('sha256');
|
|
135
|
+
const md5 = crypto.createHash('md5');
|
|
136
|
+
let count = 0;
|
|
137
|
+
const stream = fs.createReadStream(abs, { highWaterMark: PIECE });
|
|
138
|
+
req.write(start);
|
|
139
|
+
try {
|
|
140
|
+
for await (const chunk of stream) {
|
|
141
|
+
if (settled || req.destroyed) return;
|
|
142
|
+
count += chunk.length;
|
|
143
|
+
if (count > expected) throw new Error('the file changed while it was being sent');
|
|
144
|
+
sha.update(chunk);
|
|
145
|
+
md5.update(chunk);
|
|
146
|
+
if (onProgress) onProgress(count, expected);
|
|
147
|
+
if (!req.write(chunk)) await drained();
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
stream.destroy();
|
|
151
|
+
}
|
|
152
|
+
if (count !== expected) throw new Error('the file changed while it was being sent');
|
|
153
|
+
sent = { sha256: sha.digest('hex'), md5: md5.digest('hex'), bytes: count };
|
|
154
|
+
req.end(end);
|
|
155
|
+
})().catch((error) => req.destroy(error));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// GET a signed download link into `temp`, streaming. Resolves with the fingerprints of what arrived.
|
|
160
|
+
function fetchFile({ url, temp, bytes: expected = 0, onProgress = null }) {
|
|
161
|
+
const { parsed, transport } = transportFor(url);
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const req = transport.get(parsed, (res) => {
|
|
164
|
+
if (res.statusCode !== 200) {
|
|
165
|
+
readSmall(res, (body) => reject(new Error(s3Said(res.statusCode, body))));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const sha = crypto.createHash('sha256');
|
|
169
|
+
const md5 = crypto.createHash('md5');
|
|
170
|
+
let count = 0;
|
|
171
|
+
const tap = new Transform({
|
|
172
|
+
transform(chunk, _encoding, next) {
|
|
173
|
+
count += chunk.length;
|
|
174
|
+
sha.update(chunk);
|
|
175
|
+
md5.update(chunk);
|
|
176
|
+
if (onProgress) onProgress(count, expected);
|
|
177
|
+
next(null, chunk);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
pipeline(res, tap, fs.createWriteStream(temp, { flags: 'wx' }), (error) => {
|
|
181
|
+
if (error) reject(error);
|
|
182
|
+
else resolve({ sha256: sha.digest('hex'), md5: md5.digest('hex'), bytes: count });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
req.setTimeout(IDLE_MS, () => req.destroy(new Error('the download stalled for a minute')));
|
|
186
|
+
req.on('error', reject);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A hidden name beside the target, so a half-fetched file is never mistaken for the real one.
|
|
191
|
+
function tempBeside(abs) {
|
|
192
|
+
return path.join(path.dirname(abs), `.${path.basename(abs)}.atris-tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One line per large file. On a terminal it updates in place with a percent;
|
|
196
|
+
// otherwise it is printed once, when the file starts moving.
|
|
197
|
+
function progressPrinter({ log, tty = null }) {
|
|
198
|
+
const shown = new Map();
|
|
199
|
+
return ({ rel, verb, done, total, finished = false }) => {
|
|
200
|
+
if (total < LARGE_BYTES) return;
|
|
201
|
+
const label = `${verb} ${rel} (${size(total)})`;
|
|
202
|
+
if (!tty) {
|
|
203
|
+
if (!shown.has(rel)) { shown.set(rel, true); log(`${label}...`); }
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (finished) {
|
|
207
|
+
if (shown.has(rel)) tty.write('\n');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const percent = Math.floor((done * 100) / Math.max(1, total));
|
|
211
|
+
if (shown.get(rel) === percent) return;
|
|
212
|
+
shown.set(rel, percent);
|
|
213
|
+
tty.write(`\r${label} ${percent}%`);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
LARGE_BYTES,
|
|
219
|
+
fetchFile,
|
|
220
|
+
goesAsFile,
|
|
221
|
+
hashFileSync,
|
|
222
|
+
printOf,
|
|
223
|
+
progressPrinter,
|
|
224
|
+
sendFile,
|
|
225
|
+
tempBeside,
|
|
226
|
+
};
|