data-ark 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shovity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # data-ark
2
+
3
+ Cắt file lớn thành từng chunk 1.8GB và lưu trữ trên Telegram, khôi phục lại được nguyên vẹn.
4
+
5
+ ## Dùng ngay
6
+
7
+ ```bash
8
+ npx data-ark login # chỉ cần một lần
9
+ npx data-ark data.tar --to @kho_backup # đích được ghi nhớ cho lần sau
10
+ npx data-ark data.tar # từ lần thứ hai trở đi
11
+ npx data-ark restore ark-20260905-7f3a91
12
+ ```
13
+
14
+ ## Cần chuẩn bị
15
+
16
+ - Node.js 18 trở lên.
17
+ - `api_id` và `api_hash` lấy tại <https://my.telegram.org> → API development tools. Lệnh `login` sẽ hỏi hai giá trị này, kèm số điện thoại và mã xác nhận.
18
+
19
+ data-ark đăng nhập bằng chính tài khoản Telegram của bạn (MTProto), không phải bot. Đây là điều kiện bắt buộc: Bot API chỉ cho upload 50MB mỗi file, còn tài khoản người dùng được 2GB.
20
+
21
+ ## Tuỳ chọn
22
+
23
+ | Cờ | Mặc định | Ý nghĩa |
24
+ |---|---|---|
25
+ | `--to <chat>` | đích đã ghi nhớ | `@username`, `-100123…`, hoặc `me` |
26
+ | `--chunk-size <n>` | `1800MB` | Ví dụ `1.8GB`, `500MB`. Trần cứng 1950MB. |
27
+ | `--concurrency <n>` | `8` | Số phần 512KB gửi song song. Phải là số nguyên từ 1 đến 64. |
28
+ | `--out <đường-dẫn>` | basename của tên trong manifest | Nơi ghi file khi restore, tính từ thư mục hiện tại nếu là đường dẫn tương đối |
29
+
30
+ ## Hoạt động thế nào
31
+
32
+ Mỗi lần chạy sinh ra một `backupId`. File được đọc thẳng theo offset, không tạo file tạm, và đẩy lên thành các document tên `<backupId>.partNNNN`. Xong hết, data-ark gửi thêm một manifest JSON liệt kê message id và sha256 của từng chunk. Restore chỉ cần `backupId`: nó tìm manifest trong chat, tải từng chunk về đúng vị trí trong một file `.partial`, đối chiếu sha256 và kích thước từng chunk, và chỉ đổi tên thành file thật sau khi *toàn bộ* chunk đã khớp.
33
+
34
+ Đứt mạng giữa chừng lúc **upload** thì cứ chạy lại đúng lệnh cũ — tiến độ nằm ở `~/.data-ark/state/`, những chunk đã xong sẽ được bỏ qua (`backupId` giữ nguyên). Vài lưu ý về việc chạy lại:
35
+
36
+ - Nếu chạy lại với `--to` khác với đích đã lưu trong tiến độ dở dang, data-ark **từ chối chạy** thay vì tự chuyển hướng — một backup không thể tách làm hai đích. Thông báo lỗi chỉ đường: bỏ `--to` để tiếp tục gửi vào đích cũ, hoặc xoá file trạng thái để bắt đầu backup mới.
37
+ - Nếu chạy lại với `--chunk-size` khác, data-ark coi đó là một backup mới hoàn toàn (backup id mới), không resume.
38
+
39
+ **Restore không có trạng thái để resume.** Nhấn `Ctrl-C` giữa lúc restore thì không có gì được lưu — chạy lại sẽ tải lại từ đầu.
40
+
41
+ ## Giới hạn cần biết
42
+
43
+ - Chunk không thể vượt 1950MB: Telegram nhận tối đa 4000 phần 512KB mỗi file, tức trần số học khoảng 1953MB, và data-ark chốt ở 1950MB để chừa biên an toàn.
44
+ - Dữ liệu **không** được mã hoá. Đừng đẩy thứ gì bạn không muốn nằm trên hạ tầng của người khác.
45
+ - Xoá message chunk trên Telegram là mất backup, không có cách cứu.
46
+ - Giữ lấy `backupId`. Không có nó thì phải tự tìm manifest trong chat bằng tay.
47
+
48
+ ## Cấu hình lưu ở đâu
49
+
50
+ `~/.data-ark/config.json` (quyền 600) chứa `apiId`, `apiHash`, session và đích lưu mặc định. `npx data-ark logout` **chỉ xoá bản session lưu dưới máy** và giữ lại phần còn lại — phiên đăng nhập vẫn còn sống phía Telegram. Muốn cắt hẳn quyền truy cập thì vào Telegram → Settings → Devices (Active sessions) và thu hồi phiên đó.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import { route, HELP } from '../src/cli.js'
3
+ import { runLogin } from '../src/commands/login.js'
4
+ import { runLogout } from '../src/commands/logout.js'
5
+ import { runRestore } from '../src/commands/restore.js'
6
+ import { runUpload } from '../src/commands/upload.js'
7
+
8
+ const SIGINT_EXIT_CODE = 130
9
+
10
+ // Lệnh đang chạy khi Ctrl-C xảy ra — mỗi lệnh có một sự thật khác nhau về việc
11
+ // tiến độ có được lưu hay không, nên phải biết đang ở lệnh nào mới chọn đúng câu.
12
+ let currentCommand = null
13
+
14
+ function sigintMessage(command) {
15
+ if (command === 'upload') {
16
+ return '\nĐã dừng. Tiến độ đã lưu, chạy lại lệnh cũ để đi tiếp.\n'
17
+ }
18
+
19
+ if (command === 'restore') {
20
+ return '\nĐã dừng. Chưa lưu tiến độ tải, chạy lại sẽ tải lại từ đầu.\n'
21
+ }
22
+
23
+ return '\nĐã dừng.\n'
24
+ }
25
+
26
+ process.on('SIGINT', () => {
27
+ process.stderr.write(sigintMessage(currentCommand))
28
+ process.exit(SIGINT_EXIT_CODE)
29
+ })
30
+
31
+ async function main() {
32
+ let parsed
33
+
34
+ try {
35
+ parsed = route(process.argv.slice(2))
36
+ } catch (err) {
37
+ process.stderr.write(`Lỗi: ${err.message}\n\n${HELP}`)
38
+ process.exitCode = 2
39
+ return
40
+ }
41
+
42
+ currentCommand = parsed.command
43
+
44
+ switch (parsed.command) {
45
+ case 'help':
46
+ process.stdout.write(HELP)
47
+ return
48
+
49
+ case 'login':
50
+ await runLogin()
51
+ return
52
+
53
+ case 'logout':
54
+ await runLogout()
55
+ return
56
+
57
+ case 'upload':
58
+ await runUpload(parsed.args[0], parsed.options)
59
+ return
60
+
61
+ case 'restore':
62
+ if (!parsed.args[0]) {
63
+ throw new Error('Thiếu backup id. Ví dụ: npx data-ark restore ark-20260905-7f3a91')
64
+ }
65
+ await runRestore(parsed.args[0], parsed.options)
66
+ return
67
+
68
+ default:
69
+ throw new Error(`Lệnh không nhận ra: ${parsed.command}`)
70
+ }
71
+ }
72
+
73
+ // GramJS giữ lại các "exported sender" cùng một timer 30 giây để giải phóng
74
+ // chúng, và client.disconnect()/destroy() không dọn nổi: cả hai map đó là Map
75
+ // nhưng code duyệt bằng Object.values nên bỏ sót sạch. Hệ quả: lệnh in "Xong"
76
+ // rồi vẫn treo thêm ~30 giây, và Ctrl-C trong khoảng đó lại báo sai rằng chưa
77
+ // lưu được gì. Chạy xong việc là thoát hẳn, không nấn ná.
78
+ function exitWhenFlushed(code) {
79
+ // Ghi rỗng chỉ để mượn callback: nó chạy sau khi mọi thứ xếp hàng trước đó đã
80
+ // xả xuống pipe, nên không mất chữ nào khi stdout/stderr không phải TTY.
81
+ let pending = 2
82
+
83
+ const done = () => {
84
+ pending -= 1
85
+ if (pending === 0) process.exit(code)
86
+ }
87
+
88
+ // Lưới an toàn: nếu callback không bao giờ tới (pipe đã đóng) thì vẫn thoát.
89
+ setTimeout(() => process.exit(code), 2000).unref()
90
+
91
+ process.stdout.write('', done)
92
+ process.stderr.write('', done)
93
+ }
94
+
95
+ main().then(
96
+ () => {
97
+ exitWhenFlushed(process.exitCode ?? 0)
98
+ },
99
+ (err) => {
100
+ process.stderr.write(`Lỗi: ${err.message}\n`)
101
+ process.exitCode = 1
102
+ exitWhenFlushed(1)
103
+ },
104
+ )
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "data-ark",
3
+ "version": "0.1.0",
4
+ "description": "Cắt file lớn thành chunk và lưu trữ trên Telegram",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "data-ark": "bin/data-ark.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test 'test/**/*.test.js'"
20
+ },
21
+ "dependencies": {
22
+ "telegram": "^2.26.22"
23
+ }
24
+ }
@@ -0,0 +1,51 @@
1
+ export const PART_SIZE = 512 * 1024
2
+ export const MAX_PARTS = 4000
3
+ export const MAX_CHUNK_SIZE = 1950 * 1024 * 1024
4
+ export const DEFAULT_CHUNK_SIZE = 1800 * 1024 * 1024
5
+ export const DEFAULT_CONCURRENCY = 8
6
+ export const MAX_CONCURRENCY = 64
7
+
8
+ const UNITS = {
9
+ b: 1,
10
+ kb: 1024,
11
+ mb: 1024 * 1024,
12
+ gb: 1024 * 1024 * 1024,
13
+ }
14
+
15
+ export function parseSize(input) {
16
+ const text = String(input).trim().toLowerCase()
17
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/)
18
+
19
+ if (!match) {
20
+ throw new Error(`Kích thước không hợp lệ: "${input}". Ví dụ hợp lệ: 1800MB, 1.8GB, 524288.`)
21
+ }
22
+
23
+ const bytes = Math.floor(Number(match[1]) * UNITS[match[2] ?? 'b'])
24
+
25
+ if (bytes <= 0) {
26
+ throw new Error('Kích thước phải lớn hơn 0.')
27
+ }
28
+
29
+ if (bytes > MAX_CHUNK_SIZE) {
30
+ throw new Error(
31
+ 'Chunk tối đa là 1950MB. Telegram chỉ nhận 4000 phần 512KB cho mỗi file, ' +
32
+ 'tức trần số học khoảng 1953MB, nên cần chừa biên an toàn.',
33
+ )
34
+ }
35
+
36
+ return bytes
37
+ }
38
+
39
+ export function planChunks(fileSize, chunkSize) {
40
+ if (fileSize <= 0) {
41
+ throw new Error('File rỗng, không có gì để upload.')
42
+ }
43
+
44
+ const chunks = []
45
+
46
+ for (let offset = 0, i = 0; offset < fileSize; offset += chunkSize, i += 1) {
47
+ chunks.push({ i, offset, length: Math.min(chunkSize, fileSize - offset) })
48
+ }
49
+
50
+ return chunks
51
+ }
package/src/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ import { parseArgs } from 'node:util'
2
+
3
+ const SUBCOMMANDS = new Set(['login', 'logout', 'restore', 'help'])
4
+
5
+ const OPTIONS = {
6
+ to: { type: 'string' },
7
+ 'chunk-size': { type: 'string' },
8
+ concurrency: { type: 'string' },
9
+ out: { type: 'string' },
10
+ help: { type: 'boolean', short: 'h' },
11
+ }
12
+
13
+ export const HELP = `data-ark — cắt file lớn thành chunk và lưu trữ trên Telegram
14
+
15
+ Cách dùng:
16
+ npx data-ark login Đăng nhập Telegram, chỉ cần chạy một lần
17
+ npx data-ark <file> Cắt file và đẩy lên Telegram
18
+ npx data-ark restore <backup-id> Tải về và ghép lại file gốc
19
+ npx data-ark logout Xoá phiên đăng nhập đã lưu
20
+
21
+ Tuỳ chọn:
22
+ --to <chat> Đích lưu: @username, -100123..., hoặc me. Được ghi nhớ cho lần sau.
23
+ --chunk-size <n> Kích thước mỗi chunk, mặc định 1800MB. Ví dụ: 1.8GB, 500MB.
24
+ --concurrency <n> Số phần 512KB gửi song song, mặc định 8, tối đa 64.
25
+ --out <đường-dẫn> Nơi ghi file khi restore. Mặc định lấy basename của tên trong manifest.
26
+ -h, --help Hiện trợ giúp này.
27
+ `
28
+
29
+ export function route(argv) {
30
+ const { values, positionals } = parseArgs({
31
+ args: argv,
32
+ options: OPTIONS,
33
+ allowPositionals: true,
34
+ })
35
+
36
+ const [first, ...rest] = positionals
37
+
38
+ if (values.help || first === undefined || first === 'help') {
39
+ return { command: 'help', args: [], options: values }
40
+ }
41
+
42
+ if (SUBCOMMANDS.has(first)) {
43
+ return { command: first, args: rest, options: values }
44
+ }
45
+
46
+ return { command: 'upload', args: [first], options: values }
47
+ }
package/src/client.js ADDED
@@ -0,0 +1,52 @@
1
+ import { TelegramClient } from 'telegram'
2
+ import { StringSession } from 'telegram/sessions/index.js'
3
+
4
+ export function normalizeChatTarget(input) {
5
+ const text = String(input).trim()
6
+
7
+ if (text === '') {
8
+ throw new Error('Đích lưu không được để rỗng.')
9
+ }
10
+
11
+ if (/^-?\d+$/.test(text)) {
12
+ return Number(text)
13
+ }
14
+
15
+ return text
16
+ }
17
+
18
+ export function requireChat(options, config) {
19
+ const raw = options.to ?? config.defaultChat
20
+
21
+ if (!raw) {
22
+ throw new Error(
23
+ 'Chưa có đích lưu — chạy lại với --to @kho_backup (hoặc --to me để dùng Saved Messages). ' +
24
+ 'Lần sau data-ark sẽ tự nhớ.',
25
+ )
26
+ }
27
+
28
+ return normalizeChatTarget(raw)
29
+ }
30
+
31
+ export function assertLoggedIn(config) {
32
+ if (!config.session || !config.apiId || !config.apiHash) {
33
+ throw new Error('Chưa đăng nhập — chạy "npx data-ark login" trước.')
34
+ }
35
+ }
36
+
37
+ export async function connect(config) {
38
+ assertLoggedIn(config)
39
+
40
+ const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
41
+ connectionRetries: 5,
42
+ floodSleepThreshold: 60,
43
+ })
44
+
45
+ await client.connect()
46
+
47
+ if (!(await client.isUserAuthorized())) {
48
+ throw new Error('Phiên đăng nhập đã hết hạn — chạy "npx data-ark login".')
49
+ }
50
+
51
+ return client
52
+ }
@@ -0,0 +1,87 @@
1
+ import readline from 'node:readline/promises'
2
+ import { stdin, stdout } from 'node:process'
3
+
4
+ import { TelegramClient } from 'telegram'
5
+ import { StringSession } from 'telegram/sessions/index.js'
6
+
7
+ import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
8
+ import { normalizeChatTarget } from '../client.js'
9
+
10
+ function createPrompts() {
11
+ const rl = readline.createInterface({ input: stdin, output: stdout })
12
+ return {
13
+ ask: (question) => rl.question(question),
14
+ close: () => rl.close(),
15
+ }
16
+ }
17
+
18
+ const LOGIN_ERROR_MESSAGES = {
19
+ PHONE_NUMBER_INVALID: 'số điện thoại không hợp lệ',
20
+ PHONE_CODE_INVALID: 'mã xác nhận sai',
21
+ PHONE_CODE_EXPIRED: 'mã xác nhận đã hết hạn',
22
+ PASSWORD_HASH_INVALID: 'mật khẩu hai lớp sai',
23
+ FLOOD_WAIT: 'bị Telegram giới hạn tần suất, cần chờ',
24
+ }
25
+
26
+ export function describeLoginError(err) {
27
+ const message = String(err?.message ?? err ?? '')
28
+ const known = Object.entries(LOGIN_ERROR_MESSAGES).find(([code]) => message.startsWith(code))
29
+
30
+ if (!known) return message
31
+
32
+ const [, description] = known
33
+ return `${description} (${message})`
34
+ }
35
+
36
+ export async function runLogin({ configDir = defaultConfigDir(), prompts = createPrompts() } = {}) {
37
+ const config = await loadConfig(configDir)
38
+
39
+ console.log('Cần api_id và api_hash của riêng bạn. Lấy tại https://my.telegram.org → API development tools.\n')
40
+
41
+ const apiIdAnswer = (await prompts.ask(`api_id${config.apiId ? ` [${config.apiId}]` : ''}: `)).trim()
42
+
43
+ if (apiIdAnswer !== '' && !/^\d+$/.test(apiIdAnswer)) {
44
+ prompts.close()
45
+ throw new Error('api_id phải là số nguyên.')
46
+ }
47
+
48
+ const apiId = apiIdAnswer === '' ? config.apiId : Number(apiIdAnswer)
49
+ const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [giữ nguyên]' : ''}: `)) || config.apiHash
50
+
51
+ if (!apiId || !apiHash) {
52
+ prompts.close()
53
+ throw new Error('Thiếu api_id hoặc api_hash.')
54
+ }
55
+
56
+ const client = new TelegramClient(new StringSession(''), apiId, apiHash, { connectionRetries: 5 })
57
+
58
+ await client.start({
59
+ phoneNumber: () => prompts.ask('Số điện thoại (dạng +84...): '),
60
+ phoneCode: () => prompts.ask('Mã xác nhận Telegram vừa gửi: '),
61
+ password: () => prompts.ask('Mật khẩu hai lớp (bỏ trống nếu không bật): '),
62
+ onError: (err) => console.error(`Lỗi đăng nhập: ${describeLoginError(err)}`),
63
+ })
64
+
65
+ const me = await client.getMe()
66
+ const session = client.session.save()
67
+ await client.disconnect()
68
+
69
+ const chatAnswer = (
70
+ await prompts.ask(
71
+ `Đẩy backup vào chat nào? (@username, -100..., hoặc me)${config.defaultChat ? ` [${config.defaultChat}]` : ''}, Enter để bỏ qua: `,
72
+ )
73
+ ).trim()
74
+
75
+ prompts.close()
76
+
77
+ const next = { ...config, apiId, apiHash, session }
78
+
79
+ if (chatAnswer !== '') {
80
+ next.defaultChat = String(normalizeChatTarget(chatAnswer))
81
+ }
82
+
83
+ await saveConfig(next, configDir)
84
+
85
+ console.log(`\nĐã đăng nhập với tài khoản ${me.username ? `@${me.username}` : me.firstName}.`)
86
+ console.log(`Cấu hình đã lưu vào ${configDir}/config.json`)
87
+ }
@@ -0,0 +1,13 @@
1
+ import { clearSession, defaultConfigDir } from '../config.js'
2
+
3
+ export async function runLogout({ configDir = defaultConfigDir() } = {}) {
4
+ await clearSession(configDir)
5
+ console.log(
6
+ 'Đã xoá phiên đăng nhập lưu trên máy này. api_id, api_hash và đích lưu vẫn được giữ lại.',
7
+ )
8
+ console.log(
9
+ 'Lưu ý: lệnh này chỉ xoá bản lưu dưới máy, phiên vẫn còn sống phía Telegram. ' +
10
+ 'Muốn cắt hẳn quyền truy cập, mở Telegram → Settings → Devices (Active sessions) ' +
11
+ 'và thu hồi phiên đó.',
12
+ )
13
+ }
@@ -0,0 +1,197 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import readline from 'node:readline/promises'
4
+ import { stdin, stdout } from 'node:process'
5
+
6
+ import { Api } from 'telegram'
7
+
8
+ import { connect as realConnect, requireChat } from '../client.js'
9
+ import { defaultConfigDir, loadConfig } from '../config.js'
10
+ import { downloadToFile } from '../downloader.js'
11
+ import { manifestFileName, parseManifest } from '../manifest.js'
12
+ import { createProgress, formatBytes } from '../progress.js'
13
+
14
+ function documentFileName(message) {
15
+ const attributes = message?.media?.document?.attributes ?? []
16
+ const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
17
+ return named?.fileName ?? null
18
+ }
19
+
20
+ async function realSearchManifest(client, peer, backupId) {
21
+ const wanted = manifestFileName(backupId)
22
+
23
+ // Dùng client.getMessages thay vì raw Api.messages.Search: nó tự lo offset,
24
+ // hash và phân trang, nên không phải tự dựng các trường dễ sai kiểu.
25
+ const messages = await client.getMessages(peer, {
26
+ search: backupId,
27
+ filter: new Api.InputMessagesFilterDocument(),
28
+ limit: 100,
29
+ })
30
+
31
+ return messages.find((m) => documentFileName(m) === wanted) ?? null
32
+ }
33
+
34
+ async function realReadMessageBytes(client, message) {
35
+ return await client.downloadMedia(message)
36
+ }
37
+
38
+ async function realGetMessage(client, peer, msgId) {
39
+ const [message] = await client.getMessages(peer, { ids: [msgId] })
40
+ return message ?? null
41
+ }
42
+
43
+ export async function realDownloadChunk(client, message, handle, offset, onProgress) {
44
+ return await downloadToFile(client, message, handle.fd, { offset, onProgress })
45
+ }
46
+
47
+ // manifest.name đến từ dữ liệu tải về Telegram — không tin nó khi tự chọn đường
48
+ // dẫn. path.basename chặn được "../../x" nhưng vẫn trả về "..", "." hay "" cho
49
+ // vài tên bệnh: path.resolve('..') là thư mục cha, nên file .partial hàng GB sẽ
50
+ // nằm ngoài thư mục hiện tại và chỉ vỡ ra ở bước rename.
51
+ function safeOutName(name) {
52
+ const base = path.basename(String(name ?? ''))
53
+
54
+ if (base === '' || base === '.' || base === '..') {
55
+ throw new Error(
56
+ `Tên file trong manifest ("${name}") không dùng được làm tên file. ` +
57
+ 'Chạy lại kèm --out <đường-dẫn> để tự chỉ định nơi ghi.',
58
+ )
59
+ }
60
+
61
+ return base
62
+ }
63
+
64
+ async function askConfirm(question) {
65
+ const rl = readline.createInterface({ input: stdin, output: stdout })
66
+ const answer = await rl.question(question)
67
+ rl.close()
68
+ return /^(c|y)/i.test(answer.trim())
69
+ }
70
+
71
+ export async function runRestore(backupId, options = {}, deps = {}) {
72
+ const {
73
+ connect = realConnect,
74
+ disconnect = (client) => client.destroy(),
75
+ configDir = defaultConfigDir(),
76
+ searchManifest = realSearchManifest,
77
+ readMessageBytes = realReadMessageBytes,
78
+ getMessage = realGetMessage,
79
+ downloadChunk = realDownloadChunk,
80
+ confirm = askConfirm,
81
+ writeErr = (line) => process.stderr.write(line),
82
+ silent = false,
83
+ } = deps
84
+
85
+ const config = await loadConfig(configDir)
86
+ const chat = requireChat(options, config)
87
+ const log = silent ? () => {} : (line) => console.log(line)
88
+ const warn = silent ? () => {} : writeErr
89
+
90
+ const client = await connect(config)
91
+
92
+ try {
93
+ const manifestMessage = await searchManifest(client, chat, backupId)
94
+
95
+ if (!manifestMessage) {
96
+ throw new Error(
97
+ `Không tìm thấy manifest của ${backupId} trong ${chat}. ` +
98
+ 'Kiểm tra lại backup id, hoặc dùng --to để trỏ đúng chat.',
99
+ )
100
+ }
101
+
102
+ const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
103
+ // Khi người dùng tự chỉ định --out thì tôn trọng nguyên văn đường dẫn đó.
104
+ const target = path.resolve(options.out ?? safeOutName(manifest.name))
105
+ const partial = `${target}.partial`
106
+
107
+ // Chỉ ENOENT mới có nghĩa là "chưa có file". Lỗi quyền hay lỗi I/O mà bị coi
108
+ // là vắng mặt thì data-ark sẽ ghi đè file của người dùng mà không hỏi.
109
+ let exists = true
110
+ try {
111
+ await fs.stat(target)
112
+ } catch (err) {
113
+ if (err.code !== 'ENOENT') throw err
114
+ exists = false
115
+ }
116
+
117
+ if (exists && !(await confirm(`${target} đã tồn tại. Ghi đè? [c/K] `))) {
118
+ throw new Error('Đã huỷ theo yêu cầu.')
119
+ }
120
+
121
+ log(`Backup ${manifest.id}`)
122
+ log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunk)\n`)
123
+
124
+ const handle = await fs.open(partial, 'w+')
125
+
126
+ try {
127
+ await handle.truncate(manifest.size)
128
+
129
+ for (const chunk of manifest.chunks) {
130
+ const message = await getMessage(client, chat, chunk.msgId)
131
+
132
+ if (!message) {
133
+ throw new Error(
134
+ `Thiếu chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} không còn trong ${chat}. ` +
135
+ 'Backup này không khôi phục được.',
136
+ )
137
+ }
138
+
139
+ const progress = silent
140
+ ? { advance: () => {}, finish: () => {} }
141
+ : createProgress({
142
+ total: chunk.size,
143
+ label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
144
+ write: writeErr,
145
+ })
146
+
147
+ const { sha256, size } = await downloadChunk(
148
+ client,
149
+ message,
150
+ handle,
151
+ chunk.i * manifest.chunkSize,
152
+ progress.advance,
153
+ )
154
+
155
+ progress.finish()
156
+
157
+ if (size !== chunk.size) {
158
+ throw new Error(
159
+ `Chunk ${chunk.i + 1} có ${size} byte, manifest ghi ${chunk.size} byte — không khớp.`,
160
+ )
161
+ }
162
+
163
+ if (sha256 !== chunk.sha256) {
164
+ throw new Error(
165
+ `Chunk ${chunk.i + 1} có sha256 không khớp manifest. File tải về giữ ở ${partial} để kiểm tra.`,
166
+ )
167
+ }
168
+ }
169
+ } finally {
170
+ await handle.close()
171
+ }
172
+
173
+ // Chốt chặn cuối: mọi chunk khớp sha256 mà file vẫn sai độ dài thì bố cục đã
174
+ // lệch ở đâu đó. Thà báo lỗi còn hơn đổi tên một file sai thành file thật.
175
+ const written = await fs.stat(partial)
176
+
177
+ if (written.size !== manifest.size) {
178
+ throw new Error(
179
+ `File ghép xong có ${written.size} byte, manifest ghi ${manifest.size} byte — không khớp. ` +
180
+ `File tải về giữ ở ${partial} để kiểm tra.`,
181
+ )
182
+ }
183
+
184
+ await fs.rename(partial, target)
185
+
186
+ log(`\nXong. Đã ghi ${formatBytes(manifest.size)} vào ${target}`)
187
+
188
+ return { path: target, size: manifest.size }
189
+ } finally {
190
+ // Ngắt kết nối hỏng thì cũng không được nuốt mất lỗi thật đang bay lên.
191
+ try {
192
+ await disconnect(client)
193
+ } catch (err) {
194
+ warn(`\nCảnh báo: không đóng được kết nối Telegram: ${err.message}\n`)
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,237 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { Api } from 'telegram'
5
+ import { CustomFile } from 'telegram/client/uploads.js'
6
+
7
+ import {
8
+ DEFAULT_CHUNK_SIZE,
9
+ DEFAULT_CONCURRENCY,
10
+ MAX_CONCURRENCY,
11
+ PART_SIZE,
12
+ parseSize,
13
+ planChunks,
14
+ } from '../chunking.js'
15
+ import { connect as realConnect, requireChat } from '../client.js'
16
+ import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
17
+ import {
18
+ buildManifest,
19
+ chunkFileName,
20
+ manifestFileName,
21
+ newBackupId,
22
+ serializeManifest,
23
+ } from '../manifest.js'
24
+ import { createProgress, formatBytes, formatDuration } from '../progress.js'
25
+ import { clearState, loadState, markChunkDone, saveState, stateDir, stateKey } from '../state.js'
26
+ import { uploadRange } from '../uploader.js'
27
+
28
+ // Trên ngưỡng này thì phải nói rõ đang chờ bao lâu, theo spec §8.
29
+ const LONG_WAIT_MS = 60_000
30
+
31
+ async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
32
+ return await client.sendFile(peer, {
33
+ file: inputFile,
34
+ caption,
35
+ forceDocument: true,
36
+ attributes: [new Api.DocumentAttributeFilename({ fileName })],
37
+ })
38
+ }
39
+
40
+ async function realSendManifest(client, peer, { bytes, fileName, caption }) {
41
+ return await client.sendFile(peer, {
42
+ file: new CustomFile(fileName, bytes.length, '', bytes),
43
+ caption,
44
+ forceDocument: true,
45
+ attributes: [new Api.DocumentAttributeFilename({ fileName })],
46
+ })
47
+ }
48
+
49
+ export async function runUpload(filePath, options = {}, deps = {}) {
50
+ const {
51
+ connect = realConnect,
52
+ sendChunk = realSendChunk,
53
+ sendManifest = realSendManifest,
54
+ disconnect = (client) => client.destroy(),
55
+ configDir = defaultConfigDir(),
56
+ partSize = PART_SIZE,
57
+ retryOptions = {},
58
+ writeErr = (line) => process.stderr.write(line),
59
+ silent = false,
60
+ } = deps
61
+
62
+ const absPath = path.resolve(filePath)
63
+
64
+ let stat
65
+ try {
66
+ stat = await fs.stat(absPath)
67
+ } catch (err) {
68
+ if (err.code === 'ENOENT') throw new Error(`File không tồn tại: ${absPath}`)
69
+ throw err
70
+ }
71
+
72
+ if (!stat.isFile()) {
73
+ throw new Error(`${absPath} không phải là file.`)
74
+ }
75
+
76
+ const config = await loadConfig(configDir)
77
+ const chat = requireChat(options, config)
78
+ const chunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : DEFAULT_CHUNK_SIZE
79
+ const concurrency = options.concurrency ? Number(options.concurrency) : DEFAULT_CONCURRENCY
80
+
81
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
82
+ throw new Error(
83
+ `--concurrency không hợp lệ: "${options.concurrency}". ` +
84
+ `Phải là số nguyên từ 1 đến ${MAX_CONCURRENCY} — mỗi luồng giữ một phần 512KB trong RAM ` +
85
+ 'và Telegram sẽ bắt chờ FLOOD_WAIT nếu bắn quá nhiều request cùng lúc.',
86
+ )
87
+ }
88
+
89
+ const chunks = planChunks(stat.size, chunkSize)
90
+ const key = stateKey(absPath, stat.size, stat.mtimeMs)
91
+
92
+ let state = await loadState(key, configDir)
93
+ const resuming = Boolean(state) && state.chunkSize === chunkSize
94
+
95
+ if (resuming && state.chat !== String(chat)) {
96
+ const stateFile = path.join(stateDir(configDir), `${key}.json`)
97
+ throw new Error(
98
+ `Backup dở dang này đang gửi vào ${state.chat}, nhưng lệnh hiện tại chỉ định đích ${chat} — ` +
99
+ `không thể tách một backup ra hai đích khác nhau. Chạy lại không kèm --to để tiếp tục gửi vào ` +
100
+ `${state.chat}, hoặc xoá ${stateFile} rồi chạy lại để bắt đầu backup mới vào ${chat}.`,
101
+ )
102
+ }
103
+
104
+ if (options.to) {
105
+ await saveConfig({ ...config, defaultChat: String(chat) }, configDir)
106
+ }
107
+
108
+ if (!resuming) {
109
+ state = {
110
+ id: newBackupId(),
111
+ chat: String(chat),
112
+ path: absPath,
113
+ size: stat.size,
114
+ mtimeMs: stat.mtimeMs,
115
+ chunkSize,
116
+ done: {},
117
+ }
118
+ await saveState(key, state, configDir)
119
+ }
120
+
121
+ const log = silent ? () => {} : (line) => console.log(line)
122
+ const warn = silent ? () => {} : writeErr
123
+
124
+ // Thử lại và FLOOD_WAIT phải nói ra: một FLOOD_WAIT_3600 mà im lặng thì người
125
+ // dùng chỉ thấy thanh tiến độ đứng hình cả tiếng và tưởng máy treo.
126
+ function onRetry(err, attempt, delayMs) {
127
+ if (delayMs > LONG_WAIT_MS) {
128
+ warn(
129
+ `\nTelegram bắt chờ ${formatDuration(delayMs / 1000)} rồi mới cho gửi tiếp ` +
130
+ `(${err.message}). data-ark đang đợi và sẽ tự đi tiếp, đừng tắt.\n`,
131
+ )
132
+ return
133
+ }
134
+
135
+ warn(
136
+ `\nLỗi tạm thời (${err.message}), thử lại lần ${attempt} sau ` +
137
+ `${formatDuration(delayMs / 1000)}.\n`,
138
+ )
139
+ }
140
+
141
+ log(`Backup ${state.id}`)
142
+ log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunk)`)
143
+ log(`Đích ${chat}\n`)
144
+
145
+ const client = await connect(config)
146
+
147
+ try {
148
+ for (const chunk of chunks) {
149
+ if (state.done[String(chunk.i)]) {
150
+ log(`Chunk ${chunk.i + 1}/${chunks.length} đã có, bỏ qua.`)
151
+ continue
152
+ }
153
+
154
+ const fileName = chunkFileName(state.id, chunk.i)
155
+ const handle = await fs.open(absPath, 'r')
156
+
157
+ const progress = silent
158
+ ? { advance: () => {}, finish: () => {} }
159
+ : createProgress({
160
+ total: chunk.length,
161
+ label: `Chunk ${chunk.i + 1}/${chunks.length}`,
162
+ write: writeErr,
163
+ })
164
+
165
+ try {
166
+ const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
167
+ offset: chunk.offset,
168
+ length: chunk.length,
169
+ fileName,
170
+ concurrency,
171
+ partSize,
172
+ onProgress: (bytes) => progress.advance(bytes),
173
+ retryOptions: { ...retryOptions, onRetry },
174
+ })
175
+
176
+ progress.finish()
177
+
178
+ const message = await sendChunk(client, chat, {
179
+ inputFile,
180
+ fileName,
181
+ caption: `#dataark ${state.id} ${chunk.i + 1}/${chunks.length}`,
182
+ })
183
+
184
+ state = await markChunkDone(
185
+ key,
186
+ state,
187
+ chunk.i,
188
+ { msgId: message.id, size: chunk.length, sha256 },
189
+ configDir,
190
+ )
191
+ } finally {
192
+ await handle.close()
193
+ }
194
+ }
195
+
196
+ // File có thể bị ghi đè trong lúc upload — với file 50GB thì cả tiếng đồng
197
+ // hồ trôi qua giữa chunk đầu và chunk cuối. Khi đó manifest mô tả một file
198
+ // lai chưa từng tồn tại: restore vẫn khớp sha256 nhưng dữ liệu là rác.
199
+ const after = await fs.stat(absPath)
200
+
201
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) {
202
+ throw new Error(
203
+ `${absPath} đã thay đổi trong lúc upload ` +
204
+ `(kích thước ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
205
+ 'Backup này trộn dữ liệu cũ với dữ liệu mới nên không đáng tin — data-ark không gửi manifest. ' +
206
+ 'Chờ file ổn định rồi chạy lại để tạo một backup mới.',
207
+ )
208
+ }
209
+
210
+ const manifest = buildManifest({
211
+ id: state.id,
212
+ name: path.basename(absPath),
213
+ size: stat.size,
214
+ chunkSize,
215
+ chunks: chunks.map((chunk) => ({ i: chunk.i, ...state.done[String(chunk.i)] })),
216
+ })
217
+
218
+ await sendManifest(client, chat, {
219
+ bytes: serializeManifest(manifest),
220
+ fileName: manifestFileName(state.id),
221
+ caption: `#dataark ${state.id} manifest`,
222
+ })
223
+
224
+ await clearState(key, configDir)
225
+
226
+ log(`\nXong. Khôi phục bằng:\n npx data-ark restore ${state.id}`)
227
+
228
+ return { id: state.id, chunks: chunks.length }
229
+ } finally {
230
+ // Ngắt kết nối hỏng thì cũng không được nuốt mất lỗi thật đang bay lên.
231
+ try {
232
+ await disconnect(client)
233
+ } catch (err) {
234
+ warn(`\nCảnh báo: không đóng được kết nối Telegram: ${err.message}\n`)
235
+ }
236
+ }
237
+ }
package/src/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const FILE_NAME = 'config.json'
6
+
7
+ export function defaultConfigDir() {
8
+ return path.join(os.homedir(), '.data-ark')
9
+ }
10
+
11
+ export async function loadConfig(dir = defaultConfigDir()) {
12
+ const file = path.join(dir, FILE_NAME)
13
+
14
+ let raw
15
+ try {
16
+ raw = await fs.readFile(file, 'utf8')
17
+ } catch (err) {
18
+ if (err.code === 'ENOENT') return {}
19
+ throw err
20
+ }
21
+
22
+ try {
23
+ return JSON.parse(raw)
24
+ } catch {
25
+ throw new Error(`File cấu hình hỏng: ${file}. Xoá nó rồi chạy lại "data-ark login".`)
26
+ }
27
+ }
28
+
29
+ export async function saveConfig(config, dir = defaultConfigDir()) {
30
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
31
+
32
+ const file = path.join(dir, FILE_NAME)
33
+ const tmp = `${file}.tmp`
34
+
35
+ await fs.writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
36
+ await fs.rename(tmp, file)
37
+ }
38
+
39
+ export async function clearSession(dir = defaultConfigDir()) {
40
+ const config = await loadConfig(dir)
41
+ delete config.session
42
+ await saveConfig(config, dir)
43
+ }
@@ -0,0 +1,40 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { write as writeCallback } from 'node:fs'
3
+ import { promisify } from 'node:util'
4
+
5
+ import { PART_SIZE } from './chunking.js'
6
+
7
+ const write = promisify(writeCallback)
8
+
9
+ async function writeExactly(fd, buffer, position) {
10
+ let written = 0
11
+
12
+ while (written < buffer.length) {
13
+ const { bytesWritten } = await write(fd, buffer, written, buffer.length - written, position + written)
14
+ written += bytesWritten
15
+ }
16
+ }
17
+
18
+ export async function downloadToFile(client, message, fd, { offset, onProgress } = {}) {
19
+ const document = message?.media?.document
20
+
21
+ if (!document) {
22
+ throw new Error(`Message ${message?.id} không chứa file đính kèm.`)
23
+ }
24
+
25
+ if (!Number.isFinite(offset)) {
26
+ throw new Error(`offset phải là một số hữu hạn, nhận được: ${offset}`)
27
+ }
28
+
29
+ const hash = createHash('sha256')
30
+ let written = 0
31
+
32
+ for await (const buffer of client.iterDownload({ file: document, requestSize: PART_SIZE })) {
33
+ await writeExactly(fd, buffer, offset + written)
34
+ hash.update(buffer)
35
+ written += buffer.length
36
+ onProgress?.(buffer.length)
37
+ }
38
+
39
+ return { sha256: hash.digest('hex'), size: written }
40
+ }
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ export const MANIFEST_VERSION = 1
4
+
5
+ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).toString('hex')) {
6
+ const yyyy = now.getUTCFullYear()
7
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
8
+ const dd = String(now.getUTCDate()).padStart(2, '0')
9
+ return `ark-${yyyy}${mm}${dd}-${randomHex()}`
10
+ }
11
+
12
+ export function chunkFileName(id, i) {
13
+ return `${id}.part${String(i + 1).padStart(4, '0')}`
14
+ }
15
+
16
+ export function manifestFileName(id) {
17
+ return `${id}.manifest.json`
18
+ }
19
+
20
+ export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = new Date().toISOString() }) {
21
+ return {
22
+ v: MANIFEST_VERSION,
23
+ id,
24
+ name,
25
+ size,
26
+ chunkSize,
27
+ createdAt,
28
+ chunks: [...chunks]
29
+ .sort((a, b) => a.i - b.i)
30
+ .map(({ i, msgId, size: chunkBytes, sha256 }) => ({ i, msgId, size: chunkBytes, sha256 })),
31
+ }
32
+ }
33
+
34
+ export function serializeManifest(manifest) {
35
+ return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
36
+ }
37
+
38
+ export function parseManifest(input) {
39
+ const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input)
40
+
41
+ let manifest
42
+ try {
43
+ manifest = JSON.parse(text)
44
+ } catch {
45
+ throw new Error('Không đọc được manifest: nội dung không phải JSON hợp lệ.')
46
+ }
47
+
48
+ if (manifest.v !== MANIFEST_VERSION) {
49
+ throw new Error(
50
+ `Manifest dùng phiên bản ${manifest.v}, bản data-ark này chỉ hiểu phiên bản ${MANIFEST_VERSION}.`,
51
+ )
52
+ }
53
+
54
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
55
+ throw new Error('Manifest thiếu danh sách chunk.')
56
+ }
57
+
58
+ manifest.chunks.forEach((chunk, index) => {
59
+ if (chunk.i !== index) {
60
+ throw new Error(`Manifest thiếu chunk số ${index}: danh sách chunk không liên tục.`)
61
+ }
62
+ })
63
+
64
+ const expectedChunks = Math.ceil(manifest.size / manifest.chunkSize)
65
+ if (manifest.chunks.length !== expectedChunks) {
66
+ throw new Error(`Manifest thiếu ${expectedChunks - manifest.chunks.length} chunk(s).`)
67
+ }
68
+
69
+ const total = manifest.chunks.reduce((sum, chunk) => sum + chunk.size, 0)
70
+ if (total !== manifest.size) {
71
+ throw new Error(
72
+ `Tổng kích thước các chunk (${total}) không khớp kích thước file trong manifest (${manifest.size}).`,
73
+ )
74
+ }
75
+
76
+ // Restore ghi chunk i vào đúng offset i * chunkSize, nên bố cục phải đều:
77
+ // mọi chunk đúng bằng chunkSize, riêng chunk cuối là phần dư. Tổng đúng mà
78
+ // từng chunk lệch thì file ghép ra sẽ thủng lỗ hoặc dài thêm mà sha256 từng
79
+ // chunk vẫn khớp — sai âm thầm, đúng thứ data-ark không được phép để xảy ra.
80
+ manifest.chunks.forEach((chunk, index) => {
81
+ const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
82
+ if (chunk.size !== expected) {
83
+ throw new Error(
84
+ `Chunk ${index + 1} trong manifest ghi ${chunk.size} byte, nhưng theo bố cục ` +
85
+ `${manifest.chunkSize} byte mỗi chunk thì phải là ${expected} byte. ` +
86
+ 'Manifest này mô tả sai vị trí các chunk, khôi phục sẽ ra file hỏng.',
87
+ )
88
+ }
89
+ })
90
+
91
+ return manifest
92
+ }
@@ -0,0 +1,71 @@
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 }) {
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
+ const bytesPerSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0
36
+ // Kẹp về 0: done có thể vượt total (đếm dư một nhịp) và ETA âm thì vô nghĩa.
37
+ const remaining = bytesPerSecond > 0 ? Math.max(0, (total - done) / bytesPerSecond) : Infinity
38
+
39
+ const percent = String(Math.floor(ratio * 100)).padStart(3)
40
+ const speed = `${formatBytes(Math.round(bytesPerSecond))}/s`
41
+
42
+ return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} còn ${formatDuration(remaining)}`
43
+ }
44
+
45
+ export function createProgress({
46
+ total,
47
+ label,
48
+ write = (line) => process.stderr.write(line),
49
+ now = () => Date.now(),
50
+ minIntervalMs = 200,
51
+ }) {
52
+ const startedAt = now()
53
+ let done = 0
54
+ let lastDrawnAt = startedAt
55
+
56
+ function draw(suffix) {
57
+ write(`\r${renderProgress({ done, total, elapsedMs: now() - startedAt, label })}${suffix}`)
58
+ }
59
+
60
+ return {
61
+ advance(bytes) {
62
+ done += bytes
63
+ if (now() - lastDrawnAt < minIntervalMs) return
64
+ lastDrawnAt = now()
65
+ draw('')
66
+ },
67
+ finish() {
68
+ draw('\n')
69
+ },
70
+ }
71
+ }
package/src/retry.js ADDED
@@ -0,0 +1,42 @@
1
+ const DEFAULT_ATTEMPTS = 5
2
+ const DEFAULT_BASE_DELAY_MS = 1000
3
+
4
+ function defaultSleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms))
6
+ }
7
+
8
+ function floodWaitSeconds(err) {
9
+ if (typeof err?.seconds === 'number' && String(err?.errorMessage ?? '').includes('FLOOD_WAIT')) {
10
+ return err.seconds
11
+ }
12
+ return null
13
+ }
14
+
15
+ export async function withRetry(fn, options = {}) {
16
+ const {
17
+ attempts = DEFAULT_ATTEMPTS,
18
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
19
+ sleep = defaultSleep,
20
+ onRetry,
21
+ } = options
22
+
23
+ let lastError
24
+
25
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
26
+ try {
27
+ return await fn()
28
+ } catch (err) {
29
+ lastError = err
30
+
31
+ if (attempt === attempts) break
32
+
33
+ const flood = floodWaitSeconds(err)
34
+ const delayMs = flood === null ? baseDelayMs * 2 ** (attempt - 1) : flood * 1000
35
+
36
+ onRetry?.(err, attempt, delayMs)
37
+ await sleep(delayMs)
38
+ }
39
+ }
40
+
41
+ throw lastError
42
+ }
package/src/state.js ADDED
@@ -0,0 +1,52 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { defaultConfigDir } 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
+ function stateFile(key, configDir) {
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
+ const dir = stateDir(configDir)
31
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
32
+
33
+ const file = stateFile(key, configDir)
34
+ const tmp = `${file}.tmp`
35
+
36
+ await fs.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
37
+ await fs.rename(tmp, file)
38
+ }
39
+
40
+ export async function markChunkDone(key, state, i, entry, configDir = defaultConfigDir()) {
41
+ const updated = { ...state, done: { ...state.done, [String(i)]: entry } }
42
+ await saveState(key, updated, configDir)
43
+ return updated
44
+ }
45
+
46
+ export async function clearState(key, configDir = defaultConfigDir()) {
47
+ try {
48
+ await fs.unlink(stateFile(key, configDir))
49
+ } catch (err) {
50
+ if (err.code !== 'ENOENT') throw err
51
+ }
52
+ }
@@ -0,0 +1,125 @@
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
+
11
+ const read = promisify(readCallback)
12
+
13
+ // Telegram tách hai API upload theo kích thước file: trên 10MB mới được dùng
14
+ // nhóm "big". GramJS chọn đúng ngưỡng này (LARGE_FILE_THRESHOLD trong
15
+ // node_modules/telegram/client/uploads.js), data-ark bám theo.
16
+ export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
17
+
18
+ async function readExactly(fd, length, position) {
19
+ const buffer = Buffer.alloc(length)
20
+ let filled = 0
21
+
22
+ while (filled < length) {
23
+ const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
24
+ if (bytesRead === 0) {
25
+ throw new Error(`Đọc hụt: cần ${length} byte từ vị trí ${position} nhưng file đã hết.`)
26
+ }
27
+ filled += bytesRead
28
+ }
29
+
30
+ return buffer
31
+ }
32
+
33
+ export async function uploadRange(client, fd, options) {
34
+ const {
35
+ offset,
36
+ length,
37
+ fileName,
38
+ concurrency = DEFAULT_CONCURRENCY,
39
+ partSize = PART_SIZE,
40
+ onProgress,
41
+ retryOptions,
42
+ } = options
43
+
44
+ const totalParts = Math.ceil(length / partSize)
45
+
46
+ if (totalParts > MAX_PARTS) {
47
+ throw new Error(
48
+ `Dải byte này cần ${totalParts} phần, trong khi Telegram chỉ nhận tối đa 4000 phần mỗi file.`,
49
+ )
50
+ }
51
+
52
+ const isLarge = length > LARGE_FILE_THRESHOLD
53
+ const fileId = readBigIntFromBuffer(randomBytes(8), true, true)
54
+ const hash = createHash('sha256')
55
+
56
+ function partRequest(part, bytes) {
57
+ if (isLarge) {
58
+ return new Api.upload.SaveBigFilePart({
59
+ fileId,
60
+ filePart: part,
61
+ fileTotalParts: totalParts,
62
+ bytes,
63
+ })
64
+ }
65
+
66
+ return new Api.upload.SaveFilePart({ fileId, filePart: part, bytes })
67
+ }
68
+
69
+ for (let start = 0; start < totalParts; start += concurrency) {
70
+ const end = Math.min(start + concurrency, totalParts)
71
+ const sending = []
72
+ let sendError = null
73
+ let sendFailed = false
74
+ let readError = null
75
+
76
+ // Đọc tuần tự để hash đúng thứ tự, gửi song song.
77
+ try {
78
+ for (let part = start; part < end; part += 1) {
79
+ const partOffset = part * partSize
80
+ const partLength = Math.min(partSize, length - partOffset)
81
+ const bytes = await readExactly(fd, partLength, offset + partOffset)
82
+
83
+ hash.update(bytes)
84
+
85
+ // Gắn handler ngay lúc tạo promise, không đợi Promise.all ở cuối lô:
86
+ // nếu readExactly ném ở part sau, một promise đã push mà chưa ai bắt sẽ
87
+ // nổ thành unhandledRejection và che mất lỗi thật.
88
+ sending.push(
89
+ withRetry(() => client.invoke(partRequest(part, bytes)), retryOptions).then(
90
+ () => onProgress?.(partLength),
91
+ (err) => {
92
+ // Không dùng `sendError ??= err`: nếu lý do reject là falsy
93
+ // (undefined/null), phép gán đó vẫn set sendError = err (falsy)
94
+ // rồi `if (sendError)` bên dưới coi như không có lỗi — nuốt mất
95
+ // một lần gửi thất bại và biến nó thành "thành công" giả.
96
+ if (!sendFailed) {
97
+ sendFailed = true
98
+ sendError = err
99
+ }
100
+ },
101
+ ),
102
+ )
103
+ }
104
+ } catch (err) {
105
+ readError = err
106
+ }
107
+
108
+ // Mọi promise trong sending đều đã tự nuốt lỗi ở trên nên luôn resolve;
109
+ // await ở đây chỉ để chắc chắn không còn request nào đang bay khi ném lỗi.
110
+ await Promise.all(sending)
111
+
112
+ if (readError) throw readError
113
+ if (sendFailed) throw sendError
114
+ }
115
+
116
+ const inputFile = isLarge
117
+ ? new Api.InputFileBig({ id: fileId, parts: totalParts, name: fileName })
118
+ : new Api.InputFile({ id: fileId, parts: totalParts, name: fileName, md5Checksum: '' })
119
+
120
+ return {
121
+ inputFile,
122
+ sha256: hash.digest('hex'),
123
+ parts: totalParts,
124
+ }
125
+ }