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/state.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { defaultConfigDir, writeJsonAtomic } from './config.js'
|
|
6
|
+
|
|
7
|
+
export function stateDir(configDir = defaultConfigDir()) {
|
|
8
|
+
return path.join(configDir, 'state')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stateKey(absPath, size, mtimeMs) {
|
|
12
|
+
return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stateFile(key, configDir = defaultConfigDir()) {
|
|
16
|
+
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadState(key, configDir = defaultConfigDir()) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await fs.readFile(stateFile(key, configDir), 'utf8'))
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.code === 'ENOENT') return null
|
|
24
|
+
if (err instanceof SyntaxError) return null
|
|
25
|
+
throw err
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function saveState(key, state, configDir = defaultConfigDir()) {
|
|
30
|
+
await writeJsonAtomic(stateFile(key, configDir), state)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function markChunkDone(key, state, i, entry, configDir = defaultConfigDir()) {
|
|
34
|
+
const updated = { ...state, done: { ...state.done, [String(i)]: entry } }
|
|
35
|
+
await saveState(key, updated, configDir)
|
|
36
|
+
return updated
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function clearState(key, configDir = defaultConfigDir()) {
|
|
40
|
+
try {
|
|
41
|
+
await fs.unlink(stateFile(key, configDir))
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== 'ENOENT') throw err
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A state file is only useful while its backup can still be resumed, and nothing ever
|
|
48
|
+
// removes one whose file was edited since: the key includes mtime, so that state can never
|
|
49
|
+
// match again. Left alone the directory only grows, and with it the report `status` prints.
|
|
50
|
+
export const MAX_STATES = 20
|
|
51
|
+
|
|
52
|
+
// The newest states are the ones worth keeping, and a state file is rewritten every time a
|
|
53
|
+
// chunk lands, so its mtime is when this backup last made progress. Returns the states that
|
|
54
|
+
// were dropped: the caller says their ids out loud, because after this the id is the only
|
|
55
|
+
// way left to find those chunks in the chat.
|
|
56
|
+
export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
|
|
57
|
+
let names
|
|
58
|
+
try {
|
|
59
|
+
names = await fs.readdir(stateDir(configDir))
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.code === 'ENOENT') return []
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const files = []
|
|
66
|
+
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith('.json')) continue
|
|
69
|
+
|
|
70
|
+
const file = path.join(stateDir(configDir), name)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(file)
|
|
74
|
+
files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Gone between readdir and stat: nothing left to prune.
|
|
77
|
+
if (err.code !== 'ENOENT') throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
82
|
+
|
|
83
|
+
const dropped = []
|
|
84
|
+
|
|
85
|
+
for (const { key, file } of files.slice(keep)) {
|
|
86
|
+
// Read before unlink: a file that cannot be read back, or that carries no id, is
|
|
87
|
+
// still pruned — it just cannot be named, and a report naming nothing helps no one.
|
|
88
|
+
const state = await loadState(key, configDir)
|
|
89
|
+
await fs.unlink(file)
|
|
90
|
+
if (state?.id) dropped.push(state)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return dropped
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// status needs every unfinished backup at once. A state file that cannot be read is skipped
|
|
97
|
+
// rather than fatal, for the same reason loadState returns null: one corrupt file must not
|
|
98
|
+
// hide the other backups still waiting to be finished.
|
|
99
|
+
export async function listStates(configDir = defaultConfigDir()) {
|
|
100
|
+
let names
|
|
101
|
+
try {
|
|
102
|
+
names = await fs.readdir(stateDir(configDir))
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code === 'ENOENT') return []
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const states = []
|
|
109
|
+
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!name.endsWith('.json')) continue
|
|
112
|
+
|
|
113
|
+
const state = await loadState(name.slice(0, -'.json'.length), configDir)
|
|
114
|
+
if (state) states.push(state)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return states
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// delete needs the file a record came from, not just its contents — and the name of that
|
|
121
|
+
// file is a hash of the path, size and mtime *inside* the record, so recomputing it would
|
|
122
|
+
// be trusting an untrusted file to say where it lives. A hand-edited path yields a key that
|
|
123
|
+
// names no file at all, clearState ignores a file that is not there, and telark reports a
|
|
124
|
+
// record dropped that is still sitting on disk. Matching the id inside each file is the one
|
|
125
|
+
// way that cannot point at the wrong one.
|
|
126
|
+
//
|
|
127
|
+
// Every record claiming the id is returned rather than the first: two of them means telark
|
|
128
|
+
// cannot know which to drop, and that is the caller's decision to refuse, not ours to make
|
|
129
|
+
// by picking one.
|
|
130
|
+
export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
131
|
+
let names
|
|
132
|
+
try {
|
|
133
|
+
names = await fs.readdir(stateDir(configDir))
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err.code === 'ENOENT') return []
|
|
136
|
+
throw err
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const found = []
|
|
140
|
+
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (!name.endsWith('.json')) continue
|
|
143
|
+
|
|
144
|
+
const key = name.slice(0, -'.json'.length)
|
|
145
|
+
const state = await loadState(key, configDir)
|
|
146
|
+
|
|
147
|
+
if (state?.id === backupId) found.push({ key, file: stateFile(key, configDir), state })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return found
|
|
151
|
+
}
|
package/src/uploader.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { read as readCallback } from 'node:fs'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
import { Api } from 'telegram'
|
|
6
|
+
import { readBigIntFromBuffer } from 'telegram/Helpers.js'
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
|
|
9
|
+
import { withRetry } from './retry.js'
|
|
10
|
+
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
11
|
+
|
|
12
|
+
const read = promisify(readCallback)
|
|
13
|
+
|
|
14
|
+
// Telegram splits its upload API by file size: only files above 10MB may use the
|
|
15
|
+
// "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
|
|
16
|
+
// node_modules/telegram/client/uploads.js), and telark follows it.
|
|
17
|
+
export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
async function readExactly(fd, length, position) {
|
|
20
|
+
// allocUnsafe skips zero-filling 512KB per part — about 0.4s of memset per 1800MB chunk,
|
|
21
|
+
// on the same thread that drives the in-flight requests. Safe only because the loop below
|
|
22
|
+
// either fills every byte or throws: no uninitialised byte can reach a request or the hash.
|
|
23
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
24
|
+
let filled = 0
|
|
25
|
+
|
|
26
|
+
while (filled < length) {
|
|
27
|
+
const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
|
|
28
|
+
if (bytesRead === 0) {
|
|
29
|
+
throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
|
|
30
|
+
}
|
|
31
|
+
filled += bytesRead
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return buffer
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function uploadRange(client, fd, options) {
|
|
38
|
+
const {
|
|
39
|
+
offset,
|
|
40
|
+
length,
|
|
41
|
+
fileName,
|
|
42
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
43
|
+
partSize = PART_SIZE,
|
|
44
|
+
onProgress,
|
|
45
|
+
retryOptions,
|
|
46
|
+
stallMs = DEFAULT_STALL_MS,
|
|
47
|
+
} = options
|
|
48
|
+
|
|
49
|
+
// A pool of fewer than one worker does no work. In the download path that means
|
|
50
|
+
// Promise.all resolves at once and a chunk nobody fetched is reported as complete; in the
|
|
51
|
+
// upload path the batch loop never advances and the process spins in microtasks, which not
|
|
52
|
+
// even a test timeout can interrupt. Neither is something a caller should be able to ask
|
|
53
|
+
// for by accident.
|
|
54
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Worker count must be a whole number of at least one, got: ${JSON.stringify(concurrency)}.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const totalParts = Math.ceil(length / partSize)
|
|
61
|
+
|
|
62
|
+
if (totalParts > MAX_PARTS) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`This byte range needs ${totalParts} parts, but Telegram accepts at most 4000 parts per file.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isLarge = length > LARGE_FILE_THRESHOLD
|
|
69
|
+
const fileId = readBigIntFromBuffer(randomBytes(8), true, true)
|
|
70
|
+
const hash = createHash('sha256')
|
|
71
|
+
|
|
72
|
+
function partRequest(part, bytes) {
|
|
73
|
+
if (isLarge) {
|
|
74
|
+
return new Api.upload.SaveBigFilePart({
|
|
75
|
+
fileId,
|
|
76
|
+
filePart: part,
|
|
77
|
+
fileTotalParts: totalParts,
|
|
78
|
+
bytes,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new Api.upload.SaveFilePart({ fileId, filePart: part, bytes })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let start = 0; start < totalParts; start += concurrency) {
|
|
86
|
+
const end = Math.min(start + concurrency, totalParts)
|
|
87
|
+
const sending = []
|
|
88
|
+
let sendError = null
|
|
89
|
+
let sendFailed = false
|
|
90
|
+
let readError = null
|
|
91
|
+
|
|
92
|
+
// Read sequentially so the hash sees parts in order, but send in parallel.
|
|
93
|
+
try {
|
|
94
|
+
for (let part = start; part < end; part += 1) {
|
|
95
|
+
const partOffset = part * partSize
|
|
96
|
+
const partLength = Math.min(partSize, length - partOffset)
|
|
97
|
+
const bytes = await readExactly(fd, partLength, offset + partOffset)
|
|
98
|
+
|
|
99
|
+
hash.update(bytes)
|
|
100
|
+
|
|
101
|
+
// Attach the handler when the promise is created rather than waiting for the
|
|
102
|
+
// Promise.all at the end of the batch: if readExactly throws on a later part,
|
|
103
|
+
// an already-pushed promise with no handler becomes an unhandledRejection and
|
|
104
|
+
// hides the real error.
|
|
105
|
+
sending.push(
|
|
106
|
+
withRetry(
|
|
107
|
+
() =>
|
|
108
|
+
// Same exposure as the download path: a request left on a sender GramJS has
|
|
109
|
+
// stopped draining never settles, so without a deadline this await would hold
|
|
110
|
+
// the batch open forever and the upload would end without a word.
|
|
111
|
+
withStallTimeout(
|
|
112
|
+
client.invoke(partRequest(part, bytes)),
|
|
113
|
+
stallMs,
|
|
114
|
+
() =>
|
|
115
|
+
`Telegram stopped acknowledging part ${part + 1}/${totalParts} of ` +
|
|
116
|
+
`${fileName}: nothing back for ${Math.round(stallMs / 1000)}s.`,
|
|
117
|
+
),
|
|
118
|
+
retryOptions,
|
|
119
|
+
).then(
|
|
120
|
+
() => onProgress?.(partLength),
|
|
121
|
+
(err) => {
|
|
122
|
+
// Not `sendError ??= err`: if the rejection reason is falsy
|
|
123
|
+
// (undefined/null), that assignment still sets sendError to a falsy
|
|
124
|
+
// value and the `if (sendError)` below reads as "no error" — swallowing
|
|
125
|
+
// a failed send and turning it into a fake success.
|
|
126
|
+
if (!sendFailed) {
|
|
127
|
+
sendFailed = true
|
|
128
|
+
sendError = err
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
readError = err
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Every promise in `sending` already absorbed its own error above, so they all
|
|
139
|
+
// resolve; this await only guarantees no request is still in flight when we throw.
|
|
140
|
+
await Promise.all(sending)
|
|
141
|
+
|
|
142
|
+
if (readError) throw readError
|
|
143
|
+
if (sendFailed) throw sendError
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const inputFile = isLarge
|
|
147
|
+
? new Api.InputFileBig({ id: fileId, parts: totalParts, name: fileName })
|
|
148
|
+
: new Api.InputFile({ id: fileId, parts: totalParts, name: fileName, md5Checksum: '' })
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
inputFile,
|
|
152
|
+
sha256: hash.digest('hex'),
|
|
153
|
+
parts: totalParts,
|
|
154
|
+
}
|
|
155
|
+
}
|