telstore 0.1.0 → 0.1.2
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/README.md +109 -36
- package/bin/{telark.js → telstore.js} +16 -3
- package/package.json +6 -6
- package/src/caption.js +2 -2
- package/src/chat.js +2 -2
- package/src/chunking.js +14 -1
- package/src/cli.js +44 -31
- package/src/client.js +21 -9
- package/src/commands/config.js +8 -8
- package/src/commands/delete.js +5 -5
- package/src/commands/list.js +3 -3
- package/src/commands/login.js +88 -15
- package/src/commands/logout.js +15 -5
- package/src/commands/restore.js +8 -5
- package/src/commands/status.js +85 -7
- package/src/commands/token.js +80 -0
- package/src/commands/upload.js +7 -7
- package/src/config.js +28 -4
- package/src/downloader.js +2 -2
- package/src/manifest.js +4 -4
- package/src/prompt.js +81 -0
- package/src/session.js +24 -0
- package/src/settings.js +32 -6
- package/src/state.js +35 -4
- package/src/token.js +221 -0
- package/src/uploader.js +3 -3
package/src/commands/config.js
CHANGED
|
@@ -12,7 +12,7 @@ const GAP = ' '
|
|
|
12
12
|
function unknownKey(name) {
|
|
13
13
|
if (isManagedByLogin(name)) {
|
|
14
14
|
return new Error(
|
|
15
|
-
`"${name}" is managed by "npx
|
|
15
|
+
`"${name}" is managed by "npx telstore login", not by config. ` +
|
|
16
16
|
`Settings you can change: ${SETTING_KEYS.join(', ')}.`,
|
|
17
17
|
)
|
|
18
18
|
}
|
|
@@ -37,7 +37,7 @@ function listLines(values, source, stored) {
|
|
|
37
37
|
return [key, `${spec.format(value)}${note}`, source(key) === 'settings' ? '' : '(default)']
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
// Keys
|
|
40
|
+
// Keys telstore does not know are left in the file untouched, but a value that silently
|
|
41
41
|
// does nothing is worse than one that is refused: show them, so a typo is visible from
|
|
42
42
|
// the command rather than only from opening the file that also holds the session.
|
|
43
43
|
const strays = Object.keys(stored).filter((key) => !SETTING_KEYS.includes(key))
|
|
@@ -50,8 +50,8 @@ function listLines(values, source, stored) {
|
|
|
50
50
|
if (strays.length > 0) {
|
|
51
51
|
lines.push('')
|
|
52
52
|
lines.push(
|
|
53
|
-
`Ignored,
|
|
54
|
-
'Remove one with: npx
|
|
53
|
+
`Ignored, telstore does not know these: ${strays.join(', ')}. ` +
|
|
54
|
+
'Remove one with: npx telstore config <name> --unset',
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -65,21 +65,21 @@ export async function runConfig(args = [], options = {}, deps = {}) {
|
|
|
65
65
|
if (extra.length > 0) {
|
|
66
66
|
throw new Error(
|
|
67
67
|
`Too many arguments: a setting takes one value, but got ${args.length}. ` +
|
|
68
|
-
`Did you mean: npx
|
|
68
|
+
`Did you mean: npx telstore config ${name} "${[value, ...extra].join(' ')}"`,
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// Both of these would otherwise be obeyed halfway and reported as a success: a listing
|
|
73
73
|
// that quietly dropped the --unset, or an unset that quietly dropped the value beside it.
|
|
74
74
|
if (options.unset && name === undefined) {
|
|
75
|
-
throw new Error('--unset needs the setting to drop. Try: npx
|
|
75
|
+
throw new Error('--unset needs the setting to drop. Try: npx telstore config chat --unset')
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
if (options.unset && value !== undefined) {
|
|
79
79
|
throw new Error(
|
|
80
80
|
`--unset takes no value, but "${value}" was given. ` +
|
|
81
|
-
`Use "npx
|
|
82
|
-
`or "npx
|
|
81
|
+
`Use "npx telstore config ${name} --unset" to drop it, ` +
|
|
82
|
+
`or "npx telstore config ${name} ${value}" to set it.`,
|
|
83
83
|
)
|
|
84
84
|
}
|
|
85
85
|
|
package/src/commands/delete.js
CHANGED
|
@@ -56,7 +56,7 @@ function stateMessageIds(record) {
|
|
|
56
56
|
throw new Error(
|
|
57
57
|
`The record of unfinished backup ${record.state.id} gives ` +
|
|
58
58
|
`${JSON.stringify(msgId)} as the message id of chunk ${Number(index) + 1}, which ` +
|
|
59
|
-
`is not a message id. ${record.file} is damaged, so
|
|
59
|
+
`is not a message id. ${record.file} is damaged, so telstore is not deleting ` +
|
|
60
60
|
'anything.',
|
|
61
61
|
)
|
|
62
62
|
}
|
|
@@ -106,7 +106,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
106
106
|
if (records.length > 1) {
|
|
107
107
|
throw new Error(
|
|
108
108
|
`Two local records both claim to be backup ${backupId}: ` +
|
|
109
|
-
`${records.map((r) => r.file).join(' and ')}.
|
|
109
|
+
`${records.map((r) => r.file).join(' and ')}. telstore will not guess which one to ` +
|
|
110
110
|
'drop — remove the wrong one by hand and run again.',
|
|
111
111
|
)
|
|
112
112
|
}
|
|
@@ -120,7 +120,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
120
120
|
if (!manifestMessage && !record) {
|
|
121
121
|
throw new Error(
|
|
122
122
|
`No backup ${backupId} found in ${chatName(chat)}, and no unfinished record of it on ` +
|
|
123
|
-
'this machine. Check the id with "npx
|
|
123
|
+
'this machine. Check the id with "npx telstore list", or use --to to point at the ' +
|
|
124
124
|
'right chat.',
|
|
125
125
|
)
|
|
126
126
|
}
|
|
@@ -131,7 +131,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
131
131
|
if (manifestMessage) {
|
|
132
132
|
manifest = parseManifestJson(await readMessageBytes(client, manifestMessage))
|
|
133
133
|
|
|
134
|
-
// The manifest was found by the file name
|
|
134
|
+
// The manifest was found by the file name telstore itself wrote, and that name is the
|
|
135
135
|
// id this command was asked about. A body naming a different backup is a file that was
|
|
136
136
|
// renamed or replaced, and its message ids point at somebody else's chunks — the one
|
|
137
137
|
// mistake in this whole command that nothing can undo.
|
|
@@ -139,7 +139,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
139
139
|
throw new Error(
|
|
140
140
|
`The manifest named ${manifestFileName(backupId)} describes backup ` +
|
|
141
141
|
`${JSON.stringify(manifest.id)}, not ${backupId}. Its message ids point at ` +
|
|
142
|
-
'another backup\'s chunks, so
|
|
142
|
+
'another backup\'s chunks, so telstore is not deleting anything.',
|
|
143
143
|
)
|
|
144
144
|
}
|
|
145
145
|
|
package/src/commands/list.js
CHANGED
|
@@ -36,7 +36,7 @@ function utcDay(unixSeconds) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// The card is text in a chat, which means a person can edit or predate it. The id is the
|
|
39
|
-
// one field restore cannot be wrong about, so it always comes from the file name
|
|
39
|
+
// one field restore cannot be wrong about, so it always comes from the file name telstore
|
|
40
40
|
// wrote; the caption only decorates. A card that cannot be read back leaves the rest
|
|
41
41
|
// unknown, because inventing it would describe a backup that does not exist.
|
|
42
42
|
function toRow(message) {
|
|
@@ -106,14 +106,14 @@ export async function runList(options = {}, deps = {}) {
|
|
|
106
106
|
.map(toRow)
|
|
107
107
|
|
|
108
108
|
if (rows.length === 0) {
|
|
109
|
-
log(`No backups found in ${chatName(chat)}. Upload one with: npx
|
|
109
|
+
log(`No backups found in ${chatName(chat)}. Upload one with: npx telstore <file>`)
|
|
110
110
|
return rows
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
for (const line of renderTable(rows)) log(line)
|
|
114
114
|
|
|
115
115
|
log('')
|
|
116
|
-
log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx
|
|
116
|
+
log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx telstore restore <backup-id>`)
|
|
117
117
|
|
|
118
118
|
return rows
|
|
119
119
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
import readline from 'node:readline/promises'
|
|
2
|
-
import { stdin, stdout } from 'node:process'
|
|
3
|
-
|
|
4
1
|
import { TelegramClient } from 'telegram'
|
|
5
2
|
import { StringSession } from 'telegram/sessions/index.js'
|
|
6
3
|
|
|
7
4
|
import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
|
|
8
5
|
import { normalizeChatTarget } from '../chat.js'
|
|
9
|
-
import { createLogger } from '../client.js'
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
14
|
-
return {
|
|
15
|
-
ask: (question) => rl.question(question),
|
|
16
|
-
close: () => rl.close(),
|
|
17
|
-
}
|
|
18
|
-
}
|
|
6
|
+
import { closeQuietly, connect as realConnect, createLogger } from '../client.js'
|
|
7
|
+
import { createPrompts } from '../prompt.js'
|
|
8
|
+
import { knownSettings, resolveSettings } from '../settings.js'
|
|
9
|
+
import { decodeToken, isSealedToken } from '../token.js'
|
|
19
10
|
|
|
20
11
|
const LOGIN_ERROR_MESSAGES = {
|
|
21
12
|
PHONE_NUMBER_INVALID: 'invalid phone number',
|
|
@@ -43,14 +34,96 @@ export function describeLoginError(err) {
|
|
|
43
34
|
const createTelegramClient = (apiId, apiHash, options) =>
|
|
44
35
|
new TelegramClient(new StringSession(''), apiId, apiHash, options)
|
|
45
36
|
|
|
37
|
+
// The token never reaches here as an argument, and telstore refuses to pretend otherwise: a
|
|
38
|
+
// token on the command line sits in the shell history of a machine the user does not trust,
|
|
39
|
+
// and ignoring it silently would leave it there for nothing.
|
|
40
|
+
function refuseArguments(args) {
|
|
41
|
+
if (args.length === 0) return
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
'login takes no arguments. A session token is pasted at a prompt, never written on the ' +
|
|
45
|
+
'command line — there it stays in this machine\'s shell history and is visible in "ps" ' +
|
|
46
|
+
'for as long as the command runs. Run "npx telstore login --token" and paste it when asked.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Logging in with a token telstore printed elsewhere. The blob is stored exactly as it
|
|
51
|
+
// arrived rather than opened and written back out: what makes this worth doing is that the
|
|
52
|
+
// session never exists on this machine's disk in a form anyone can read.
|
|
53
|
+
async function loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log }) {
|
|
54
|
+
const token = (await prompts.askSecret('Session token: ')).trim()
|
|
55
|
+
const passphrase = isSealedToken(token) ? await prompts.askSecret('Passphrase for the token: ') : ''
|
|
56
|
+
|
|
57
|
+
// Opened here, before anything is written, so a wrong passphrase is a sentence now rather
|
|
58
|
+
// than a failure at the start of a restore that was going to take twenty minutes.
|
|
59
|
+
const bundle = await decodeToken(token, passphrase)
|
|
60
|
+
const account = { apiId: bundle.apiId, apiHash: bundle.apiHash, session: bundle.session }
|
|
61
|
+
|
|
62
|
+
// A token says what the session was when it was made. Only Telegram can say whether that
|
|
63
|
+
// session is still alive, and finding out now is the difference between a login that failed
|
|
64
|
+
// and a login that appeared to work.
|
|
65
|
+
const client = await connectWith(account, { verbose })
|
|
66
|
+
|
|
67
|
+
let me
|
|
68
|
+
try {
|
|
69
|
+
me = await client.getMe()
|
|
70
|
+
} finally {
|
|
71
|
+
await closeQuietly(client, shutdown)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const config = await loadConfig(configDir)
|
|
75
|
+
// Built fresh rather than merged over what was there: a config holding both a sealed
|
|
76
|
+
// session and a plain one is refused on the next run, and merging is how it would come to
|
|
77
|
+
// hold both. Settings are the exception, because they are nobody's secret.
|
|
78
|
+
const next = passphrase === '' ? { ...account } : { sealed: token }
|
|
79
|
+
const settings = { ...config.settings, ...knownSettings(bundle.settings) }
|
|
80
|
+
|
|
81
|
+
if (Object.keys(settings).length > 0) next.settings = settings
|
|
82
|
+
|
|
83
|
+
await saveConfig(next, configDir)
|
|
84
|
+
|
|
85
|
+
log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
|
|
86
|
+
|
|
87
|
+
if (passphrase === '') {
|
|
88
|
+
log(
|
|
89
|
+
`Config saved to ${configDir}/config.json. This token had no passphrase, so the session ` +
|
|
90
|
+
'is stored here in plain text, exactly as an ordinary login would store it.',
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
log(
|
|
96
|
+
`Config saved to ${configDir}/config.json, with the session sealed behind your ` +
|
|
97
|
+
'passphrase. Every command that talks to Telegram will ask for it.',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
export async function runLogin({
|
|
47
102
|
configDir = defaultConfigDir(),
|
|
48
|
-
|
|
103
|
+
args = [],
|
|
104
|
+
token = false,
|
|
105
|
+
prompts = null,
|
|
106
|
+
connectWith = realConnect,
|
|
49
107
|
verbose = false,
|
|
50
108
|
createClient = createTelegramClient,
|
|
51
109
|
shutdown = (client) => client.destroy(),
|
|
52
110
|
log = (line) => console.log(line),
|
|
53
111
|
} = {}) {
|
|
112
|
+
refuseArguments(args)
|
|
113
|
+
|
|
114
|
+
// Opened here and not in the parameter list: a default argument runs on every call, so the
|
|
115
|
+
// refusal above would open a readline on stdin it never reads from and the process would
|
|
116
|
+
// hang with nothing left to do.
|
|
117
|
+
prompts = prompts ?? createPrompts()
|
|
118
|
+
|
|
119
|
+
if (token) {
|
|
120
|
+
try {
|
|
121
|
+
return await loginWithToken({ configDir, prompts, connectWith, shutdown, verbose, log })
|
|
122
|
+
} finally {
|
|
123
|
+
prompts.close()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
54
127
|
const config = await loadConfig(configDir)
|
|
55
128
|
const storedChat = config.settings?.chat
|
|
56
129
|
const loud = verbose || resolveSettings({}, config).values.verbose
|
|
@@ -80,7 +153,7 @@ export async function runLogin({
|
|
|
80
153
|
await client.start({
|
|
81
154
|
phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
|
|
82
155
|
phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
|
|
83
|
-
password: () => prompts.
|
|
156
|
+
password: () => prompts.askSecret('Two-step password (leave blank if not enabled): '),
|
|
84
157
|
onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
|
|
85
158
|
})
|
|
86
159
|
|
package/src/commands/logout.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import { clearSession, defaultConfigDir } from '../config.js'
|
|
1
|
+
import { clearSession, defaultConfigDir, loadConfig } from '../config.js'
|
|
2
|
+
|
|
3
|
+
// The same true fact in both cases — the session outlives this machine — but not the same
|
|
4
|
+
// sentence about what is left behind. An ordinary login keeps the api_id and api_hash beside
|
|
5
|
+
// the session, and a sealed one keeps them inside it, so there the api_hash goes too. Saying
|
|
6
|
+
// otherwise would describe a machine other than the one in front of the reader.
|
|
7
|
+
export async function runLogout({ configDir = defaultConfigDir(), log = (line) => console.log(line) } = {}) {
|
|
8
|
+
const { sealed } = await loadConfig(configDir)
|
|
2
9
|
|
|
3
|
-
export async function runLogout({ configDir = defaultConfigDir() } = {}) {
|
|
4
10
|
await clearSession(configDir)
|
|
5
|
-
|
|
6
|
-
|
|
11
|
+
|
|
12
|
+
log(
|
|
13
|
+
sealed
|
|
14
|
+
? 'Removed the sealed session stored on this machine. The api_id and api_hash were ' +
|
|
15
|
+
'inside it, so they are gone with it; the destination is kept.'
|
|
16
|
+
: 'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
|
|
7
17
|
)
|
|
8
|
-
|
|
18
|
+
log(
|
|
9
19
|
'Note: this only deletes the local copy — the session is still alive on Telegram\'s side. ' +
|
|
10
20
|
'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
|
|
11
21
|
'and terminate that session.',
|
package/src/commands/restore.js
CHANGED
|
@@ -28,8 +28,8 @@ async function realGetMessage(client, peer, msgId) {
|
|
|
28
28
|
return message ?? null
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function realDownloadChunk(client, message, handle, offset, onProgress,
|
|
32
|
-
return await downloadToFile(client, message, handle.fd, { offset, onProgress,
|
|
31
|
+
export async function realDownloadChunk(client, message, handle, offset, onProgress, options) {
|
|
32
|
+
return await downloadToFile(client, message, handle.fd, { offset, onProgress, ...options })
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
@@ -78,7 +78,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
78
78
|
if (delayMs > LONG_WAIT_MS) {
|
|
79
79
|
warn(
|
|
80
80
|
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
|
|
81
|
-
`(${err.message}).
|
|
81
|
+
`(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
|
|
82
82
|
)
|
|
83
83
|
return
|
|
84
84
|
}
|
|
@@ -112,7 +112,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
112
112
|
const partial = `${target}.partial`
|
|
113
113
|
|
|
114
114
|
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
115
|
-
// would have
|
|
115
|
+
// would have telstore overwrite the user's file without asking.
|
|
116
116
|
let exists = true
|
|
117
117
|
try {
|
|
118
118
|
await fs.stat(target)
|
|
@@ -165,7 +165,10 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
165
165
|
handle,
|
|
166
166
|
chunk.i * manifest.chunkSize,
|
|
167
167
|
progress.advance,
|
|
168
|
-
{
|
|
168
|
+
{
|
|
169
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
170
|
+
concurrency: settings.downloadConcurrency,
|
|
171
|
+
},
|
|
169
172
|
)
|
|
170
173
|
|
|
171
174
|
if (size !== chunk.size) {
|
package/src/commands/status.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
|
-
|
|
3
1
|
import { countChunks } from '../chunking.js'
|
|
4
2
|
import { describeChat } from '../chat.js'
|
|
5
3
|
import { assertLoggedIn, closeQuietly, connect as realConnect } from '../client.js'
|
|
6
4
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
7
5
|
import { formatBytes } from '../progress.js'
|
|
8
6
|
import { resolveSettings } from '../settings.js'
|
|
9
|
-
import { listStates } from '../state.js'
|
|
7
|
+
import { canResume, listStates } from '../state.js'
|
|
10
8
|
|
|
11
9
|
const LABEL_WIDTH = 'Destination'.length + 2
|
|
12
10
|
|
|
@@ -14,6 +12,66 @@ function row(label, value) {
|
|
|
14
12
|
return `${label.padEnd(LABEL_WIDTH)}${value}`
|
|
15
13
|
}
|
|
16
14
|
|
|
15
|
+
// Each unfinished backup gets its own indented block, so the fields line up under a heading
|
|
16
|
+
// that is the id — the one string `restore` and `delete` both take.
|
|
17
|
+
const FIELD_WIDTH = 'Resume'.length + 3
|
|
18
|
+
const CONTINUATION = ' '.repeat(FIELD_WIDTH + 2)
|
|
19
|
+
|
|
20
|
+
function field(label, value) {
|
|
21
|
+
return ` ${label.padEnd(FIELD_WIDTH)}${value}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The Resume line is a command meant to be pasted, so anything a shell would take apart has
|
|
25
|
+
// to come back quoted — a path with a space in it is the ordinary case, not an exotic one.
|
|
26
|
+
const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
|
|
27
|
+
|
|
28
|
+
function shellArg(text) {
|
|
29
|
+
const value = String(text)
|
|
30
|
+
|
|
31
|
+
return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// runUpload refuses to send the rest of a backup to a different chat, so the command has to
|
|
35
|
+
// name the one the chunks are already in — unless the destination in force is that chat
|
|
36
|
+
// anyway, where --to would just be noise. Not knowing the destination counts as not matching:
|
|
37
|
+
// leaving --to out would be a guess about where a backup already in progress went.
|
|
38
|
+
function resumeCommand(state, destination) {
|
|
39
|
+
const matches = destination !== null && state.chat === String(destination)
|
|
40
|
+
|
|
41
|
+
return `npx telstore ${shellArg(state.path)}${matches ? '' : ` --to ${shellArg(state.chat)}`}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Why a resume is off the table, in the words of the thing the user would have to fix. The
|
|
45
|
+
// record is keyed on the file's path, size and mtime, so any of these means runUpload would
|
|
46
|
+
// hash the file to a different key, find nothing, and start a second backup instead.
|
|
47
|
+
const NO_RESUME = {
|
|
48
|
+
missing: 'the file is no longer there',
|
|
49
|
+
changed: 'the file has changed since the backup started',
|
|
50
|
+
'not-a-file': 'that path is no longer a file',
|
|
51
|
+
unreadable: 'the record does not name a file that can be read',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A command is printed only when it will really resume. Printing one regardless would be
|
|
55
|
+
// telling the user to run something that quietly starts a second backup and abandons every
|
|
56
|
+
// chunk this one already sent — and those chunks are then findable only by this id, which
|
|
57
|
+
// is worth saying while there are any.
|
|
58
|
+
async function resumeLines(key, state, destination, done) {
|
|
59
|
+
const check = await canResume(key, state)
|
|
60
|
+
|
|
61
|
+
if (check.ok) return [field('Resume', resumeCommand(state, destination))]
|
|
62
|
+
|
|
63
|
+
const lines = [field('Resume', `not possible: ${NO_RESUME[check.reason]}.`)]
|
|
64
|
+
|
|
65
|
+
if (done > 0) {
|
|
66
|
+
lines.push(
|
|
67
|
+
`${CONTINUATION}${done} chunk${done === 1 ? ' is' : 's are'} already in the chat, ` +
|
|
68
|
+
'searchable by this id.',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines
|
|
73
|
+
}
|
|
74
|
+
|
|
17
75
|
function describeAccount(me) {
|
|
18
76
|
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
19
77
|
const handle = me.username ? ` (@${me.username})` : ''
|
|
@@ -72,13 +130,24 @@ export async function runStatus(options = {}, deps = {}) {
|
|
|
72
130
|
settingsError = err.message
|
|
73
131
|
}
|
|
74
132
|
|
|
133
|
+
// Which config this report is about, and whether the session in it can be read. Printed
|
|
134
|
+
// unconditionally: status never said where it looked before, and a row that shows up only
|
|
135
|
+
// when something is unusual reads as a warning rather than as a fact.
|
|
136
|
+
log(
|
|
137
|
+
row(
|
|
138
|
+
'Session',
|
|
139
|
+
config.sealed
|
|
140
|
+
? `${configFile(configDir)} (sealed — opened with a passphrase)`
|
|
141
|
+
: configFile(configDir),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
75
144
|
log(row('Account', await accountLine(config, settings?.verbose ?? false, { connect, disconnect })))
|
|
76
145
|
log(
|
|
77
146
|
row(
|
|
78
147
|
'Destination',
|
|
79
148
|
settingsError ??
|
|
80
149
|
(settings.chat === null
|
|
81
|
-
? 'none set — run "npx
|
|
150
|
+
? 'none set — run "npx telstore config chat @my_backups" to set one'
|
|
82
151
|
: describeChat(settings.chat)),
|
|
83
152
|
),
|
|
84
153
|
)
|
|
@@ -92,11 +161,20 @@ export async function runStatus(options = {}, deps = {}) {
|
|
|
92
161
|
|
|
93
162
|
log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
|
|
94
163
|
|
|
95
|
-
|
|
164
|
+
// The destination is what decides whether the resume command needs a --to. A row that
|
|
165
|
+
// failed to parse leaves nothing to compare against, which is not the same as a match.
|
|
166
|
+
const destination = settings?.chat ?? null
|
|
167
|
+
|
|
168
|
+
for (const { key, state } of states) {
|
|
96
169
|
const total = countChunks(state.size, state.chunkSize)
|
|
97
170
|
const done = Object.keys(state.done ?? {}).length
|
|
98
171
|
|
|
99
|
-
log(
|
|
100
|
-
log(`
|
|
172
|
+
log('')
|
|
173
|
+
log(` ${state.id}`)
|
|
174
|
+
log(field('File', `${state.path} (${formatBytes(state.size)})`))
|
|
175
|
+
log(field('Chunks', `${done} of ${total} uploaded`))
|
|
176
|
+
log(field('Chat', describeChat(state.chat)))
|
|
177
|
+
|
|
178
|
+
for (const line of await resumeLines(key, state, destination, done)) log(line)
|
|
101
179
|
}
|
|
102
180
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { assertLoggedIn } from '../client.js'
|
|
2
|
+
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
3
|
+
import { createPrompts } from '../prompt.js'
|
|
4
|
+
import { unlockConfig } from '../session.js'
|
|
5
|
+
import { knownSettings } from '../settings.js'
|
|
6
|
+
import { encodeToken } from '../token.js'
|
|
7
|
+
|
|
8
|
+
// Short enough that whoever holds the token can work through the possibilities faster than
|
|
9
|
+
// scrypt can slow them down. Not a rule — a length minimum is a preference wearing a check's
|
|
10
|
+
// clothes, and the main thing one teaches is to append digits — so this warns and goes on.
|
|
11
|
+
const SHORT_PASSPHRASE = 12
|
|
12
|
+
|
|
13
|
+
export async function runToken(args = [], options = {}, deps = {}) {
|
|
14
|
+
const {
|
|
15
|
+
configDir = defaultConfigDir(),
|
|
16
|
+
prompts = null,
|
|
17
|
+
log = (line) => console.log(line),
|
|
18
|
+
writeErr = (line) => process.stderr.write(line),
|
|
19
|
+
} = deps
|
|
20
|
+
|
|
21
|
+
const config = await loadConfig(configDir)
|
|
22
|
+
|
|
23
|
+
// Before anything is asked for. Someone who has never logged in should be told that, not
|
|
24
|
+
// asked to invent a passphrase for an account that is not there.
|
|
25
|
+
assertLoggedIn(config)
|
|
26
|
+
|
|
27
|
+
// Opened here rather than in the parameter list, so the command that refuses above never
|
|
28
|
+
// touches stdin. Every question in this run goes through this one interface: asking each
|
|
29
|
+
// through its own would leave the second one at end-of-input having read nothing.
|
|
30
|
+
const ask = prompts ?? createPrompts({ output: process.stderr })
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await mint(config, ask, { log, writeErr })
|
|
34
|
+
} finally {
|
|
35
|
+
ask.close()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function mint(config, ask, { log, writeErr }) {
|
|
40
|
+
const readSecret = (question) => ask.askSecret(question)
|
|
41
|
+
const account = await unlockConfig(config, { readSecret })
|
|
42
|
+
|
|
43
|
+
const passphrase = await readSecret('Passphrase to protect the token: ')
|
|
44
|
+
const again = await readSecret('Repeat it: ')
|
|
45
|
+
|
|
46
|
+
if (passphrase !== again) {
|
|
47
|
+
throw new Error('The two passphrases are different. Nothing was printed — run the command again.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (passphrase === '') {
|
|
51
|
+
writeErr(
|
|
52
|
+
'This token has no passphrase, so anyone who reads it can use your Telegram account. ' +
|
|
53
|
+
'Do not send it through anything that keeps a copy.\n',
|
|
54
|
+
)
|
|
55
|
+
} else if (passphrase.length < SHORT_PASSPHRASE) {
|
|
56
|
+
writeErr(
|
|
57
|
+
`That passphrase is ${passphrase.length} characters. Whoever holds the token can try ` +
|
|
58
|
+
'passphrases offline as fast as their hardware allows, and telstore can only make ' +
|
|
59
|
+
'each attempt cost about half a second.\n',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The same true fact logout already states, said the same way: telstore cannot take a token
|
|
64
|
+
// back, and the session it carries outlives every copy of the token.
|
|
65
|
+
writeErr(
|
|
66
|
+
'This token carries your Telegram session. telstore cannot take it back — as with ' +
|
|
67
|
+
'logout, the session stays alive on Telegram\'s side until you open Telegram → ' +
|
|
68
|
+
'Settings → Devices (Active sessions) and terminate it. It is about to be printed ' +
|
|
69
|
+
'here, so it will sit in this terminal\'s scrollback until you clear it.\n\n',
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// stdout carries the token and nothing else, the rule `config <name>` already follows, so
|
|
73
|
+
// this can be piped into a QR encoder with every warning above still on the screen.
|
|
74
|
+
log(
|
|
75
|
+
await encodeToken(
|
|
76
|
+
{ ...account, settings: knownSettings(config.settings) },
|
|
77
|
+
passphrase,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
}
|
package/src/commands/upload.js
CHANGED
|
@@ -95,7 +95,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
95
95
|
file: configFile(configDir),
|
|
96
96
|
})
|
|
97
97
|
const chat = requireChat(settings)
|
|
98
|
-
const concurrency = settings.
|
|
98
|
+
const concurrency = settings.uploadConcurrency
|
|
99
99
|
|
|
100
100
|
const key = stateKey(absPath, stat.size, stat.mtimeMs)
|
|
101
101
|
|
|
@@ -103,7 +103,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
103
103
|
|
|
104
104
|
// The chunks already in the chat were cut at the size this backup started with, and
|
|
105
105
|
// nothing can re-cut them. Carrying on at a different size would abandon every one of
|
|
106
|
-
// them in the chat, where
|
|
106
|
+
// them in the chat, where telstore can no longer find them — so an unfinished backup
|
|
107
107
|
// keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
|
|
108
108
|
//
|
|
109
109
|
// Only a flag is a disagreement. A configured chunkSize says what to use when nobody asks
|
|
@@ -164,10 +164,10 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
164
164
|
|
|
165
165
|
// Only a new backup adds to the directory, so this is the one place it can grow.
|
|
166
166
|
// The report goes out even when the caller asked for silence: this is not narration
|
|
167
|
-
// about a transfer, it is
|
|
167
|
+
// about a transfer, it is telstore dropping the only record of someone else's chunks.
|
|
168
168
|
for (const gone of await pruneStates(configDir)) {
|
|
169
169
|
writeErr(
|
|
170
|
-
`\nDropped the record of unfinished backup ${gone.id}:
|
|
170
|
+
`\nDropped the record of unfinished backup ${gone.id}: telstore keeps the ` +
|
|
171
171
|
`${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
|
|
172
172
|
'searchable by that id, but that backup can no longer be resumed.\n',
|
|
173
173
|
)
|
|
@@ -185,7 +185,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
185
185
|
if (delayMs > LONG_WAIT_MS) {
|
|
186
186
|
warn(
|
|
187
187
|
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
|
|
188
|
-
`(${err.message}).
|
|
188
|
+
`(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
|
|
189
189
|
)
|
|
190
190
|
return
|
|
191
191
|
}
|
|
@@ -289,7 +289,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
289
289
|
throw new Error(
|
|
290
290
|
`${absPath} changed during the upload ` +
|
|
291
291
|
`(size ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
|
|
292
|
-
'This backup mixes old and new data and cannot be trusted —
|
|
292
|
+
'This backup mixes old and new data and cannot be trusted — telstore is not sending the manifest. ' +
|
|
293
293
|
'Wait until the file settles, then run again to create a new backup.',
|
|
294
294
|
)
|
|
295
295
|
}
|
|
@@ -316,7 +316,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
316
316
|
|
|
317
317
|
await clearState(key, configDir)
|
|
318
318
|
|
|
319
|
-
log(`\nDone. Restore with:\n npx
|
|
319
|
+
log(`\nDone. Restore with:\n npx telstore restore ${state.id}`)
|
|
320
320
|
|
|
321
321
|
return { id: state.id, chunks: chunks.length }
|
|
322
322
|
} finally {
|
package/src/config.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path'
|
|
|
5
5
|
const FILE_NAME = 'config.json'
|
|
6
6
|
|
|
7
7
|
export function defaultConfigDir() {
|
|
8
|
-
return path.join(os.homedir(), '.
|
|
8
|
+
return path.join(os.homedir(), '.telstore')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function configFile(dir = defaultConfigDir()) {
|
|
@@ -19,7 +19,7 @@ function isPlainObject(value) {
|
|
|
19
19
|
// `config.json` is hand-editable now that the `config` command invites people into it,
|
|
20
20
|
// which puts it in the same category as a manifest or a state file: believed only after it
|
|
21
21
|
// has been checked. A `settings` that is not an object would make every lookup below it
|
|
22
|
-
// return undefined, and
|
|
22
|
+
// return undefined, and telstore would then run happily on built-in defaults while the
|
|
23
23
|
// user's own choices sat there ignored — so it is named and refused instead.
|
|
24
24
|
export function checkConfigShape(raw, file) {
|
|
25
25
|
if (!isPlainObject(raw)) {
|
|
@@ -32,11 +32,31 @@ export function checkConfigShape(raw, file) {
|
|
|
32
32
|
if (raw.settings !== undefined && !isPlainObject(raw.settings)) {
|
|
33
33
|
throw new Error(
|
|
34
34
|
`"settings" in ${file} holds ${Array.isArray(raw.settings) ? 'a list' : typeof raw.settings}, ` +
|
|
35
|
-
'not a group of settings.
|
|
35
|
+
'not a group of settings. telstore will not guess what was meant — fix that entry, ' +
|
|
36
36
|
'or remove it to fall back to the defaults.',
|
|
37
37
|
)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// What `login --token` leaves behind: the session and the api_hash sealed inside one blob
|
|
41
|
+
// that only a passphrase opens. It is read by `connect`, never by anything that writes.
|
|
42
|
+
if (raw.sealed !== undefined && typeof raw.sealed !== 'string') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`"sealed" in ${file} holds ${Array.isArray(raw.sealed) ? 'a list' : typeof raw.sealed}, ` +
|
|
45
|
+
'not a sealed session. Log in again with "npx telstore login --token", or remove that ' +
|
|
46
|
+
'entry and log in with a phone number.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Two sources of truth for one account, and nothing to say which one was meant. Guessing
|
|
51
|
+
// would mean connecting as an account the user did not choose — either the stale one they
|
|
52
|
+
// thought they had replaced, or the one they thought they had left behind.
|
|
53
|
+
if (raw.sealed !== undefined && (raw.session !== undefined || raw.apiHash !== undefined)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${file} holds both a sealed session and a plain one. telstore will not guess which ` +
|
|
56
|
+
'account was meant — delete the file and log in again.',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
return raw
|
|
41
61
|
}
|
|
42
62
|
|
|
@@ -57,7 +77,7 @@ export async function loadConfig(dir = defaultConfigDir()) {
|
|
|
57
77
|
} catch (err) {
|
|
58
78
|
throw new Error(
|
|
59
79
|
`Corrupt config file: ${file} is not valid JSON (${err.message}). ` +
|
|
60
|
-
'Fix the syntax to keep your session, or delete the file and run "
|
|
80
|
+
'Fix the syntax to keep your session, or delete the file and run "telstore login" again.',
|
|
61
81
|
)
|
|
62
82
|
}
|
|
63
83
|
|
|
@@ -79,8 +99,12 @@ export async function saveConfig(config, dir = defaultConfigDir()) {
|
|
|
79
99
|
await writeJsonAtomic(configFile(dir), config)
|
|
80
100
|
}
|
|
81
101
|
|
|
102
|
+
// Both shapes go, because they are the same thing written two ways. On a machine that logged
|
|
103
|
+
// in with a token the api_hash lives inside the sealed blob, so keeping it "like an ordinary
|
|
104
|
+
// logout does" would keep the entire account.
|
|
82
105
|
export async function clearSession(dir = defaultConfigDir()) {
|
|
83
106
|
const config = await loadConfig(dir)
|
|
84
107
|
delete config.session
|
|
108
|
+
delete config.sealed
|
|
85
109
|
await saveConfig(config, dir)
|
|
86
110
|
}
|