telstore 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/telark.js +121 -0
- package/package.json +42 -0
- package/src/caption.js +63 -0
- package/src/chat.js +42 -0
- package/src/chunking.js +92 -0
- package/src/cli.js +161 -0
- package/src/client.js +146 -0
- package/src/commands/config.js +130 -0
- package/src/commands/delete.js +250 -0
- package/src/commands/list.js +119 -0
- package/src/commands/login.js +109 -0
- package/src/commands/logout.js +13 -0
- package/src/commands/restore.js +215 -0
- package/src/commands/status.js +102 -0
- package/src/commands/upload.js +327 -0
- package/src/config.js +86 -0
- package/src/confirm.js +11 -0
- package/src/downloader.js +190 -0
- package/src/manifest.js +163 -0
- package/src/progress.js +100 -0
- package/src/retry.js +57 -0
- package/src/settings.js +190 -0
- package/src/stall.js +63 -0
- package/src/state.js +151 -0
- package/src/uploader.js +155 -0
package/src/manifest.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { countChunks } from './chunking.js'
|
|
4
|
+
|
|
5
|
+
export const MANIFEST_VERSION = 1
|
|
6
|
+
|
|
7
|
+
export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).toString('hex')) {
|
|
8
|
+
const yyyy = now.getUTCFullYear()
|
|
9
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
|
|
10
|
+
const dd = String(now.getUTCDate()).padStart(2, '0')
|
|
11
|
+
return `telark-${yyyy}${mm}${dd}-${randomHex()}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function chunkFileName(id, i) {
|
|
15
|
+
return `${id}.part${String(i + 1).padStart(4, '0')}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function manifestFileName(id) {
|
|
19
|
+
return `${id}.manifest.json`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = new Date().toISOString() }) {
|
|
23
|
+
return {
|
|
24
|
+
v: MANIFEST_VERSION,
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
size,
|
|
28
|
+
chunkSize,
|
|
29
|
+
createdAt,
|
|
30
|
+
chunks: [...chunks]
|
|
31
|
+
.sort((a, b) => a.i - b.i)
|
|
32
|
+
.map(({ i, msgId, size: chunkBytes, sha256 }) => ({ i, msgId, size: chunkBytes, sha256 })),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function serializeManifest(manifest) {
|
|
37
|
+
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// parseManifest's own front door, on its own so delete can read a manifest body without
|
|
41
|
+
// the layout checks behind it.
|
|
42
|
+
export function parseManifestJson(input) {
|
|
43
|
+
const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text)
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error('Cannot read manifest: content is not valid JSON.')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Delete needs one thing from a manifest that restore does not, and none of the things
|
|
53
|
+
// restore needs. parseManifest is the wrong gate for it: it validates the chunk *layout*,
|
|
54
|
+
// because restore writes bytes at offsets computed from it — and a manifest that fails
|
|
55
|
+
// those checks is exactly the broken backup somebody is trying to delete, so refusing to
|
|
56
|
+
// read it here would leave the only way out through the Telegram app. It also never looks
|
|
57
|
+
// at msgId, which is the only field delete actually uses.
|
|
58
|
+
//
|
|
59
|
+
// Every id is checked before a single message is removed. A msgId is handed to Telegram as
|
|
60
|
+
// the name of something to destroy for good, and that is the one number nobody may guess
|
|
61
|
+
// at — so a manifest that cannot say it exactly is refused whole, rather than half-deleted
|
|
62
|
+
// and then left without the list that names the rest.
|
|
63
|
+
export function manifestMessageIds(manifest) {
|
|
64
|
+
if (!Array.isArray(manifest?.chunks) || manifest.chunks.length === 0) {
|
|
65
|
+
throw new Error('Manifest has no chunk list, so it cannot say which messages to remove.')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return manifest.chunks.map((chunk, index) => {
|
|
69
|
+
const msgId = chunk?.msgId
|
|
70
|
+
|
|
71
|
+
if (!Number.isSafeInteger(msgId) || msgId < 1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Manifest gives ${JSON.stringify(msgId)} as the message id of chunk ${index + 1}, ` +
|
|
74
|
+
'which is not a message id. Deleting from this manifest could remove the wrong ' +
|
|
75
|
+
'messages, so telark is not deleting anything.',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return msgId
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseManifest(input) {
|
|
84
|
+
const manifest = parseManifestJson(input)
|
|
85
|
+
|
|
86
|
+
if (manifest.v !== MANIFEST_VERSION) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Manifest uses version ${manifest.v}, this build of telark only understands version ${MANIFEST_VERSION}.`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
|
|
93
|
+
throw new Error('Manifest has no chunk list.')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The manifest comes off a chat, so nothing in it is trusted. The checks below are
|
|
97
|
+
// arithmetic on these numbers, and arithmetic on a string or a null does not fail — it
|
|
98
|
+
// produces a comparison that rejects the manifest for the wrong reason. A string size used
|
|
99
|
+
// to be reported as "add up to 100, but the manifest records a file size of 100", which
|
|
100
|
+
// sends the reader hunting for a difference that is not there.
|
|
101
|
+
if (!Number.isSafeInteger(manifest.size) || manifest.size < 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Manifest records a file size of ${JSON.stringify(manifest.size)}, ` +
|
|
104
|
+
'which is not a whole number of bytes.',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!Number.isSafeInteger(manifest.chunkSize) || manifest.chunkSize < 1) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Manifest records a chunk size of ${JSON.stringify(manifest.chunkSize)}, ` +
|
|
111
|
+
'which is not a whole number of bytes above zero.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
manifest.chunks.forEach((chunk, index) => {
|
|
116
|
+
if (typeof chunk !== 'object' || chunk === null) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Manifest entry for chunk ${index + 1} is not an object: ${JSON.stringify(chunk)}.`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!Number.isSafeInteger(chunk.size) || chunk.size < 0) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Manifest records ${JSON.stringify(chunk.size)} bytes for chunk ${index + 1}, ` +
|
|
125
|
+
'which is not a whole number of bytes.',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (chunk.i !== index) {
|
|
130
|
+
throw new Error(`Manifest is missing chunk ${index}: the chunk list is not contiguous.`)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const expectedChunks = countChunks(manifest.size, manifest.chunkSize)
|
|
135
|
+
if (manifest.chunks.length !== expectedChunks) {
|
|
136
|
+
throw new Error(`Manifest is missing ${expectedChunks - manifest.chunks.length} chunk(s).`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const total = manifest.chunks.reduce((sum, chunk) => sum + chunk.size, 0)
|
|
140
|
+
if (total !== manifest.size) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Chunk sizes add up to ${total}, but the manifest records a file size of ${manifest.size}.`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Restore writes chunk i at exactly offset i * chunkSize, so the layout must be
|
|
147
|
+
// uniform: every chunk is chunkSize, except the last one which is the remainder.
|
|
148
|
+
// A correct total with individually wrong sizes yields a file with a hole or
|
|
149
|
+
// extra length while every per-chunk sha256 still matches — silently wrong data,
|
|
150
|
+
// precisely what telark must never produce.
|
|
151
|
+
manifest.chunks.forEach((chunk, index) => {
|
|
152
|
+
const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
|
|
153
|
+
if (chunk.size !== expected) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Manifest records ${chunk.size} bytes for chunk ${index + 1}, but a layout of ` +
|
|
156
|
+
`${manifest.chunkSize} bytes per chunk requires ${expected} bytes. ` +
|
|
157
|
+
'This manifest describes the wrong chunk positions; restoring it would produce a corrupt file.',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
return manifest
|
|
163
|
+
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
2
|
+
|
|
3
|
+
export function formatBytes(n) {
|
|
4
|
+
if (n < 1024) return `${n} B`
|
|
5
|
+
|
|
6
|
+
let value = n
|
|
7
|
+
let unit = 0
|
|
8
|
+
|
|
9
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
10
|
+
value /= 1024
|
|
11
|
+
unit += 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return `${value.toFixed(1)} ${UNITS[unit]}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatDuration(seconds) {
|
|
18
|
+
if (!Number.isFinite(seconds)) return '--'
|
|
19
|
+
|
|
20
|
+
const total = Math.round(seconds)
|
|
21
|
+
const h = Math.floor(total / 3600)
|
|
22
|
+
const m = Math.floor((total % 3600) / 60)
|
|
23
|
+
const s = total % 60
|
|
24
|
+
|
|
25
|
+
if (h > 0) return `${h}h${m}m`
|
|
26
|
+
if (m > 0) return `${m}m${s}s`
|
|
27
|
+
return `${s}s`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderProgress({ done, total, elapsedMs, label, width = 24, transferred = done }) {
|
|
31
|
+
const ratio = total === 0 ? 1 : Math.min(done / total, 1)
|
|
32
|
+
const filled = Math.round(ratio * width)
|
|
33
|
+
const bar = `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
|
|
34
|
+
|
|
35
|
+
// Speed measures what this run moved, not what the file already has. A resumed upload
|
|
36
|
+
// starts with gigabytes behind it, and dividing those by a two-second-old run reports a
|
|
37
|
+
// fictional 3 GB/s and an ETA of almost nothing. The bar and the byte counts still speak
|
|
38
|
+
// for the whole file, because that is the question being asked.
|
|
39
|
+
const bytesPerSecond = elapsedMs > 0 ? transferred / (elapsedMs / 1000) : 0
|
|
40
|
+
// Clamp to 0: done can overshoot total (one extra tick) and a negative ETA is meaningless.
|
|
41
|
+
const remaining = bytesPerSecond > 0 ? Math.max(0, (total - done) / bytesPerSecond) : Infinity
|
|
42
|
+
|
|
43
|
+
const percent = String(Math.floor(ratio * 100)).padStart(3)
|
|
44
|
+
const speed = `${formatBytes(Math.round(bytesPerSecond))}/s`
|
|
45
|
+
|
|
46
|
+
return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createProgress({
|
|
50
|
+
total,
|
|
51
|
+
label,
|
|
52
|
+
// Bytes a previous run already sent. They belong on the bar — the question is how much of
|
|
53
|
+
// the file is done, not how much of today's session — but not in the speed.
|
|
54
|
+
done: startedWith = 0,
|
|
55
|
+
write = (line) => process.stderr.write(line),
|
|
56
|
+
now = () => Date.now(),
|
|
57
|
+
minIntervalMs = 200,
|
|
58
|
+
}) {
|
|
59
|
+
const startedAt = now()
|
|
60
|
+
let done = startedWith
|
|
61
|
+
let currentLabel = label
|
|
62
|
+
let lastDrawnAt = startedAt
|
|
63
|
+
let widestLine = 0
|
|
64
|
+
|
|
65
|
+
// \r only moves the cursor home, it does not erase. A redraw that is shorter than the one
|
|
66
|
+
// before it (a shrinking ETA, a speed that changes unit) would leave the previous tail on
|
|
67
|
+
// screen, so pad every line out to the widest one drawn so far.
|
|
68
|
+
function draw(suffix) {
|
|
69
|
+
const line = renderProgress({
|
|
70
|
+
done,
|
|
71
|
+
total,
|
|
72
|
+
elapsedMs: now() - startedAt,
|
|
73
|
+
label: currentLabel,
|
|
74
|
+
transferred: done - startedWith,
|
|
75
|
+
})
|
|
76
|
+
widestLine = Math.max(widestLine, line.length)
|
|
77
|
+
write(`\r${line.padEnd(widestLine)}${suffix}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
advance(bytes) {
|
|
82
|
+
done += bytes
|
|
83
|
+
if (now() - lastDrawnAt < minIntervalMs) return
|
|
84
|
+
lastDrawnAt = now()
|
|
85
|
+
draw('')
|
|
86
|
+
},
|
|
87
|
+
// One bar spans the whole transfer while the label names the chunk in flight, so the
|
|
88
|
+
// label changes mid-line and has to be drawn at once: throttled, it would keep showing
|
|
89
|
+
// the previous chunk's number for the first 200ms of the new one. It counts as a draw,
|
|
90
|
+
// because the redraw a moment later would say the same thing.
|
|
91
|
+
setLabel(next) {
|
|
92
|
+
currentLabel = next
|
|
93
|
+
lastDrawnAt = now()
|
|
94
|
+
draw('')
|
|
95
|
+
},
|
|
96
|
+
finish() {
|
|
97
|
+
draw('\n')
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/retry.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const DEFAULT_ATTEMPTS = 8
|
|
2
|
+
const DEFAULT_BASE_DELAY_MS = 1000
|
|
3
|
+
|
|
4
|
+
// Past half a minute the doubling stops buying anything: the wait is already long enough
|
|
5
|
+
// that the far side has either recovered or is not coming back on this attempt. Left
|
|
6
|
+
// uncapped, eight attempts would end in a two-minute stare at a frozen bar.
|
|
7
|
+
const MAX_BACKOFF_MS = 30_000
|
|
8
|
+
|
|
9
|
+
function defaultSleep(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function floodWaitSeconds(err) {
|
|
14
|
+
if (typeof err?.seconds === 'number' && String(err?.errorMessage ?? '').includes('FLOOD_WAIT')) {
|
|
15
|
+
return err.seconds
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withRetry(fn, options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
attempts = DEFAULT_ATTEMPTS,
|
|
23
|
+
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
24
|
+
sleep = defaultSleep,
|
|
25
|
+
now = Date.now,
|
|
26
|
+
onRetry,
|
|
27
|
+
} = options
|
|
28
|
+
|
|
29
|
+
let lastError
|
|
30
|
+
|
|
31
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
32
|
+
const startedAt = now()
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return await fn()
|
|
36
|
+
} catch (err) {
|
|
37
|
+
lastError = err
|
|
38
|
+
|
|
39
|
+
if (attempt === attempts) break
|
|
40
|
+
|
|
41
|
+
const flood = floodWaitSeconds(err)
|
|
42
|
+
const delayMs =
|
|
43
|
+
flood === null
|
|
44
|
+
? Math.min(baseDelayMs * 2 ** (attempt - 1), MAX_BACKOFF_MS)
|
|
45
|
+
: flood * 1000
|
|
46
|
+
|
|
47
|
+
// How long the failed attempt itself took. A request that errors instantly costs the
|
|
48
|
+
// user nothing but a line of output; one that took a minute to give up left the
|
|
49
|
+
// progress bar frozen for that minute. Only the caller can weigh the two, so it is
|
|
50
|
+
// told which kind this was.
|
|
51
|
+
onRetry?.(err, attempt, delayMs, now() - startedAt)
|
|
52
|
+
await sleep(delayMs)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw lastError
|
|
57
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { normalizeChatTarget } from './chat.js'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_CHUNK_SIZE,
|
|
4
|
+
DEFAULT_CONCURRENCY,
|
|
5
|
+
MAX_CONCURRENCY,
|
|
6
|
+
parseSize,
|
|
7
|
+
} from './chunking.js'
|
|
8
|
+
import { formatBytes } from './progress.js'
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_LIMIT = 20
|
|
11
|
+
|
|
12
|
+
// The three keys telark writes for itself. Naming them separately is what lets the
|
|
13
|
+
// unknown-key error say "managed by login" instead of listing a session as something the
|
|
14
|
+
// user forgot to spell correctly.
|
|
15
|
+
const MANAGED_BY_LOGIN = new Set(['session', 'apiId', 'apiHash'])
|
|
16
|
+
|
|
17
|
+
function wholeNumber(raw, where, minimum, maximum, explanation) {
|
|
18
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') {
|
|
19
|
+
throw new Error(`Invalid ${where}: ${JSON.stringify(raw)}. ${explanation}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const value = Number(String(raw).trim())
|
|
23
|
+
|
|
24
|
+
if (!Number.isInteger(value) || value < minimum || (maximum !== null && value > maximum)) {
|
|
25
|
+
throw new Error(`Invalid ${where}: "${raw}". ${explanation}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One entry per setting: the flag that overrides it for a run, the built-in default, the
|
|
32
|
+
// parser that both the flag and the stored value go through, and how the value is written
|
|
33
|
+
// back out. `format` must round-trip — whatever it prints has to parse to the same value,
|
|
34
|
+
// which is why chunkSize prints bytes and leaves "1.8 GB" to `describe`.
|
|
35
|
+
export const SETTINGS = {
|
|
36
|
+
chat: {
|
|
37
|
+
flag: 'to',
|
|
38
|
+
default: null,
|
|
39
|
+
// A number here is a chat id, and a chat id is a whole number. 42.5 would otherwise
|
|
40
|
+
// slip through as the string "42.5" and only fail much later, at Telegram, as a chat
|
|
41
|
+
// that does not exist.
|
|
42
|
+
parse(raw, where) {
|
|
43
|
+
const usable = typeof raw === 'string' || (typeof raw === 'number' && Number.isInteger(raw))
|
|
44
|
+
|
|
45
|
+
if (!usable) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid ${where}: ${JSON.stringify(raw)}. A destination is @username, -100123..., or me.`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return normalizeChatTarget(raw)
|
|
52
|
+
},
|
|
53
|
+
format: (value) => String(value),
|
|
54
|
+
},
|
|
55
|
+
chunkSize: {
|
|
56
|
+
flag: 'chunk-size',
|
|
57
|
+
default: DEFAULT_CHUNK_SIZE,
|
|
58
|
+
parse(raw, where) {
|
|
59
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Invalid ${where}: ${JSON.stringify(raw)}. Valid examples: 1800MB, 1.8GB, 524288.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return parseSize(raw)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(`Invalid ${where}: "${raw}". ${err.message}`)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
// Bytes, not "1.8 GB": formatBytes rounds to one decimal, so the pretty form parses
|
|
72
|
+
// back to a different size and `config chunkSize` would print a value that, typed in
|
|
73
|
+
// again, cuts the file differently.
|
|
74
|
+
format: (value) => String(value),
|
|
75
|
+
describe: (value) => formatBytes(value),
|
|
76
|
+
},
|
|
77
|
+
concurrency: {
|
|
78
|
+
flag: 'concurrency',
|
|
79
|
+
default: DEFAULT_CONCURRENCY,
|
|
80
|
+
parse: (raw, where) =>
|
|
81
|
+
wholeNumber(
|
|
82
|
+
raw,
|
|
83
|
+
where,
|
|
84
|
+
1,
|
|
85
|
+
MAX_CONCURRENCY,
|
|
86
|
+
`Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot holds a 512KB part in RAM ` +
|
|
87
|
+
'and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
|
|
88
|
+
),
|
|
89
|
+
format: (value) => String(value),
|
|
90
|
+
},
|
|
91
|
+
limit: {
|
|
92
|
+
flag: 'limit',
|
|
93
|
+
default: DEFAULT_LIMIT,
|
|
94
|
+
parse: (raw, where) =>
|
|
95
|
+
wholeNumber(raw, where, 1, null, 'Must be a whole number of backups, 1 or more.'),
|
|
96
|
+
format: (value) => String(value),
|
|
97
|
+
},
|
|
98
|
+
verbose: {
|
|
99
|
+
flag: 'verbose',
|
|
100
|
+
default: false,
|
|
101
|
+
parse(raw, where) {
|
|
102
|
+
if (typeof raw === 'boolean') return raw
|
|
103
|
+
|
|
104
|
+
const text = String(raw).trim().toLowerCase()
|
|
105
|
+
|
|
106
|
+
if (text === 'true') return true
|
|
107
|
+
if (text === 'false') return false
|
|
108
|
+
|
|
109
|
+
throw new Error(`Invalid ${where}: ${JSON.stringify(raw)}. Must be true or false.`)
|
|
110
|
+
},
|
|
111
|
+
format: (value) => String(value),
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const SETTING_KEYS = Object.keys(SETTINGS)
|
|
116
|
+
|
|
117
|
+
// Someone who has been typing `--to` and `--chunk-size` for a week will type them at the
|
|
118
|
+
// config command too. Accept the flag spelling as a way in, and canonicalise on the way to
|
|
119
|
+
// disk so the file only ever holds one name per setting.
|
|
120
|
+
const ALIASES = new Map()
|
|
121
|
+
|
|
122
|
+
for (const [key, spec] of Object.entries(SETTINGS)) {
|
|
123
|
+
ALIASES.set(key.toLowerCase(), key)
|
|
124
|
+
ALIASES.set(spec.flag.toLowerCase(), key)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function canonicalKey(input) {
|
|
128
|
+
return ALIASES.get(String(input).trim().toLowerCase()) ?? null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isManagedByLogin(input) {
|
|
132
|
+
return MANAGED_BY_LOGIN.has(String(input).trim())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Where a value came from, spelled the way the user would recognise it. A stored value that
|
|
136
|
+
// fails to parse must not be reported as a bad flag: nobody typed a flag, and telling them
|
|
137
|
+
// to fix one sends them off after the wrong thing — the same mistake the old
|
|
138
|
+
// "run again without --to" advice made.
|
|
139
|
+
function origin(key, from, file) {
|
|
140
|
+
return from === 'flag' ? `--${SETTINGS[key].flag}` : `${key} in ${file}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Precedence is flag, then the stored setting, then the built-in default. `source` is not
|
|
144
|
+
// decoration: an unfinished upload has to tell "you asked for this size" from "this is
|
|
145
|
+
// merely your default", and only the source separates them.
|
|
146
|
+
export function resolveSettings(options = {}, config = {}, { file = 'the config file' } = {}) {
|
|
147
|
+
const values = {}
|
|
148
|
+
const sources = {}
|
|
149
|
+
const stored = config.settings ?? {}
|
|
150
|
+
|
|
151
|
+
for (const [key, spec] of Object.entries(SETTINGS)) {
|
|
152
|
+
const flagValue = options[spec.flag]
|
|
153
|
+
|
|
154
|
+
if (flagValue !== undefined) {
|
|
155
|
+
values[key] = spec.parse(flagValue, origin(key, 'flag', file))
|
|
156
|
+
sources[key] = 'flag'
|
|
157
|
+
} else if (stored[key] !== undefined) {
|
|
158
|
+
values[key] = spec.parse(stored[key], origin(key, 'settings', file))
|
|
159
|
+
sources[key] = 'settings'
|
|
160
|
+
} else {
|
|
161
|
+
values[key] = spec.default
|
|
162
|
+
sources[key] = 'default'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
values,
|
|
168
|
+
// Throws rather than returning undefined for a typo: a misspelt key at the chunk-size
|
|
169
|
+
// call site would turn "refuse to resume at a different size" into "resume at a
|
|
170
|
+
// different size", silently, which is the one thing this project must never do.
|
|
171
|
+
source(key) {
|
|
172
|
+
if (!(key in sources)) {
|
|
173
|
+
throw new Error(`Unknown setting: ${key}. Known settings: ${SETTING_KEYS.join(', ')}.`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sources[key]
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function requireChat(values) {
|
|
182
|
+
if (values.chat === null || values.chat === undefined) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
'No destination set — run "npx telark config chat @my_backups" to set one ' +
|
|
185
|
+
'("config chat me" for Saved Messages), or pass --to to choose one for this run.',
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return values.chat
|
|
190
|
+
}
|
package/src/stall.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Sixty seconds of silence on one 512KB part is a dead connection, not a slow one: a link
|
|
2
|
+
// that cannot deliver 8KB/s could not finish a multi-gigabyte restore anyway.
|
|
3
|
+
export const DEFAULT_STALL_MS = 60_000
|
|
4
|
+
|
|
5
|
+
// setTimeout stores its delay in a 32-bit signed integer. Node's answer to a larger one is
|
|
6
|
+
// to warn and fire after a single tick, so a deadline meant to be more patient becomes the
|
|
7
|
+
// least patient one there is.
|
|
8
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
9
|
+
|
|
10
|
+
// A request that never comes back is not the same as one that fails, and only one of the
|
|
11
|
+
// two is something withRetry can do anything about.
|
|
12
|
+
//
|
|
13
|
+
// GramJS can leave a request queued on a sender it has quietly given up on. Its own abort
|
|
14
|
+
// path is unreachable: MTProtoSender rejects pending states only when
|
|
15
|
+
// `_currentRetries > _reconnectRetries`, and `reconnectRetries` has no default to compare
|
|
16
|
+
// against, so the test is never true (network/MTProtoSender.js:376). Nor does the reconnect
|
|
17
|
+
// itself report failure — `connect()` exhausts its attempts and returns false rather than
|
|
18
|
+
// throwing, so `_reconnect()` finishes as if it had worked and puts the request back on a
|
|
19
|
+
// queue with no send loop left to drain it (network/MTProtoSender.js:148,795).
|
|
20
|
+
//
|
|
21
|
+
// The promise then simply sits there. Nothing throws, nothing prints, and once no handle is
|
|
22
|
+
// left the process ends mid-transfer without a word — the one outcome this project forbids.
|
|
23
|
+
export async function withStallTimeout(promise, ms, describe) {
|
|
24
|
+
if (!(ms > 0)) return await promise
|
|
25
|
+
|
|
26
|
+
if (ms > MAX_TIMER_MS) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`A stall deadline of ${ms}ms is out of range: a timer holds at most ${MAX_TIMER_MS}ms, ` +
|
|
29
|
+
'and a larger one fires after a single tick rather than waiting.',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let timer
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return await Promise.race([
|
|
37
|
+
promise,
|
|
38
|
+
new Promise((_, reject) => {
|
|
39
|
+
// Deliberately not unref'd. This timer is the only thing keeping the event loop
|
|
40
|
+
// alive while a request is outstanding, so a stall ends in this rejection instead
|
|
41
|
+
// of in Node running out of work and exiting silently.
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
// A throw inside a timer callback is an uncaught exception, which ends the process
|
|
44
|
+
// rather than the request. Building the message must never be able to do that.
|
|
45
|
+
let message
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
message = describe()
|
|
49
|
+
} catch (err) {
|
|
50
|
+
message = `A network wait went past ${ms}ms, and describing it failed: ${err.message}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reject(new Error(message))
|
|
54
|
+
}, ms)
|
|
55
|
+
}),
|
|
56
|
+
])
|
|
57
|
+
} finally {
|
|
58
|
+
// Promise.race has already attached a handler to `promise`, so a rejection arriving
|
|
59
|
+
// after we have stopped waiting is still handled and cannot surface as an
|
|
60
|
+
// unhandledRejection that hides the stall.
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
}
|
|
63
|
+
}
|