telstore 0.1.1 → 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 +75 -2
- package/bin/telstore.js +14 -1
- package/package.json +1 -1
- package/src/chunking.js +14 -1
- package/src/cli.js +29 -16
- package/src/client.js +14 -2
- package/src/commands/login.js +88 -15
- package/src/commands/logout.js +15 -5
- package/src/commands/restore.js +6 -3
- package/src/commands/status.js +84 -6
- package/src/commands/token.js +80 -0
- package/src/commands/upload.js +1 -1
- package/src/config.js +24 -0
- package/src/downloader.js +2 -2
- package/src/prompt.js +81 -0
- package/src/session.js +24 -0
- package/src/settings.js +30 -4
- package/src/state.js +33 -2
- package/src/token.js +221 -0
- package/src/uploader.js +2 -2
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ npx telstore status # account, destination, unfinished bac
|
|
|
14
14
|
npx telstore list # what is already stored in the destination
|
|
15
15
|
npx telstore restore telstore-20260905-7f3a91
|
|
16
16
|
npx telstore delete telstore-20260905-7f3a91 # take it back out of the chat, for good
|
|
17
|
+
npx telstore token # a session token for a machine you do not trust
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## What you need
|
|
@@ -40,7 +41,8 @@ npx telstore config chunkSize --unset # back to the default
|
|
|
40
41
|
|---|---|---|---|
|
|
41
42
|
| `chat` | `--to` | none | `@username`, `-100123…`, or `me`. A negative channel id works with a space or an `=`, as a flag or as a config value. |
|
|
42
43
|
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
|
|
43
|
-
| `
|
|
44
|
+
| `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts sent in parallel while uploading. An integer from 1 to 64. |
|
|
45
|
+
| `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices fetched in parallel while restoring. An integer from 1 to 64. |
|
|
44
46
|
| `limit` | `--limit` | `20` | How many backups `list` shows, newest first |
|
|
45
47
|
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
|
|
46
48
|
|
|
@@ -127,10 +129,71 @@ manifest or local record giving a message id that is not a whole positive number
|
|
|
127
129
|
deletes anything at all. A manifest too damaged for `restore` to use *can* still be deleted —
|
|
128
130
|
that is usually the one you want gone.
|
|
129
131
|
|
|
132
|
+
## Running on a machine you do not trust
|
|
133
|
+
|
|
134
|
+
`login` leaves your Telegram session on the machine you run it on, in plain text. When that
|
|
135
|
+
machine is not yours, print a **session token** on one that is and log in with that instead:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# on the machine you are already logged in on
|
|
139
|
+
npx telstore token # asks for a passphrase, twice
|
|
140
|
+
# prints one line: tls1.…
|
|
141
|
+
|
|
142
|
+
# on the other machine
|
|
143
|
+
npx telstore login --token # paste the token, then type the passphrase
|
|
144
|
+
npx telstore restore telstore-20260905-7f3a91
|
|
145
|
+
npx telstore logout # when you are done
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The token holds your `api_id`, `api_hash`, session and settings, encrypted with the
|
|
149
|
+
passphrase (AES-256-GCM, key derived with scrypt). It is one line of base64url, so it
|
|
150
|
+
survives a chat message, an email or a QR code — and a copy that leaks without the
|
|
151
|
+
passphrase is not enough to use your account.
|
|
152
|
+
|
|
153
|
+
`login --token` stores the token exactly as it arrived, so **the session never exists on that
|
|
154
|
+
machine in a form anyone can read**. Every command that talks to Telegram asks for the
|
|
155
|
+
passphrase and opens it in memory:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`npx telstore status` shows which of the two you are running:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Session /home/you/.telstore/config.json (sealed — opened with a passphrase)
|
|
165
|
+
Account Sho (@shovity)
|
|
166
|
+
Destination https://web.telegram.org/k/#@my_backups
|
|
167
|
+
Unfinished none
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### What is left behind
|
|
171
|
+
|
|
172
|
+
- **No readable session.** `logout` removes the sealed blob, and with it the `api_hash`,
|
|
173
|
+
which lives inside it.
|
|
174
|
+
- **State files, yes.** An upload writes `~/.telstore/state/<key>.json` so it can be resumed
|
|
175
|
+
after an interruption. It holds the file path, the chunk sizes and the message ids — no
|
|
176
|
+
secret — and losing it would strand chunks in the chat with nothing able to name them.
|
|
177
|
+
|
|
178
|
+
### Things worth knowing before you use one
|
|
179
|
+
|
|
180
|
+
- **`--token` takes no value, on purpose.** A token written on the command line would sit in
|
|
181
|
+
`ps` for the whole life of the command and stay in that machine's shell history afterwards.
|
|
182
|
+
It is pasted at a prompt that does not echo it.
|
|
183
|
+
- **The passphrase may be left empty**, and then the token says so on its face: it starts
|
|
184
|
+
`tls0.` instead of `tls1.`, `token` warns, and `login` stores the session in plain text
|
|
185
|
+
exactly as an ordinary login would. telstore will not write a file that looks encrypted and
|
|
186
|
+
is not.
|
|
187
|
+
- **telstore cannot take a token back.** There is no expiry and no revocation. Like `logout`,
|
|
188
|
+
it says nothing to Telegram — to end the session for good, open Telegram → Settings →
|
|
189
|
+
Devices (Active sessions) and terminate it. Every copy of the token dies with it.
|
|
190
|
+
- **A wrong passphrase and a damaged token are the same event** to AES-GCM: one failed
|
|
191
|
+
authentication check. telstore names both possibilities rather than guessing which it was.
|
|
192
|
+
|
|
130
193
|
## Limits worth knowing
|
|
131
194
|
|
|
132
195
|
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and telstore stops at 1950MB to leave a safety margin.
|
|
133
|
-
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
|
|
196
|
+
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure. The one thing telstore does encrypt is a session token, and that protects your login rather than your files.
|
|
134
197
|
- Deleting a chunk message on Telegram destroys the backup, with no way to recover it. Use `npx telstore delete <backup-id>` when that is what you actually want.
|
|
135
198
|
- Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
|
|
136
199
|
|
|
@@ -147,6 +210,16 @@ that is usually the one you want gone.
|
|
|
147
210
|
}
|
|
148
211
|
```
|
|
149
212
|
|
|
213
|
+
A machine logged in with `npx telstore login --token` holds `sealed` instead of those three
|
|
214
|
+
fields, and nothing else about the account:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A file holding both shapes is refused rather than guessed at — there would be no way to know
|
|
221
|
+
which account was meant.
|
|
222
|
+
|
|
150
223
|
Editing it by hand is fine, and a value that cannot be used is named on the next run — with the file and the key, never with a flag you did not type.
|
|
151
224
|
|
|
152
225
|
`npx telstore logout` **only deletes the locally stored session** and keeps the rest — the session is still alive on Telegram's side. To revoke access for good, open Telegram → Settings → Devices (Active sessions) and terminate that session.
|
package/bin/telstore.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runList } from '../src/commands/list.js'
|
|
|
7
7
|
import { runLogout } from '../src/commands/logout.js'
|
|
8
8
|
import { runRestore } from '../src/commands/restore.js'
|
|
9
9
|
import { runStatus } from '../src/commands/status.js'
|
|
10
|
+
import { runToken } from '../src/commands/token.js'
|
|
10
11
|
import { runUpload } from '../src/commands/upload.js'
|
|
11
12
|
|
|
12
13
|
const SIGINT_EXIT_CODE = 130
|
|
@@ -18,6 +19,10 @@ let currentCommand = null
|
|
|
18
19
|
let currentBackupId = null
|
|
19
20
|
|
|
20
21
|
process.on('SIGINT', () => {
|
|
22
|
+
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
23
|
+
// Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
|
|
24
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
|
+
|
|
21
26
|
process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
|
|
22
27
|
process.exit(SIGINT_EXIT_CODE)
|
|
23
28
|
})
|
|
@@ -41,7 +46,11 @@ async function main() {
|
|
|
41
46
|
return
|
|
42
47
|
|
|
43
48
|
case 'login':
|
|
44
|
-
await runLogin({
|
|
49
|
+
await runLogin({
|
|
50
|
+
args: parsed.args,
|
|
51
|
+
token: Boolean(parsed.options.token),
|
|
52
|
+
verbose: Boolean(parsed.options.verbose),
|
|
53
|
+
})
|
|
45
54
|
return
|
|
46
55
|
|
|
47
56
|
case 'logout':
|
|
@@ -60,6 +69,10 @@ async function main() {
|
|
|
60
69
|
await runConfig(parsed.args, parsed.options)
|
|
61
70
|
return
|
|
62
71
|
|
|
72
|
+
case 'token':
|
|
73
|
+
await runToken(parsed.args, parsed.options)
|
|
74
|
+
return
|
|
75
|
+
|
|
63
76
|
case 'upload':
|
|
64
77
|
await runUpload(parsed.args[0], parsed.options, {
|
|
65
78
|
onBackupId: (id) => {
|
package/package.json
CHANGED
package/src/chunking.js
CHANGED
|
@@ -7,7 +7,20 @@ export const MAX_PARTS = 4000
|
|
|
7
7
|
export const SLICE_SIZE = 8 * 1024 * 1024
|
|
8
8
|
export const MAX_CHUNK_SIZE = 1950 * 1024 * 1024
|
|
9
9
|
export const DEFAULT_CHUNK_SIZE = 1800 * 1024 * 1024
|
|
10
|
-
|
|
10
|
+
// Upload counts 512KB parts and download counts 8MB slices, so one number cannot serve both.
|
|
11
|
+
// Measured on a 1Gb/s line against a single 1800MB chunk, so every run transfers for three
|
|
12
|
+
// to five minutes rather than the seconds a burst benchmark samples — the distinction the
|
|
13
|
+
// parallel-download spec insists on, and it matters: measured in 20s bursts the download
|
|
14
|
+
// order came out reversed, with 4 apparently the fastest value.
|
|
15
|
+
// upload 16 -> 7.7 MB/s (234s), 32 -> 9.4 (193s), 64 -> 9.5 (191s)
|
|
16
|
+
// download 4 -> 5.8 MB/s (311s), 8 -> 6.0 (300s), 16 -> 6.0 (302s)
|
|
17
|
+
// Upload takes 32 because 64 measures the same speed (1% apart, inside the noise) at twice
|
|
18
|
+
// the cost: 64 puts 32MB in flight, and a batch's last part then needs 4.4 Mbps to land
|
|
19
|
+
// before the 60s stall deadline, against 2.1 Mbps at 32. A slow link would be told its
|
|
20
|
+
// transfer stalled while it was merely slow, which is the one thing that deadline must not say.
|
|
21
|
+
// Download takes 8 for the same reason from the other side: 16 buys nothing and 4 is slower.
|
|
22
|
+
export const DEFAULT_UPLOAD_CONCURRENCY = 32
|
|
23
|
+
export const DEFAULT_DOWNLOAD_CONCURRENCY = 8
|
|
11
24
|
export const MAX_CONCURRENCY = 64
|
|
12
25
|
|
|
13
26
|
// Every chunk is one message in the chat and one entry in the manifest, so a plan this long
|
package/src/cli.js
CHANGED
|
@@ -8,18 +8,21 @@ const SUBCOMMANDS = new Set([
|
|
|
8
8
|
'delete',
|
|
9
9
|
'status',
|
|
10
10
|
'config',
|
|
11
|
+
'token',
|
|
11
12
|
'help',
|
|
12
13
|
])
|
|
13
14
|
|
|
14
15
|
const OPTIONS = {
|
|
15
16
|
to: { type: 'string' },
|
|
16
17
|
'chunk-size': { type: 'string' },
|
|
17
|
-
concurrency: { type: 'string' },
|
|
18
|
+
'upload-concurrency': { type: 'string' },
|
|
19
|
+
'download-concurrency': { type: 'string' },
|
|
18
20
|
out: { type: 'string' },
|
|
19
21
|
limit: { type: 'string' },
|
|
20
22
|
verbose: { type: 'boolean' },
|
|
21
23
|
unset: { type: 'boolean' },
|
|
22
24
|
yes: { type: 'boolean' },
|
|
25
|
+
token: { type: 'boolean' },
|
|
23
26
|
help: { type: 'boolean', short: 'h' },
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -35,28 +38,38 @@ Usage:
|
|
|
35
38
|
npx telstore config Show every setting and where its value comes from
|
|
36
39
|
npx telstore logout Remove the saved session
|
|
37
40
|
|
|
41
|
+
Running on a machine you do not trust:
|
|
42
|
+
npx telstore token Print a session token for another machine
|
|
43
|
+
npx telstore login --token Log in there by pasting one, session stays sealed
|
|
44
|
+
|
|
38
45
|
Settings:
|
|
39
46
|
npx telstore config <name> Print one setting's value
|
|
40
47
|
npx telstore config <name> <value> Change it for good
|
|
41
48
|
npx telstore config <name> --unset Drop it and fall back to the default
|
|
42
49
|
|
|
43
|
-
chat
|
|
44
|
-
chunkSize
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
chat Where backups go: @username, -100123..., or me. No default.
|
|
51
|
+
chunkSize Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
52
|
+
uploadConcurrency 512KB parts sent in parallel, default 32, max 64.
|
|
53
|
+
downloadConcurrency 8MB slices fetched in parallel, default 8, max 64.
|
|
54
|
+
limit How many backups list shows, default 20.
|
|
55
|
+
verbose Show Telegram connection logs, default false.
|
|
48
56
|
|
|
49
57
|
Options apply to one run and are never saved. Use config to change a setting for good.
|
|
50
|
-
--to <chat>
|
|
51
|
-
--chunk-size <n>
|
|
52
|
-
|
|
53
|
-
--concurrency <n>
|
|
54
|
-
|
|
55
|
-
--out <path>
|
|
56
|
-
|
|
57
|
-
--
|
|
58
|
-
--
|
|
59
|
-
|
|
58
|
+
--to <chat> Destination for this run only.
|
|
59
|
+
--chunk-size <n> Chunk size for this run only. An unfinished backup keeps the
|
|
60
|
+
size it started with.
|
|
61
|
+
--upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
|
|
62
|
+
--download-concurrency <n> 8MB slices in parallel while restoring, this run only.
|
|
63
|
+
--out <path> Where to write the restored file. Defaults to the basename in
|
|
64
|
+
the manifest.
|
|
65
|
+
--limit <n> How many backups list shows this run.
|
|
66
|
+
--token Log in by pasting a session token. It takes no value on
|
|
67
|
+
purpose: a token written on the command line would sit in
|
|
68
|
+
"ps" for the whole life of the command, and stay in that
|
|
69
|
+
machine's shell history afterwards.
|
|
70
|
+
--yes Delete without asking to confirm first.
|
|
71
|
+
--verbose Show Telegram connection logs for this run.
|
|
72
|
+
-h, --help Show this help.
|
|
60
73
|
`
|
|
61
74
|
|
|
62
75
|
// What Ctrl-C means depends on the command that was running: upload has written every
|
package/src/client.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StringSession } from 'telegram/sessions/index.js'
|
|
|
5
5
|
|
|
6
6
|
import { manifestFileName } from './manifest.js'
|
|
7
7
|
import { withRetry } from './retry.js'
|
|
8
|
+
import { unlockConfig } from './session.js'
|
|
8
9
|
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
9
10
|
|
|
10
11
|
// GramJS narrates its version, every connection and every disconnect at info level, and
|
|
@@ -121,16 +122,27 @@ export async function closeQuietly(client, disconnect, onWarn) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
|
|
126
|
+
// "login --token" leaves, which holds the same three fields behind a passphrase. Checked
|
|
127
|
+
// before anything asks for that passphrase, so somebody who never logged in is told so rather
|
|
128
|
+
// than asked to type a secret for an account that is not there.
|
|
124
129
|
export function assertLoggedIn(config) {
|
|
130
|
+
if (config.sealed) return
|
|
131
|
+
|
|
125
132
|
if (!config.session || !config.apiId || !config.apiHash) {
|
|
126
133
|
throw new Error('Not logged in — run "npx telstore login" first.')
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
|
|
137
|
+
// Every command that needs Telegram comes through here, which makes this the one place a
|
|
138
|
+
// sealed session has to be opened. Doing it anywhere else would mean eight places to keep in
|
|
139
|
+
// step, and a ninth command would simply forget.
|
|
140
|
+
export async function connect(config, { verbose = false, unlock = unlockConfig } = {}) {
|
|
131
141
|
assertLoggedIn(config)
|
|
132
142
|
|
|
133
|
-
const
|
|
143
|
+
const { apiId, apiHash, session } = await unlock(config)
|
|
144
|
+
|
|
145
|
+
const client = new TelegramClient(new StringSession(session), apiId, apiHash, {
|
|
134
146
|
connectionRetries: 5,
|
|
135
147
|
floodSleepThreshold: 60,
|
|
136
148
|
baseLogger: createLogger(verbose),
|
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
|
|
@@ -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,6 +130,17 @@ 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(
|
|
@@ -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
|
|
package/src/config.js
CHANGED
|
@@ -37,6 +37,26 @@ export function checkConfigShape(raw, file) {
|
|
|
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
|
|
|
@@ -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
|
}
|
package/src/downloader.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promisify } from 'node:util'
|
|
|
4
4
|
|
|
5
5
|
import { returnBigInt } from 'telegram/Helpers.js'
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { DEFAULT_DOWNLOAD_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
|
|
8
8
|
import { withRetry } from './retry.js'
|
|
9
9
|
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
10
10
|
|
|
@@ -57,7 +57,7 @@ export async function downloadToFile(
|
|
|
57
57
|
client,
|
|
58
58
|
message,
|
|
59
59
|
fd,
|
|
60
|
-
{ offset, onProgress, retryOptions, concurrency =
|
|
60
|
+
{ offset, onProgress, retryOptions, concurrency = DEFAULT_DOWNLOAD_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
|
|
61
61
|
) {
|
|
62
62
|
const document = message?.media?.document
|
|
63
63
|
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import readline from 'node:readline/promises'
|
|
2
|
+
import { stdin, stdout, stderr } from 'node:process'
|
|
3
|
+
import { Writable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
// readline echoes what it reads through its own output, so putting a curtain in front of that
|
|
6
|
+
// output is what makes typing invisible.
|
|
7
|
+
function veiledOutput(output) {
|
|
8
|
+
let hidden = false
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
hide: () => {
|
|
12
|
+
hidden = true
|
|
13
|
+
},
|
|
14
|
+
show: () => {
|
|
15
|
+
hidden = false
|
|
16
|
+
},
|
|
17
|
+
// The question goes straight to the real output, past the curtain, so it stays on screen
|
|
18
|
+
// while the answer to it does not.
|
|
19
|
+
say: (text) => output.write(text),
|
|
20
|
+
stream: new Writable({
|
|
21
|
+
write(chunk, encoding, callback) {
|
|
22
|
+
if (!hidden) output.write(chunk, encoding)
|
|
23
|
+
callback()
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_TERMINAL =
|
|
30
|
+
'There is no terminal here to type a secret into. Run this where you can type it.'
|
|
31
|
+
|
|
32
|
+
// One readline for a whole conversation, some of whose answers must not stay on the screen.
|
|
33
|
+
// It has to be one: two readlines over a single stdin do not take turns — the first keeps the
|
|
34
|
+
// listener and everything typed after it lands nowhere, so the second reaches end-of-input
|
|
35
|
+
// having read nothing and reports it as Ctrl-D. Verified against a real terminal, because a
|
|
36
|
+
// fake stream takes turns perfectly well and would have called this fine.
|
|
37
|
+
export function createPrompts({ input = stdin, output = stdout } = {}) {
|
|
38
|
+
const veil = veiledOutput(output)
|
|
39
|
+
// terminal follows stdin: true is what stops the tty driver from echoing on its own, which
|
|
40
|
+
// is what leaves the curtain as the only thing between the keyboard and the screen. Forcing
|
|
41
|
+
// it on a pipe would put readline into line editing over input with no terminal behind it.
|
|
42
|
+
const rl = readline.createInterface({ input, output: veil.stream, terminal: Boolean(input.isTTY) })
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ask: (question) => rl.question(question),
|
|
46
|
+
async askSecret(question) {
|
|
47
|
+
// A prompt written where nobody can see it, waiting on a stream that will never carry a
|
|
48
|
+
// typed answer, is a hang — the one failure this project refuses to produce anywhere.
|
|
49
|
+
// Reading it from the pipe instead would be worse: the secret would then have come from
|
|
50
|
+
// somewhere that kept a copy of it.
|
|
51
|
+
if (!input.isTTY) throw new Error(NO_TERMINAL)
|
|
52
|
+
|
|
53
|
+
veil.say(question)
|
|
54
|
+
veil.hide()
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return await rl.question('')
|
|
58
|
+
} finally {
|
|
59
|
+
veil.show()
|
|
60
|
+
veil.say('\n')
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
// Not optional: terminal mode put stdin in raw mode, and leaving it there hands the user
|
|
64
|
+
// back a shell that no longer echoes what they type.
|
|
65
|
+
close: () => rl.close(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The single-question case. Its question goes to stderr rather than stdout, because
|
|
70
|
+
// "npx telstore token > token.txt" has to still show it, and stdout there carries one thing.
|
|
71
|
+
export async function readSecret(question, { input = stdin, output = stderr } = {}) {
|
|
72
|
+
if (!input.isTTY) throw new Error(NO_TERMINAL)
|
|
73
|
+
|
|
74
|
+
const prompts = createPrompts({ input, output })
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return await prompts.askSecret(question)
|
|
78
|
+
} finally {
|
|
79
|
+
prompts.close()
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readSecret as realReadSecret } from './prompt.js'
|
|
2
|
+
import { decodeToken } from './token.js'
|
|
3
|
+
|
|
4
|
+
// How a stored config becomes the three things GramJS needs. There are two shapes on disk —
|
|
5
|
+
// the ordinary login, and the sealed blob `login --token` writes — and this is the one place
|
|
6
|
+
// that knows the difference, so `connect` stays about Telegram and the commands stay about
|
|
7
|
+
// their own narrative.
|
|
8
|
+
export async function unlockConfig(config, { readSecret = realReadSecret } = {}) {
|
|
9
|
+
const { sealed } = config
|
|
10
|
+
|
|
11
|
+
if (!sealed) {
|
|
12
|
+
const { apiId, apiHash, session } = config
|
|
13
|
+
|
|
14
|
+
return { apiId, apiHash, session }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const passphrase = await readSecret('Passphrase for the stored session: ')
|
|
18
|
+
// Everything comes out of the blob and nothing from around it. The fields beside it on disk
|
|
19
|
+
// are editable by anyone who can reach the file, and an apiId taken from there would let an
|
|
20
|
+
// edit decide which account a passphrase unlocks.
|
|
21
|
+
const { apiId, apiHash, session } = await decodeToken(sealed, passphrase)
|
|
22
|
+
|
|
23
|
+
return { apiId, apiHash, session }
|
|
24
|
+
}
|
package/src/settings.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { normalizeChatTarget } from './chat.js'
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_CHUNK_SIZE,
|
|
4
|
-
|
|
4
|
+
DEFAULT_DOWNLOAD_CONCURRENCY,
|
|
5
|
+
DEFAULT_UPLOAD_CONCURRENCY,
|
|
5
6
|
MAX_CONCURRENCY,
|
|
6
7
|
parseSize,
|
|
7
8
|
} from './chunking.js'
|
|
@@ -74,9 +75,9 @@ export const SETTINGS = {
|
|
|
74
75
|
format: (value) => String(value),
|
|
75
76
|
describe: (value) => formatBytes(value),
|
|
76
77
|
},
|
|
77
|
-
|
|
78
|
-
flag: 'concurrency',
|
|
79
|
-
default:
|
|
78
|
+
uploadConcurrency: {
|
|
79
|
+
flag: 'upload-concurrency',
|
|
80
|
+
default: DEFAULT_UPLOAD_CONCURRENCY,
|
|
80
81
|
parse: (raw, where) =>
|
|
81
82
|
wholeNumber(
|
|
82
83
|
raw,
|
|
@@ -88,6 +89,20 @@ export const SETTINGS = {
|
|
|
88
89
|
),
|
|
89
90
|
format: (value) => String(value),
|
|
90
91
|
},
|
|
92
|
+
downloadConcurrency: {
|
|
93
|
+
flag: 'download-concurrency',
|
|
94
|
+
default: DEFAULT_DOWNLOAD_CONCURRENCY,
|
|
95
|
+
parse: (raw, where) =>
|
|
96
|
+
wholeNumber(
|
|
97
|
+
raw,
|
|
98
|
+
where,
|
|
99
|
+
1,
|
|
100
|
+
MAX_CONCURRENCY,
|
|
101
|
+
`Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot runs its own download ` +
|
|
102
|
+
'stream, and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
|
|
103
|
+
),
|
|
104
|
+
format: (value) => String(value),
|
|
105
|
+
},
|
|
91
106
|
limit: {
|
|
92
107
|
flag: 'limit',
|
|
93
108
|
default: DEFAULT_LIMIT,
|
|
@@ -188,3 +203,14 @@ export function requireChat(values) {
|
|
|
188
203
|
|
|
189
204
|
return values.chat
|
|
190
205
|
}
|
|
206
|
+
|
|
207
|
+
// The snapshot of somebody's settings that a session token carries to a machine which has no
|
|
208
|
+
// config file of its own. A stray key would arrive there as a line in `config` telling the
|
|
209
|
+
// reader to remove a setting they never wrote, so keys telstore does not know are left behind
|
|
210
|
+
// rather than carried along. This lives here because this is the one place that knows which
|
|
211
|
+
// settings exist.
|
|
212
|
+
export function knownSettings(stored = {}) {
|
|
213
|
+
return Object.fromEntries(
|
|
214
|
+
SETTING_KEYS.filter((key) => stored[key] !== undefined).map((key) => [key, stored[key]]),
|
|
215
|
+
)
|
|
216
|
+
}
|
package/src/state.js
CHANGED
|
@@ -96,6 +96,10 @@ export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STA
|
|
|
96
96
|
// status needs every unfinished backup at once. A state file that cannot be read is skipped
|
|
97
97
|
// rather than fatal, for the same reason loadState returns null: one corrupt file must not
|
|
98
98
|
// hide the other backups still waiting to be finished.
|
|
99
|
+
//
|
|
100
|
+
// The key comes back alongside each record because canResume needs it, and the file name is
|
|
101
|
+
// the only place it survives: the record's own path, size and mtime are exactly what a
|
|
102
|
+
// rewritten file makes stale, so recomputing the key from them would always say yes.
|
|
99
103
|
export async function listStates(configDir = defaultConfigDir()) {
|
|
100
104
|
let names
|
|
101
105
|
try {
|
|
@@ -110,8 +114,10 @@ export async function listStates(configDir = defaultConfigDir()) {
|
|
|
110
114
|
for (const name of names) {
|
|
111
115
|
if (!name.endsWith('.json')) continue
|
|
112
116
|
|
|
113
|
-
const
|
|
114
|
-
|
|
117
|
+
const key = name.slice(0, -'.json'.length)
|
|
118
|
+
const state = await loadState(key, configDir)
|
|
119
|
+
|
|
120
|
+
if (state) states.push({ key, state })
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
return states
|
|
@@ -149,3 +155,28 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
|
149
155
|
|
|
150
156
|
return found
|
|
151
157
|
}
|
|
158
|
+
|
|
159
|
+
// Whether a record can still be resumed, which is not a question about the record alone:
|
|
160
|
+
// runUpload hashes the file it finds on disk and looks the result up, so a backup is
|
|
161
|
+
// resumable exactly when that hash is still the key this record is filed under. Recomputing
|
|
162
|
+
// through stateKey rather than comparing size and mtime by hand is the point — a second way
|
|
163
|
+
// of asking is a second way to drift, and status would end up promising a resume that upload
|
|
164
|
+
// turns into a brand new backup, stranding every chunk already sent.
|
|
165
|
+
//
|
|
166
|
+
// Never throws. status calls this for every record it prints, and one damaged path must not
|
|
167
|
+
// take the rest of the report down with it.
|
|
168
|
+
export async function canResume(key, state) {
|
|
169
|
+
let stat
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
stat = await fs.stat(state.path)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.code === 'ENOENT') return { ok: false, reason: 'missing' }
|
|
175
|
+
return { ok: false, reason: 'unreadable' }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!stat.isFile()) return { ok: false, reason: 'not-a-file' }
|
|
179
|
+
if (stateKey(state.path, stat.size, stat.mtimeMs) !== key) return { ok: false, reason: 'changed' }
|
|
180
|
+
|
|
181
|
+
return { ok: true }
|
|
182
|
+
}
|
package/src/token.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
|
|
4
|
+
const derive = promisify(scrypt)
|
|
5
|
+
|
|
6
|
+
// A session token is this machine's Telegram login, written down so another machine can use
|
|
7
|
+
// it. Two formats, not one format with a flag inside: a blob that looked encrypted and was
|
|
8
|
+
// not would be telstore lying about what it handed over, and the prefix is the one part a
|
|
9
|
+
// reader can check before knowing anything else about the bytes.
|
|
10
|
+
export const TOKEN_PREFIX_SEALED = 'tls1.'
|
|
11
|
+
export const TOKEN_PREFIX_PLAIN = 'tls0.'
|
|
12
|
+
|
|
13
|
+
const SALT_BYTES = 16
|
|
14
|
+
const IV_BYTES = 12
|
|
15
|
+
const TAG_BYTES = 16
|
|
16
|
+
const KEY_BYTES = 32
|
|
17
|
+
const OVERHEAD = SALT_BYTES + IV_BYTES + TAG_BYTES
|
|
18
|
+
|
|
19
|
+
// Pinned to the prefix above, never carried inside the token. Whoever holds a token can try
|
|
20
|
+
// passphrases offline as fast as their hardware allows, and 64MB per attempt is what makes
|
|
21
|
+
// that expensive; letting a token name its own parameters would let a stranger's N decide how
|
|
22
|
+
// much memory this machine allocates, which is a denial of service that needs no passphrase
|
|
23
|
+
// at all. maxmem is spelled out because Node's own default is 32MB and would refuse these
|
|
24
|
+
// outright, as an error that reads like a bug in telstore.
|
|
25
|
+
const SCRYPT = { N: 65536, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }
|
|
26
|
+
|
|
27
|
+
const MAKE_ONE = 'Make a new one with "npx telstore token" on the machine you are logged in on.'
|
|
28
|
+
|
|
29
|
+
// Whitespace is the one damage repaired rather than refused: a chat client or a mail reader
|
|
30
|
+
// wrapping the line is the likeliest thing to happen to a token, whitespace is never part of
|
|
31
|
+
// one, and joining it back up cannot produce a *different* valid token, because the
|
|
32
|
+
// authentication tag still has to match. Anything else is refused — repairing it would be
|
|
33
|
+
// guessing at what somebody meant.
|
|
34
|
+
function tidy(token) {
|
|
35
|
+
return String(token).replace(/\s+/g, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isSealedToken(text) {
|
|
39
|
+
return tidy(text).startsWith(TOKEN_PREFIX_SEALED)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A token is untrusted input in the same category as a manifest or a state file. Opening one
|
|
47
|
+
// proves whoever made it knew the passphrase, not that they made it correctly — and the
|
|
48
|
+
// unprotected format proves nothing at all. An apiId of undefined does not throw here; it
|
|
49
|
+
// reaches GramJS and fails much later as something that reads like a network problem.
|
|
50
|
+
export function checkTokenBundle(bundle) {
|
|
51
|
+
if (!isPlainObject(bundle)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`The session token holds ${Array.isArray(bundle) ? 'a list' : typeof bundle}, ` +
|
|
54
|
+
`not an account. ${MAKE_ONE}`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!Number.isSafeInteger(bundle.apiId) || bundle.apiId < 1) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`The session token gives ${JSON.stringify(bundle.apiId)} as the api_id, which is not ` +
|
|
61
|
+
`one. ${MAKE_ONE}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const field of ['apiHash', 'session']) {
|
|
66
|
+
if (typeof bundle[field] !== 'string' || bundle[field] === '') {
|
|
67
|
+
throw new Error(`The session token is missing its ${field}. ${MAKE_ONE}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The twin of checkConfigShape, refused for the twin's reason: every lookup below a
|
|
72
|
+
// settings that is not an object returns undefined, and telstore would then run on its
|
|
73
|
+
// built-in defaults while the choices carried in the token sat there ignored.
|
|
74
|
+
if (bundle.settings !== undefined && !isPlainObject(bundle.settings)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`"settings" in the session token holds ` +
|
|
77
|
+
`${Array.isArray(bundle.settings) ? 'a list' : typeof bundle.settings}, not a group ` +
|
|
78
|
+
`of settings. ${MAKE_ONE}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return bundle
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Both spellings of the same accented passphrase have to derive the same key: macOS hands
|
|
86
|
+
// back one and Linux the other, and the difference would surface as "wrong passphrase" for a
|
|
87
|
+
// passphrase that is right.
|
|
88
|
+
function keyFrom(passphrase, salt) {
|
|
89
|
+
return derive(String(passphrase).normalize('NFC'), salt, KEY_BYTES, SCRYPT)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function encodeToken(bundle, passphrase) {
|
|
93
|
+
checkTokenBundle(bundle)
|
|
94
|
+
|
|
95
|
+
const json = JSON.stringify(bundle)
|
|
96
|
+
|
|
97
|
+
// An empty passphrase is not a weak secret, it is the absence of one. Saying so in the
|
|
98
|
+
// prefix is what keeps the format honest: the alternative is a token that opens for anyone
|
|
99
|
+
// who reads the channel it travelled through while looking exactly like a protected one.
|
|
100
|
+
if (String(passphrase) === '') {
|
|
101
|
+
return TOKEN_PREFIX_PLAIN + Buffer.from(json, 'utf8').toString('base64url')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const salt = randomBytes(SALT_BYTES)
|
|
105
|
+
const iv = randomBytes(IV_BYTES)
|
|
106
|
+
const cipher = createCipheriv('aes-256-gcm', await keyFrom(passphrase, salt), iv)
|
|
107
|
+
const body = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
TOKEN_PREFIX_SEALED +
|
|
111
|
+
Buffer.concat([salt, iv, cipher.getAuthTag(), body]).toString('base64url')
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function splitToken(token) {
|
|
116
|
+
const text = tidy(token)
|
|
117
|
+
|
|
118
|
+
for (const prefix of [TOKEN_PREFIX_SEALED, TOKEN_PREFIX_PLAIN]) {
|
|
119
|
+
if (text.startsWith(prefix)) return { sealed: prefix === TOKEN_PREFIX_SEALED, encoded: text.slice(prefix.length) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const version = /^([a-z]+\d+)\./.exec(text)
|
|
123
|
+
|
|
124
|
+
if (version) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`This session token is in format "${version[1]}", which this telstore does not know ` +
|
|
127
|
+
`(it reads "${TOKEN_PREFIX_SEALED.slice(0, -1)}" and ` +
|
|
128
|
+
`"${TOKEN_PREFIX_PLAIN.slice(0, -1)}"). Upgrade telstore, or make a new token with ` +
|
|
129
|
+
'the version you have.',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new Error(
|
|
134
|
+
`That does not look like a session token: one starts with "${TOKEN_PREFIX_SEALED}" or ` +
|
|
135
|
+
`"${TOKEN_PREFIX_PLAIN}". ${MAKE_ONE}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function bodyBytes(encoded) {
|
|
140
|
+
const trimmed = encoded.replace(/=+$/, '')
|
|
141
|
+
const bytes = Buffer.from(trimmed, 'base64url')
|
|
142
|
+
|
|
143
|
+
// Buffer.from silently drops every character outside the alphabet, so a token with a stray
|
|
144
|
+
// quote in it decodes to *something* — which would then fail authentication and be
|
|
145
|
+
// reported as a wrong passphrase. Re-encoding and comparing is what turns that into the
|
|
146
|
+
// sentence that actually helps.
|
|
147
|
+
if (bytes.toString('base64url') !== trimmed) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'The session token has characters that do not belong to one — it was probably ' +
|
|
150
|
+
'truncated or altered on the way here. Copy it again, whole.',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return bytes
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function decodeToken(token, passphrase) {
|
|
158
|
+
const { sealed, encoded } = splitToken(token)
|
|
159
|
+
const bytes = bodyBytes(encoded)
|
|
160
|
+
|
|
161
|
+
if (!sealed) {
|
|
162
|
+
return checkTokenBundle(readJson(bytes.toString('utf8')))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Asked here rather than at the top: a token nobody can read is a different problem from a
|
|
166
|
+
// passphrase nobody supplied, and telling someone to type a passphrase for a token that was
|
|
167
|
+
// damaged in transit sends them after the wrong thing.
|
|
168
|
+
if (String(passphrase) === '') {
|
|
169
|
+
throw new Error(
|
|
170
|
+
'This session token is protected by a passphrase, and none was given. Run the command ' +
|
|
171
|
+
'again where you can type it.',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (bytes.length <= OVERHEAD) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`The session token is too short to be one: it carries ${bytes.length} bytes and the ` +
|
|
178
|
+
`salt, nonce and tag alone need ${OVERHEAD}. Copy it again, whole.`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const decipher = createDecipheriv(
|
|
183
|
+
'aes-256-gcm',
|
|
184
|
+
await keyFrom(passphrase, bytes.subarray(0, SALT_BYTES)),
|
|
185
|
+
bytes.subarray(SALT_BYTES, SALT_BYTES + IV_BYTES),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
decipher.setAuthTag(bytes.subarray(SALT_BYTES + IV_BYTES, OVERHEAD))
|
|
189
|
+
|
|
190
|
+
let json
|
|
191
|
+
try {
|
|
192
|
+
json = Buffer.concat([
|
|
193
|
+
decipher.update(bytes.subarray(OVERHEAD)),
|
|
194
|
+
decipher.final(),
|
|
195
|
+
]).toString('utf8')
|
|
196
|
+
} catch {
|
|
197
|
+
// AES-GCM cannot tell a wrong key from altered bytes: both are one failed tag check, and
|
|
198
|
+
// guessing which it was would send half the readers after the wrong thing. Name both, put
|
|
199
|
+
// the likelier one first, and say out loud that telstore is not guessing.
|
|
200
|
+
throw new Error(
|
|
201
|
+
'Could not open the session token: either the passphrase is wrong or the token was ' +
|
|
202
|
+
'altered on the way here. Encryption cannot tell those two apart, so telstore will ' +
|
|
203
|
+
'not guess — check the passphrase first, then copy the token again, whole.',
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return checkTokenBundle(readJson(json))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Past the tag check the plaintext is ours, so bad JSON here is not somebody else's mistake:
|
|
211
|
+
// it means the token was written by a telstore that disagreed with this one about the format.
|
|
212
|
+
function readJson(json) {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(json)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`The session token opened, but what is inside it is not what telstore writes ` +
|
|
218
|
+
`(${err.message}). ${MAKE_ONE}`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/uploader.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util'
|
|
|
5
5
|
import { Api } from 'telegram'
|
|
6
6
|
import { readBigIntFromBuffer } from 'telegram/Helpers.js'
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { DEFAULT_UPLOAD_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
|
|
9
9
|
import { withRetry } from './retry.js'
|
|
10
10
|
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
11
11
|
|
|
@@ -39,7 +39,7 @@ export async function uploadRange(client, fd, options) {
|
|
|
39
39
|
offset,
|
|
40
40
|
length,
|
|
41
41
|
fileName,
|
|
42
|
-
concurrency =
|
|
42
|
+
concurrency = DEFAULT_UPLOAD_CONCURRENCY,
|
|
43
43
|
partSize = PART_SIZE,
|
|
44
44
|
onProgress,
|
|
45
45
|
retryOptions,
|