@amxts/ftp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kukson777
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/logo.svg" width="96" alt="FTP">
4
+
5
+ # FTP
6
+
7
+ *FTP, FTPS and SFTP for amxts plugins*
8
+
9
+ [![amxts module](https://img.shields.io/badge/amxts-module-3178c6?style=flat-square)](https://amxts.github.io/)
10
+ [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE)
11
+
12
+ [Features](#features) • [Installation](#installation) • [Usage](#usage) • [API](#api)
13
+
14
+ **English** | [Русский](README.ru.md)
15
+
16
+ </div>
17
+
18
+ Game hosts give access to a server's files over FTP, FTPS or SFTP. This library lets an amxts plugin log in to such a server and upload, download and list files there: a log backed up every night, a map list read from a shared server, a demo sent away after a match.
19
+
20
+ ```ts
21
+ import { ftp } from "@amxts/ftp";
22
+
23
+ const client = await ftp.connect("sftp://backup@example.com", { password });
24
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
25
+ const maps = await client.readFile("/configs/maps.ini");
26
+ const entries = await client.list("/configs"); // { name, size, isDirectory, modified }
27
+ await client.close();
28
+ ```
29
+
30
+ ## Features
31
+
32
+ - **FTP, FTPS and SFTP, by the address.** `ftp://`, `ftpes://` (FTP with TLS asked for - explicit FTPS), `ftps://` (TLS from the first byte - implicit FTPS) and `sftp://`.
33
+ - **Never holds the game up.** Every call is a promise; the transfer runs on the server's network client, on a thread of its own, and the promise settles on a later frame.
34
+ - **Files byte for byte.** `upload` and `download` move a file of the game folder - a map, a demo, a log - between the disk and the network without it passing through the plugin, whatever its size.
35
+ - **A password or a key.** SFTP logs in with a password or a private key, and can check the server's host key.
36
+ - **Errors that say what failed.** A wrong password, a missing file, a refused connection or a timeout rejects with an `Error` whose message names the call, the path and the reason - never the password.
37
+ - **A timeout and a signal on every call,** as `fetch` has them.
38
+
39
+ ## Installation
40
+
41
+ Nothing to install on the game server: the network client is part of amxts.
42
+
43
+ ```bash
44
+ npx amxts module add ftp
45
+ ```
46
+
47
+ It installs the package and adds it to your project's `amxts.config.ts`:
48
+
49
+ ```ts
50
+ export default defineConfig({
51
+ modules: ["@amxts/ftp"],
52
+ });
53
+ ```
54
+
55
+ `@amxts/ftp` is a library: the build compiles it into each plugin that imports it. A plugin imports it by name - `import { ftp } from "@amxts/ftp"` - and each plugin has its own clients.
56
+
57
+ ## Usage
58
+
59
+ ### Where the password goes
60
+
61
+ The server's address and password are the server owner's, not the plugin's: keep them in a config file beside the server's other configs, and read it with [config-core](https://github.com/amxts/config-core) (`npx amxts module add config-core`):
62
+
63
+ ```yaml
64
+ # addons/amxmodx/configs/backup.yaml
65
+ url: sftp://backup@files.example.com/cstrike
66
+ password: "correct horse battery staple"
67
+ ```
68
+
69
+ ```ts
70
+ import { ftp } from "@amxts/ftp";
71
+
72
+ interface BackupSettings {
73
+ /** The server and folder to back up to. */
74
+ url: string;
75
+ /** The account's password. */
76
+ password: string;
77
+ }
78
+
79
+ const settings = configs.load<BackupSettings>("backup", { url: "", password: "" });
80
+
81
+ server.addServerCommand("backup_log", backUpLog);
82
+
83
+ async function backUpLog() {
84
+ try {
85
+ const client = await ftp.connect(settings.url, { password: settings.password, timeout: 60_000 });
86
+ await client.upload("addons/amxmodx/logs/today.log", "logs/today.log");
87
+ await client.close();
88
+ console.log("backup: done");
89
+ } catch (error) {
90
+ console.error(`backup: ${error.message}`);
91
+ }
92
+ }
93
+ ```
94
+
95
+ > [!WARNING]
96
+ > **A password in the code goes wherever the code goes** - into git, into a plugin shared with someone else. Keep it in a config file on the server, as above, readable by the server's account only. Prefer an account that may write only to its backup folder; for SFTP, a key (`keyFile`) is better still than a password.
97
+
98
+ ### The address
99
+
100
+ `ftp.connect(url, options)` logs in and gives a client; it rejects when the login fails.
101
+
102
+ | Scheme | What it is | Port by default |
103
+ | --- | --- | --- |
104
+ | `ftp://` | plain FTP: the password and the files go unencrypted | 21 |
105
+ | `ftpes://` | FTP with TLS asked for (`AUTH TLS`), required for the commands and the files - explicit FTPS | 21 |
106
+ | `ftps://` | TLS from the first byte - implicit FTPS | 990 |
107
+ | `sftp://` | SFTP over SSH | 22 |
108
+
109
+ The user can be in the address (`sftp://backup@example.com`) or in `options.user`, and so can the password, though `options.password` keeps it out of an address you might log. A folder in the address (`sftp://example.com/cstrike`) is where relative paths start; a path that starts with `/` starts at the server's root.
110
+
111
+ ### Files
112
+
113
+ ```ts
114
+ await client.upload("demos/match.dem", "demos/match.dem"); // the game folder's file up, its folders made
115
+ await client.download("/maps/de_dust2.bsp", "maps/de_dust2.bsp"); // down into the game folder
116
+ ```
117
+
118
+ A local path is a path of the game folder (`cstrike/`), as everywhere in amxts. A download that fails leaves a file already there as it was.
119
+
120
+ Text reads and writes without a file on the game server:
121
+
122
+ ```ts
123
+ const maps = await client.readFile("/configs/maps.ini");
124
+ await client.writeFile("/status/online.txt", `${server.players.length}`);
125
+ ```
126
+
127
+ ### Listing a folder
128
+
129
+ ```ts
130
+ const entries = await client.list("/configs");
131
+ for (const entry of entries) console.log(`${entry.name} ${entry.isDirectory ? "<dir>" : `${entry.size} B`}`);
132
+ ```
133
+
134
+ Each entry is `{ name, size, isDirectory, modified }`: `modified` is a `Date`, to the minute as the server lists it (to the day for a file older than half a year).
135
+
136
+ ### Errors
137
+
138
+ A call rejects with an `Error` whose message says what failed:
139
+
140
+ ```
141
+ download /maps/de_dust3.bsp: no such file or folder on files.example.com
142
+ connect ftp://backup@files.example.com: files.example.com refused the login - a wrong user, password or key (reply 530)
143
+ upload logs/today.log: timed out
144
+ ```
145
+
146
+ The message never holds the password.
147
+
148
+ ### Timeouts and cancelling
149
+
150
+ ```ts
151
+ const client = await ftp.connect(url, { password, timeout: 30_000 }); // every call: 30 seconds at most
152
+ await client.download("/maps/big.bsp", "maps/big.bsp", { timeout: 300_000 });
153
+
154
+ const controller = new AbortController();
155
+ const pending = client.list("/", { signal: controller.signal });
156
+ controller.abort(); // rejects with the signal's reason
157
+ ```
158
+
159
+ Without a `timeout`, a call has no limit, besides 30 seconds to connect. In an async command handler or a player's event, the player leaving cancels the call.
160
+
161
+ ### SFTP with a key
162
+
163
+ ```ts
164
+ const client = await ftp.connect("sftp://backup@files.example.com", {
165
+ keyFile: "addons/amxmodx/data/backup_key", // a file of the game folder
166
+ keyPassphrase: settings.keyPassphrase, // when the key is encrypted
167
+ hostKey: "nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8", // ssh-keygen -lf, without SHA256:
168
+ });
169
+ ```
170
+
171
+ > [!WARNING]
172
+ > **An SSH key is RSA in PEM:** `ssh-keygen -t rsa -m PEM -f backup_key`. A key in OpenSSH's own format (`BEGIN OPENSSH PRIVATE KEY`, what `ssh-keygen` writes by default) or an Ed25519 key is not read; convert one with `ssh-keygen -p -m PEM -f backup_key`.
173
+ >
174
+ > **Without `hostKey`, any server key is taken.** Give the fingerprint to be sure the server is the one you mean.
175
+
176
+ ## API
177
+
178
+ | Member | What it is |
179
+ | --- | --- |
180
+ | `ftp.connect(url, options?)` | Logs in and gives an `FtpClient`; rejects when the login fails. `options`: `user`, `password`, `keyFile`, `keyPassphrase`, `hostKey`, `ca` (FTPS: the certificate authorities, PEM, for a certificate of the server's own), `timeout` (ms, every call). |
181
+ | `client.upload(localPath, remotePath, options?)` | A file of the game folder up, byte for byte, its remote folders made. |
182
+ | `client.download(remotePath, localPath, options?)` | A remote file down into the game folder, byte for byte. |
183
+ | `client.readFile(remotePath, options?)` | A remote file's text, as UTF-8. |
184
+ | `client.writeFile(remotePath, text, options?)` | Text into a remote file, as UTF-8, its remote folders made. |
185
+ | `client.list(remotePath?, options?)` | The folder's entries: `{ name, size, isDirectory, modified }`. |
186
+ | `client.close()` | Ends the client: a call after it rejects. |
187
+
188
+ Each call's `options` are `{ timeout, signal }`.
package/README.ru.md ADDED
@@ -0,0 +1,188 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/logo.svg" width="96" alt="FTP">
4
+
5
+ # FTP
6
+
7
+ *FTP, FTPS и SFTP для плагинов amxts*
8
+
9
+ [![amxts module](https://img.shields.io/badge/amxts-module-3178c6?style=flat-square)](https://amxts.github.io/)
10
+ [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE)
11
+
12
+ [Возможности](#возможности) • [Установка](#установка) • [Использование](#использование) • [API](#api)
13
+
14
+ [English](README.md) | **Русский**
15
+
16
+ </div>
17
+
18
+ Игровые хостинги дают доступ к файлам сервера по FTP, FTPS или SFTP. Эта библиотека позволяет плагину amxts войти на такой сервер и загружать, скачивать и перечислять там файлы: лог, который каждую ночь уходит в резервную копию, список карт с общего сервера, демо, отправленное после матча.
19
+
20
+ ```ts
21
+ import { ftp } from "@amxts/ftp";
22
+
23
+ const client = await ftp.connect("sftp://backup@example.com", { password });
24
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
25
+ const maps = await client.readFile("/configs/maps.ini");
26
+ const entries = await client.list("/configs"); // { name, size, isDirectory, modified }
27
+ await client.close();
28
+ ```
29
+
30
+ ## Возможности
31
+
32
+ - **FTP, FTPS и SFTP — по адресу.** `ftp://`, `ftpes://` (FTP с запросом TLS — явный FTPS), `ftps://` (TLS с первого байта — неявный FTPS) и `sftp://`.
33
+ - **Никогда не держит игру.** Каждый вызов — промис; передача идёт в сетевом клиенте сервера, в отдельном потоке, а промис выполняется на одном из следующих кадров.
34
+ - **Файлы байт в байт.** `upload` и `download` переносят файл игровой папки — карту, демо, лог — между диском и сетью, не пропуская его через плагин, какого бы размера он ни был.
35
+ - **Пароль или ключ.** SFTP входит по паролю или закрытому ключу и может проверить ключ хоста сервера.
36
+ - **Ошибки говорят, что не так.** Неверный пароль, отсутствующий файл, отказ в соединении или таймаут отклоняют промис с `Error`, в сообщении которого — вызов, путь и причина, но никогда не пароль.
37
+ - **Таймаут и сигнал у каждого вызова,** как у `fetch`.
38
+
39
+ ## Установка
40
+
41
+ На игровой сервер ничего ставить не нужно: сетевой клиент — часть amxts.
42
+
43
+ ```bash
44
+ npx amxts module add ftp
45
+ ```
46
+
47
+ Команда ставит пакет и добавляет его в `amxts.config.ts` проекта:
48
+
49
+ ```ts
50
+ export default defineConfig({
51
+ modules: ["@amxts/ftp"],
52
+ });
53
+ ```
54
+
55
+ `@amxts/ftp` — библиотека: сборка компилирует её в каждый плагин, который её импортирует. Плагин импортирует её по имени — `import { ftp } from "@amxts/ftp"`, — и у каждого плагина свои клиенты.
56
+
57
+ ## Использование
58
+
59
+ ### Где хранить пароль
60
+
61
+ Адрес сервера и пароль принадлежат владельцу сервера, а не плагину: храните их в конфиге рядом с остальными конфигами сервера и читайте его через [config-core](https://github.com/amxts/config-core) (`npx amxts module add config-core`):
62
+
63
+ ```yaml
64
+ # addons/amxmodx/configs/backup.yaml
65
+ url: sftp://backup@files.example.com/cstrike
66
+ password: "correct horse battery staple"
67
+ ```
68
+
69
+ ```ts
70
+ import { ftp } from "@amxts/ftp";
71
+
72
+ interface BackupSettings {
73
+ /** Сервер и папка для резервных копий. */
74
+ url: string;
75
+ /** Пароль учётной записи. */
76
+ password: string;
77
+ }
78
+
79
+ const settings = configs.load<BackupSettings>("backup", { url: "", password: "" });
80
+
81
+ server.addServerCommand("backup_log", backUpLog);
82
+
83
+ async function backUpLog() {
84
+ try {
85
+ const client = await ftp.connect(settings.url, { password: settings.password, timeout: 60_000 });
86
+ await client.upload("addons/amxmodx/logs/today.log", "logs/today.log");
87
+ await client.close();
88
+ console.log("backup: done");
89
+ } catch (error) {
90
+ console.error(`backup: ${error.message}`);
91
+ }
92
+ }
93
+ ```
94
+
95
+ > [!WARNING]
96
+ > **Пароль в коде уходит туда же, куда и код** — в git, в плагин, которым поделились с кем-то ещё. Храните его в конфиге на сервере, как выше, доступном для чтения только учётной записи сервера. Лучше учётная запись, которая может писать только в свою папку для копий; для SFTP ключ (`keyFile`) ещё лучше пароля.
97
+
98
+ ### Адрес
99
+
100
+ `ftp.connect(url, options)` входит на сервер и даёт клиент; промис отклоняется, если вход не удался.
101
+
102
+ | Схема | Что это | Порт по умолчанию |
103
+ | --- | --- | --- |
104
+ | `ftp://` | обычный FTP: пароль и файлы идут без шифрования | 21 |
105
+ | `ftpes://` | FTP с запросом TLS (`AUTH TLS`), обязательным для команд и файлов, — явный FTPS | 21 |
106
+ | `ftps://` | TLS с первого байта — неявный FTPS | 990 |
107
+ | `sftp://` | SFTP поверх SSH | 22 |
108
+
109
+ Пользователь может быть в адресе (`sftp://backup@example.com`) или в `options.user`, как и пароль, хотя `options.password` не даёт ему попасть в адрес, который может оказаться в логе. Папка в адресе (`sftp://example.com/cstrike`) — то, откуда начинаются относительные пути; путь, начинающийся с `/`, начинается от корня сервера.
110
+
111
+ ### Файлы
112
+
113
+ ```ts
114
+ await client.upload("demos/match.dem", "demos/match.dem"); // файл игровой папки наверх, его папки создаются
115
+ await client.download("/maps/de_dust2.bsp", "maps/de_dust2.bsp"); // вниз, в игровую папку
116
+ ```
117
+
118
+ Локальный путь — путь игровой папки (`cstrike/`), как и везде в amxts. Неудачное скачивание оставляет уже существующий файл как был.
119
+
120
+ Текст читается и пишется без файла на игровом сервере:
121
+
122
+ ```ts
123
+ const maps = await client.readFile("/configs/maps.ini");
124
+ await client.writeFile("/status/online.txt", `${server.players.length}`);
125
+ ```
126
+
127
+ ### Содержимое папки
128
+
129
+ ```ts
130
+ const entries = await client.list("/configs");
131
+ for (const entry of entries) console.log(`${entry.name} ${entry.isDirectory ? "<dir>" : `${entry.size} B`}`);
132
+ ```
133
+
134
+ Каждая запись — `{ name, size, isDirectory, modified }`: `modified` — `Date` с точностью до минуты, как её даёт сервер (до дня у файла старше полугода).
135
+
136
+ ### Ошибки
137
+
138
+ Вызов отклоняется с `Error`, в сообщении которого сказано, что не так:
139
+
140
+ ```
141
+ download /maps/de_dust3.bsp: no such file or folder on files.example.com
142
+ connect ftp://backup@files.example.com: files.example.com refused the login - a wrong user, password or key (reply 530)
143
+ upload logs/today.log: timed out
144
+ ```
145
+
146
+ Пароля в сообщении нет никогда.
147
+
148
+ ### Таймауты и отмена
149
+
150
+ ```ts
151
+ const client = await ftp.connect(url, { password, timeout: 30_000 }); // каждый вызов — не дольше 30 секунд
152
+ await client.download("/maps/big.bsp", "maps/big.bsp", { timeout: 300_000 });
153
+
154
+ const controller = new AbortController();
155
+ const pending = client.list("/", { signal: controller.signal });
156
+ controller.abort(); // отклоняется с причиной сигнала
157
+ ```
158
+
159
+ Без `timeout` у вызова нет ограничения, кроме 30 секунд на соединение. В асинхронном обработчике команды или события игрока уход игрока отменяет вызов.
160
+
161
+ ### SFTP с ключом
162
+
163
+ ```ts
164
+ const client = await ftp.connect("sftp://backup@files.example.com", {
165
+ keyFile: "addons/amxmodx/data/backup_key", // файл игровой папки
166
+ keyPassphrase: settings.keyPassphrase, // если ключ зашифрован
167
+ hostKey: "nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8", // ssh-keygen -lf, без SHA256:
168
+ });
169
+ ```
170
+
171
+ > [!WARNING]
172
+ > **Ключ SSH — RSA в PEM:** `ssh-keygen -t rsa -m PEM -f backup_key`. Ключ в собственном формате OpenSSH (`BEGIN OPENSSH PRIVATE KEY`, его `ssh-keygen` пишет по умолчанию) или ключ Ed25519 не читается; переведите его командой `ssh-keygen -p -m PEM -f backup_key`.
173
+ >
174
+ > **Без `hostKey` принимается любой ключ сервера.** Укажите отпечаток, чтобы быть уверенным, что сервер — тот самый.
175
+
176
+ ## API
177
+
178
+ | Член | Что это |
179
+ | --- | --- |
180
+ | `ftp.connect(url, options?)` | Входит на сервер и даёт `FtpClient`; отклоняется, если вход не удался. `options`: `user`, `password`, `keyFile`, `keyPassphrase`, `hostKey`, `ca` (FTPS: удостоверяющие центры, PEM, для собственного сертификата сервера), `timeout` (мс, каждый вызов). |
181
+ | `client.upload(localPath, remotePath, options?)` | Файл игровой папки наверх байт в байт, удалённые папки создаются. |
182
+ | `client.download(remotePath, localPath, options?)` | Удалённый файл вниз, в игровую папку, байт в байт. |
183
+ | `client.readFile(remotePath, options?)` | Текст удалённого файла как UTF-8. |
184
+ | `client.writeFile(remotePath, text, options?)` | Текст в удалённый файл как UTF-8, удалённые папки создаются. |
185
+ | `client.list(remotePath?, options?)` | Записи папки: `{ name, size, isDirectory, modified }`. |
186
+ | `client.close()` | Закрывает клиент: вызов после этого отклоняется. |
187
+
188
+ `options` каждого вызова — `{ timeout, signal }`.
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@amxts/ftp",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "FTP, FTPS and SFTP for amxts plugins: upload, download and list files on another server, every call a promise that never holds the game up.",
6
+ "author": {
7
+ "name": "kukson777",
8
+ "url": "https://github.com/kukson777"
9
+ },
10
+ "license": "MIT",
11
+ "homepage": "https://github.com/amxts/ftp#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/amxts/ftp.git"
15
+ },
16
+ "keywords": [
17
+ "amxts-module",
18
+ "amxts-ftp",
19
+ "ftp",
20
+ "ftps",
21
+ "sftp",
22
+ "amxmodx",
23
+ "counter-strike"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.ts"
27
+ },
28
+ "types": "./src/index.ts",
29
+ "files": [
30
+ "README.md",
31
+ "README.ru.md",
32
+ "scripts/docs",
33
+ "src"
34
+ ],
35
+ "amxts": {
36
+ "module": "src/index.ts",
37
+ "library": true
38
+ },
39
+ "scripts": {
40
+ "test": "bun test",
41
+ "check": "amxts check",
42
+ "lint": "oxlint && oxfmt --check",
43
+ "lint:fix": "oxfmt && oxlint --fix --silent || oxlint --fix --silent || oxlint --fix"
44
+ },
45
+ "peerDependencies": {
46
+ "@amxts/core": "^0.2.0"
47
+ },
48
+ "devDependencies": {
49
+ "@e18e/eslint-plugin": "^0.8.1",
50
+ "@eslint-community/eslint-plugin-eslint-comments": "^4.8.1",
51
+ "@stylistic/eslint-plugin": "^5.10.0",
52
+ "@types/bun": "^1.4.2",
53
+ "basic-ftp": "^6.2.1",
54
+ "eslint-plugin-antfu": "^3.2.3",
55
+ "eslint-plugin-command": "^4.0.0",
56
+ "eslint-plugin-n": "^18.4.0",
57
+ "eslint-plugin-perfectionist": "^5.12.1",
58
+ "eslint-plugin-regexp": "^3.3.1",
59
+ "ftp-srv": "^4.6.3",
60
+ "oxfmt": "^0.70.0",
61
+ "oxlint": "^1.85.0",
62
+ "ssh2": "^1.17.0",
63
+ "ssh2-sftp-client": "^12.1.1",
64
+ "typescript": "^6.0.3"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
@@ -0,0 +1,256 @@
1
+ // The tooltips of src/index.ts, in both languages: scripts/apply-docs.ts writes the
2
+ // one AMXTS_DOCS_LANG picks into the JSDoc above each element.
3
+ export default {
4
+ 'FtpOptions': {
5
+ en: `How \`ftp.connect\` logs in, and what every call of the client takes by default.`,
6
+ ru: `Как \`ftp.connect\` входит на сервер и что каждый вызов клиента берёт по умолчанию.`,
7
+ },
8
+ 'FtpOptions.user': {
9
+ en: `The user to log in as; the address's own (\`sftp://admin@example.com\`) when left out.`,
10
+ ru: `Пользователь для входа; если не указан — из адреса (\`sftp://admin@example.com\`).`,
11
+ },
12
+ 'FtpOptions.password': {
13
+ en: `The password to log in with; the address's own when left out.`,
14
+ ru: `Пароль для входа; если не указан — из адреса.`,
15
+ },
16
+ 'FtpOptions.keyFile': {
17
+ en: `
18
+ SFTP: the private key to log in with, a file of the game folder
19
+ (\`addons/amxmodx/data/backup_key\`) - an RSA key in PEM.
20
+ `,
21
+ ru: `
22
+ SFTP: закрытый ключ для входа, файл игровой папки
23
+ (\`addons/amxmodx/data/backup_key\`), — ключ RSA в PEM.
24
+ `,
25
+ },
26
+ 'FtpOptions.keyPassphrase': {
27
+ en: `SFTP: the passphrase \`keyFile\` is encrypted with.`,
28
+ ru: `SFTP: пароль, которым зашифрован \`keyFile\`.`,
29
+ },
30
+ 'FtpOptions.hostKey': {
31
+ en: `
32
+ SFTP: the server's host key, its SHA-256 fingerprint in base64 as
33
+ \`ssh-keygen -lf\` prints it, without \`SHA256:\`. A server with another key
34
+ is refused; left out, any host key is taken.
35
+ `,
36
+ ru: `
37
+ SFTP: ключ хоста сервера — его отпечаток SHA-256 в base64, как его печатает
38
+ \`ssh-keygen -lf\`, без \`SHA256:\`. Сервер с другим ключом отвергается;
39
+ если не указан, принимается любой ключ хоста.
40
+ `,
41
+ },
42
+ 'FtpOptions.ca': {
43
+ en: `
44
+ FTPS: the certificate authorities the server's certificate must come
45
+ from, as PEM text - for a server with a certificate of its own making.
46
+ Left out, the ones a browser trusts.
47
+ `,
48
+ ru: `
49
+ FTPS: удостоверяющие центры, которыми должен быть выдан сертификат
50
+ сервера, текстом PEM, — для сервера с самодельным сертификатом.
51
+ Если не указаны — те, которым доверяет браузер.
52
+ `,
53
+ },
54
+ 'FtpOptions.timeout': {
55
+ en: `Milliseconds each call may take before it fails; no limit by default.`,
56
+ ru: `Сколько миллисекунд может длиться каждый вызов, прежде чем он завершится ошибкой; по умолчанию без ограничения.`,
57
+ },
58
+ 'FtpCallOptions': {
59
+ en: `One call's own options: \`upload\`'s, \`download\`'s and the others' last argument.`,
60
+ ru: `Параметры одного вызова: последний аргумент \`upload\`, \`download\` и остальных.`,
61
+ },
62
+ 'FtpCallOptions.timeout': {
63
+ en: `Milliseconds this call may take before it fails; the client's \`timeout\` when left out.`,
64
+ ru: `Сколько миллисекунд может длиться этот вызов, прежде чем он завершится ошибкой; если не указано — \`timeout\` клиента.`,
65
+ },
66
+ 'FtpCallOptions.signal': {
67
+ en: `A signal that cancels the call; the promise then rejects with the signal's reason.`,
68
+ ru: `Сигнал, отменяющий вызов; промис тогда отклоняется с причиной сигнала.`,
69
+ },
70
+ 'FtpEntry': {
71
+ en: `A file or a folder in a listing.`,
72
+ ru: `Файл или папка в списке.`,
73
+ },
74
+ 'FtpEntry.name': {
75
+ en: `The entry's name, without its folder, e.g. \`"maps.ini"\`.`,
76
+ ru: `Имя записи без папки, например \`"maps.ini"\`.`,
77
+ },
78
+ 'FtpEntry.size': {
79
+ en: `The entry's size in bytes; a folder's as the server gives it.`,
80
+ ru: `Размер записи в байтах; у папки — какой даёт сервер.`,
81
+ },
82
+ 'FtpEntry.isDirectory': {
83
+ en: `\`true\` for a folder.`,
84
+ ru: `\`true\` для папки.`,
85
+ },
86
+ 'FtpEntry.modified': {
87
+ en: `The entry's last change, as the server lists it: to the minute, or to the day for an older file.`,
88
+ ru: `Время последнего изменения записи, как его даёт сервер: с точностью до минуты, у старого файла — до дня.`,
89
+ },
90
+ 'FtpClient': {
91
+ en: `
92
+ A connection to an FTP, FTPS or SFTP server, from \`ftp.connect\`. Every call
93
+ returns a promise and runs on the server's network client.
94
+
95
+ \`\`\`ts
96
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
97
+ const entries = await client.list("/backup");
98
+ await client.close();
99
+ \`\`\`
100
+ `,
101
+ ru: `
102
+ Подключение к серверу FTP, FTPS или SFTP из \`ftp.connect\`. Каждый вызов
103
+ возвращает промис и выполняется сетевым клиентом сервера.
104
+
105
+ \`\`\`ts
106
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
107
+ const entries = await client.list("/backup");
108
+ await client.close();
109
+ \`\`\`
110
+ `,
111
+ },
112
+ 'FtpClient.upload': {
113
+ en: `
114
+ Sends a file of the game folder to \`remotePath\`, byte for byte - a log, a
115
+ map, a demo - making the remote folders that are not there.
116
+
117
+ \`\`\`ts
118
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
119
+ \`\`\`
120
+ `,
121
+ ru: `
122
+ Отправляет файл игровой папки в \`remotePath\` байт в байт — лог, карту,
123
+ демо, — создавая удалённые папки, которых нет.
124
+
125
+ \`\`\`ts
126
+ await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
127
+ \`\`\`
128
+ `,
129
+ },
130
+ 'FtpClient.download': {
131
+ en: `
132
+ Fetches \`remotePath\` into a file of the game folder, byte for byte. A
133
+ download that fails leaves a file already there as it was.
134
+
135
+ \`\`\`ts
136
+ await client.download("/maps/de_dust2.bsp", "maps/de_dust2.bsp");
137
+ \`\`\`
138
+ `,
139
+ ru: `
140
+ Скачивает \`remotePath\` в файл игровой папки байт в байт. Неудачная
141
+ загрузка оставляет уже существующий файл как был.
142
+
143
+ \`\`\`ts
144
+ await client.download("/maps/de_dust2.bsp", "maps/de_dust2.bsp");
145
+ \`\`\`
146
+ `,
147
+ },
148
+ 'FtpClient.readFile': {
149
+ en: `
150
+ The text of \`remotePath\`, read as UTF-8 - a config, a list.
151
+
152
+ \`\`\`ts
153
+ const maps = await client.readFile("/configs/maps.ini");
154
+ \`\`\`
155
+ `,
156
+ ru: `
157
+ Текст \`remotePath\`, прочитанный как UTF-8, — конфиг, список.
158
+
159
+ \`\`\`ts
160
+ const maps = await client.readFile("/configs/maps.ini");
161
+ \`\`\`
162
+ `,
163
+ },
164
+ 'FtpClient.writeFile': {
165
+ en: `
166
+ Writes \`text\` to \`remotePath\` as UTF-8, in place of what was there,
167
+ making the remote folders that are not there.
168
+
169
+ \`\`\`ts
170
+ await client.writeFile("/status/online.txt", \`\${server.players.length}\`);
171
+ \`\`\`
172
+ `,
173
+ ru: `
174
+ Записывает \`text\` в \`remotePath\` как UTF-8 вместо того, что там было,
175
+ создавая удалённые папки, которых нет.
176
+
177
+ \`\`\`ts
178
+ await client.writeFile("/status/online.txt", \`\${server.players.length}\`);
179
+ \`\`\`
180
+ `,
181
+ },
182
+ 'FtpClient.list': {
183
+ en: `
184
+ The files and folders in \`remotePath\` - the folder of \`ftp.connect\`'s
185
+ address when left out.
186
+
187
+ \`\`\`ts
188
+ const entries = await client.list("/configs");
189
+ const big = entries.filter((entry) => !entry.isDirectory && entry.size > 1_000_000);
190
+ \`\`\`
191
+ `,
192
+ ru: `
193
+ Файлы и папки в \`remotePath\`; если путь не указан — в папке адреса
194
+ \`ftp.connect\`.
195
+
196
+ \`\`\`ts
197
+ const entries = await client.list("/configs");
198
+ const big = entries.filter((entry) => !entry.isDirectory && entry.size > 1_000_000);
199
+ \`\`\`
200
+ `,
201
+ },
202
+ 'FtpClient.close': {
203
+ en: `Ends the client: a call after it rejects.`,
204
+ ru: `Закрывает клиент: вызов после этого отклоняется.`,
205
+ },
206
+ 'Ftp': {
207
+ en: `
208
+ Connects to an FTP, FTPS or SFTP server - \`ftp\`, imported from
209
+ \`@amxts/ftp\`.
210
+
211
+ \`\`\`ts
212
+ import { ftp } from "@amxts/ftp";
213
+
214
+ const client = await ftp.connect("sftp://backup@example.com", { password });
215
+ \`\`\`
216
+ `,
217
+ ru: `
218
+ Подключается к серверу FTP, FTPS или SFTP — \`ftp\`, импортированный из
219
+ \`@amxts/ftp\`.
220
+
221
+ \`\`\`ts
222
+ import { ftp } from "@amxts/ftp";
223
+
224
+ const client = await ftp.connect("sftp://backup@example.com", { password });
225
+ \`\`\`
226
+ `,
227
+ },
228
+ 'Ftp.connect': {
229
+ en: `
230
+ Logs in to the server of \`url\` and gives a client for it; rejects when
231
+ the login fails. The scheme says how: \`ftp:\` plain FTP, \`ftpes:\` FTP
232
+ with TLS asked for (explicit FTPS), \`ftps:\` TLS from the first byte
233
+ (implicit FTPS), \`sftp:\` SFTP over SSH. A folder in the address is where
234
+ relative paths start.
235
+
236
+ \`\`\`ts
237
+ const client = await ftp.connect("ftpes://files.example.com:2121/backup", { user: "cs", password });
238
+ \`\`\`
239
+ `,
240
+ ru: `
241
+ Входит на сервер \`url\` и даёт клиент для него; отклоняется, если вход не
242
+ удался. Схема говорит как: \`ftp:\` — обычный FTP, \`ftpes:\` — FTP с
243
+ запросом TLS (явный FTPS), \`ftps:\` — TLS с первого байта (неявный
244
+ FTPS), \`sftp:\` — SFTP поверх SSH. Папка в адресе — то, откуда
245
+ начинаются относительные пути.
246
+
247
+ \`\`\`ts
248
+ const client = await ftp.connect("ftpes://files.example.com:2121/backup", { user: "cs", password });
249
+ \`\`\`
250
+ `,
251
+ },
252
+ 'ftp': {
253
+ en: `Connects to an FTP, FTPS or SFTP server: \`ftp.connect(url, options)\`.`,
254
+ ru: `Подключается к серверу FTP, FTPS или SFTP: \`ftp.connect(url, options)\`.`,
255
+ },
256
+ };
package/src/index.ts ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * FTP, FTPS and SFTP for amxts plugins: a client that logs in once, then
3
+ * uploads, downloads and lists files - every call a request of the server's
4
+ * network client, settled on a later frame, so the game never waits. A
5
+ * library: the build compiles it into each plugin that imports it. How to
6
+ * use it: README.md.
7
+ */
8
+ import { URL } from "@amxts/core";
9
+ import { request, RequestOptions, RequestResult } from "@amxts/core/kit";
10
+
11
+ /** How `ftp.connect` logs in, and what every call of the client takes by default. */
12
+ export interface FtpOptions {
13
+ /** The user to log in as; the address's own (`sftp://admin@example.com`) when left out. */
14
+ user?: string;
15
+ /** The password to log in with; the address's own when left out. */
16
+ password?: string;
17
+ /**
18
+ * SFTP: the private key to log in with, a file of the game folder
19
+ * (`addons/amxmodx/data/backup_key`) - an RSA key in PEM.
20
+ */
21
+ keyFile?: string;
22
+ /** SFTP: the passphrase `keyFile` is encrypted with. */
23
+ keyPassphrase?: string;
24
+ /**
25
+ * SFTP: the server's host key, its SHA-256 fingerprint in base64 as
26
+ * `ssh-keygen -lf` prints it, without `SHA256:`. A server with another key
27
+ * is refused; left out, any host key is taken.
28
+ */
29
+ hostKey?: string;
30
+ /**
31
+ * FTPS: the certificate authorities the server's certificate must come
32
+ * from, as PEM text - for a server with a certificate of its own making.
33
+ * Left out, the ones a browser trusts.
34
+ */
35
+ ca?: string;
36
+ /** Milliseconds each call may take before it fails; no limit by default. */
37
+ timeout?: number;
38
+ }
39
+
40
+ /** One call's own options: `upload`'s, `download`'s and the others' last argument. */
41
+ export interface FtpCallOptions {
42
+ /** Milliseconds this call may take before it fails; the client's `timeout` when left out. */
43
+ timeout?: number;
44
+ /** A signal that cancels the call; the promise then rejects with the signal's reason. */
45
+ signal?: AbortSignal | null;
46
+ }
47
+
48
+ /** A file or a folder in a listing. */
49
+ export interface FtpEntry {
50
+ /** The entry's name, without its folder, e.g. `"maps.ini"`. */
51
+ name: string;
52
+ /** The entry's size in bytes; a folder's as the server gives it. */
53
+ size: number;
54
+ /** `true` for a folder. */
55
+ isDirectory: boolean;
56
+ /** The entry's last change, as the server lists it: to the minute, or to the day for an older file. */
57
+ modified: Date;
58
+ }
59
+
60
+ /** Where a client's addresses start: the protocol, the host and the folder of `ftp.connect`'s address. */
61
+ interface Origin {
62
+ /** `"ftp:"`, `"ftpes:"`, `"ftps:"` or `"sftp:"`, as the address has it. */
63
+ scheme: string;
64
+ /** The host and its port, e.g. `"example.com:2121"`. */
65
+ host: string;
66
+ /** The address's folder, encoded, without a slash at the end: `""` or `"/backup"`. */
67
+ folder: string;
68
+ }
69
+
70
+ /**
71
+ * A connection to an FTP, FTPS or SFTP server, from `ftp.connect`. Every call
72
+ * returns a promise and runs on the server's network client.
73
+ *
74
+ * ```ts
75
+ * await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
76
+ * const entries = await client.list("/backup");
77
+ * await client.close();
78
+ * ```
79
+ */
80
+ export class FtpClient {
81
+ private closed = false;
82
+
83
+ constructor(private readonly origin: Origin, private readonly user: string, private readonly login: FtpOptions) {}
84
+
85
+ /**
86
+ * Sends a file of the game folder to `remotePath`, byte for byte - a log, a
87
+ * map, a demo - making the remote folders that are not there.
88
+ *
89
+ * ```ts
90
+ * await client.upload("addons/amxmodx/logs/today.log", "/backup/today.log");
91
+ * ```
92
+ */
93
+ async upload(localPath: string, remotePath: string, options: FtpCallOptions = {}) {
94
+ const transfer = this.requestOptions(options);
95
+ transfer.upload = true;
96
+ transfer.createDirs = true;
97
+ transfer.file = localPath;
98
+ await this.send("upload", remotePath, this.address(remotePath, false), transfer);
99
+ }
100
+
101
+ /**
102
+ * Fetches `remotePath` into a file of the game folder, byte for byte. A
103
+ * download that fails leaves a file already there as it was.
104
+ *
105
+ * ```ts
106
+ * await client.download("/maps/de_dust2.bsp", "maps/de_dust2.bsp");
107
+ * ```
108
+ */
109
+ async download(remotePath: string, localPath: string, options: FtpCallOptions = {}) {
110
+ const transfer = this.requestOptions(options);
111
+ transfer.file = localPath;
112
+ await this.send("download", remotePath, this.address(remotePath, false), transfer);
113
+ }
114
+
115
+ /**
116
+ * The text of `remotePath`, read as UTF-8 - a config, a list.
117
+ *
118
+ * ```ts
119
+ * const maps = await client.readFile("/configs/maps.ini");
120
+ * ```
121
+ */
122
+ async readFile(remotePath: string, options: FtpCallOptions = {}) {
123
+ const result = await this.send("readFile", remotePath, this.address(remotePath, false), this.requestOptions(options));
124
+ return result.text();
125
+ }
126
+
127
+ /**
128
+ * Writes `text` to `remotePath` as UTF-8, in place of what was there,
129
+ * making the remote folders that are not there.
130
+ *
131
+ * ```ts
132
+ * await client.writeFile("/status/online.txt", `${server.players.length}`);
133
+ * ```
134
+ */
135
+ async writeFile(remotePath: string, text: string, options: FtpCallOptions = {}) {
136
+ const transfer = this.requestOptions(options);
137
+ transfer.upload = true;
138
+ transfer.createDirs = true;
139
+ transfer.body = text;
140
+ await this.send("writeFile", remotePath, this.address(remotePath, false), transfer);
141
+ }
142
+
143
+ /**
144
+ * The files and folders in `remotePath` - the folder of `ftp.connect`'s
145
+ * address when left out.
146
+ *
147
+ * ```ts
148
+ * const entries = await client.list("/configs");
149
+ * const big = entries.filter((entry) => !entry.isDirectory && entry.size > 1_000_000);
150
+ * ```
151
+ */
152
+ async list(remotePath?: string, options: FtpCallOptions = {}) {
153
+ const path = remotePath ?? "";
154
+ const result = await this.send("list", path, this.address(path, true), this.requestOptions(options));
155
+ return parseListing(result.text());
156
+ }
157
+
158
+ /** Ends the client: a call after it rejects. */
159
+ async close() {
160
+ this.closed = true;
161
+ }
162
+
163
+ /** @hidden checks the login: the folder of the address entered, nothing transferred. */
164
+ async __check() {
165
+ const check = this.requestOptions({});
166
+ check.method = "HEAD";
167
+ await this.send("connect", "", this.address("", true), check);
168
+ }
169
+
170
+ /** The request's options: the login, then the call's timeout and signal. */
171
+ private requestOptions(options: FtpCallOptions) {
172
+ const login = this.login;
173
+ const transfer: RequestOptions = {
174
+ user: this.user,
175
+ password: login.password,
176
+ keyFile: login.keyFile,
177
+ keyPassphrase: login.keyPassphrase,
178
+ hostKey: login.hostKey,
179
+ ca: login.ca,
180
+ signal: options.signal ?? null,
181
+ };
182
+ const timeout = options.timeout ?? login.timeout;
183
+ if (timeout !== undefined) transfer.timeout = timeout;
184
+ // ftpes: TLS asked for on a plain FTP connection, and required.
185
+ if (this.origin.scheme == "ftpes:") transfer.ssl = "all";
186
+ return transfer;
187
+ }
188
+
189
+ /**
190
+ * The address of `path` as the network client reads it. An absolute path
191
+ * starts at the server's root, a relative one at the folder of
192
+ * `ftp.connect`'s address. FTP counts a URL's path from the login folder
193
+ * (`%2F` is its root); SFTP from the root (`/~` is the login folder).
194
+ */
195
+ private address(path: string, folder: boolean) {
196
+ const origin = this.origin;
197
+ const scheme = schemeOf(origin.scheme);
198
+ const sftp = scheme == "sftp:";
199
+ const relative = !path.startsWith("/");
200
+ const start = relative ? origin.folder || (sftp ? "/~" : "") : sftp ? "" : "/%2F";
201
+ const rest = encodePath(relative ? path : path.slice(1));
202
+ const joined = rest.length > 0 ? `${start}/${rest}` : start;
203
+ const tail = folder && !joined.endsWith("/") ? "/" : "";
204
+ return `${scheme}//${origin.host}${joined}${tail}`;
205
+ }
206
+
207
+ /** Sends a request; its result, or a rejection that says what failed. */
208
+ private async send(action: string, path: string, url: string, transfer: RequestOptions) {
209
+ if (this.closed) throw new Error(`${action} ${path}: the FTP client is closed`);
210
+
211
+ const result = await request(url, transfer);
212
+ if (result.errorKind == "") return result;
213
+ throw this.failure(action, path, result, transfer.signal ?? null);
214
+ }
215
+
216
+ /** What failed, in words: the action, the path and why - never the password. */
217
+ private failure(action: string, path: string, result: RequestResult, signal: AbortSignal | null) {
218
+ const reason = signal ? signal.reason : null;
219
+ if (result.errorKind == "aborted" && reason) return reason;
220
+
221
+ const host = this.origin.host;
222
+ const what = path.length > 0 ? `${action} ${path}` : `${action} ${this.origin.scheme}//${this.user.length > 0 ? `${this.user}@` : ""}${host}`;
223
+ const reply = result.replyCode > 0 ? ` (reply ${result.replyCode})` : "";
224
+ const because = reasonOf(result.errorKind) || `failed: ${result.errorText}`;
225
+ return new Error(this.hidden(`${what}: ${because.replace("{host}", host)}${reply}`));
226
+ }
227
+
228
+ /** `text` with the password, should a server repeat it, as `***`. */
229
+ private hidden(text: string) {
230
+ const password = this.login.password ?? "";
231
+ return password.length > 0 ? text.replaceAll(password, "***") : text;
232
+ }
233
+ }
234
+
235
+ /** Why a call failed, by the network client's kind of error; `{host}` is the server. */
236
+ function reasonOf(kind: string) {
237
+ switch (kind) {
238
+ case "login":
239
+ return "{host} refused the login - a wrong user, password or key";
240
+ case "denied":
241
+ return "{host} denied it - the account may not do this here";
242
+ case "notFound":
243
+ return "no such file or folder on {host}";
244
+ case "refused":
245
+ return "could not connect to {host} - it is down, unknown or refuses the connection";
246
+ case "timeout":
247
+ return "timed out";
248
+ case "tls":
249
+ return "the secure connection to {host} failed - its certificate or host key is not trusted";
250
+ case "aborted":
251
+ return "aborted";
252
+ default:
253
+ return "";
254
+ }
255
+ }
256
+
257
+ /** The network client's scheme for one `ftp.connect` takes - `ftpes:` is FTP with TLS asked for - or `""`. */
258
+ function schemeOf(protocol: string) {
259
+ if (protocol == "ftpes:") return "ftp:";
260
+ return protocol == "ftp:" || protocol == "ftps:" || protocol == "sftp:" ? protocol : "";
261
+ }
262
+
263
+ /**
264
+ * Connects to an FTP, FTPS or SFTP server - `ftp`, imported from
265
+ * `@amxts/ftp`.
266
+ *
267
+ * ```ts
268
+ * import { ftp } from "@amxts/ftp";
269
+ *
270
+ * const client = await ftp.connect("sftp://backup@example.com", { password });
271
+ * ```
272
+ */
273
+ export class Ftp {
274
+ /**
275
+ * Logs in to the server of `url` and gives a client for it; rejects when
276
+ * the login fails. The scheme says how: `ftp:` plain FTP, `ftpes:` FTP
277
+ * with TLS asked for (explicit FTPS), `ftps:` TLS from the first byte
278
+ * (implicit FTPS), `sftp:` SFTP over SSH. A folder in the address is where
279
+ * relative paths start.
280
+ *
281
+ * ```ts
282
+ * const client = await ftp.connect("ftpes://files.example.com:2121/backup", { user: "cs", password });
283
+ * ```
284
+ */
285
+ async connect(url: string, options: FtpOptions = {}) {
286
+ const address = URL.parse(url);
287
+ if (!address) throw new TypeError("ftp.connect: the address is not a URL, e.g. sftp://user@example.com");
288
+
289
+ if (schemeOf(address.protocol).length == 0) throw new TypeError(`ftp.connect: ${address.protocol} is not one of ftp:, ftpes:, ftps: or sftp:`);
290
+
291
+ const login: FtpOptions = { ...options };
292
+ login.password = options.password ?? decodeURIComponent(address.password);
293
+ const origin: Origin = {
294
+ scheme: address.protocol,
295
+ host: address.host,
296
+ folder: address.pathname.endsWith("/") ? address.pathname.slice(0, -1) : address.pathname,
297
+ };
298
+ const client = new FtpClient(origin, options.user ?? decodeURIComponent(address.username), login);
299
+ await client.__check();
300
+ return client;
301
+ }
302
+ }
303
+
304
+ /** Connects to an FTP, FTPS or SFTP server: `ftp.connect(url, options)`. */
305
+ export const ftp = new Ftp();
306
+
307
+ /** A path's segments percent-encoded, its slashes kept: `"my maps/a#1.bsp"` as `"my%20maps/a%231.bsp"`. */
308
+ function encodePath(path: string) {
309
+ return path.split("/").map(encodeURIComponent).join("/");
310
+ }
311
+
312
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
313
+
314
+ // A Unix listing's line: `drwxr-xr-x 2 owner group 4096 Oct 1 21:05 name`,
315
+ // the group left out by some servers, a year in place of the time for an
316
+ // older file.
317
+ const UNIX_LINE = /^([-bcdlps])\S*\s+\d+\s+\S+(?:\s+\S+)?\s+(\d+)\s+([a-z]{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(\S.*)$/i;
318
+
319
+ // A Windows server's: `10-01-24 09:05PM <DIR> name`.
320
+ const DOS_LINE = /^(\d{2})-(\d{2})-(\d{2,4})\s+(\d{1,2}):(\d{2})([AP]M)\s+(<DIR>|\d+)\s+(\S.*)$/i;
321
+
322
+ /** A listing, a line an entry, without `.` and `..`. */
323
+ function parseListing(text: string) {
324
+ const entries: FtpEntry[] = [];
325
+ for (const line of text.split("\n")) {
326
+ const entry = parseLine(line.trim());
327
+ if (entry && entry.name != "." && entry.name != "..") entries.push(entry);
328
+ }
329
+ return entries;
330
+ }
331
+
332
+ function parseLine(line: string) {
333
+ const unix = UNIX_LINE.exec(line);
334
+ if (unix) return unixEntry(unix);
335
+ const dos = DOS_LINE.exec(line);
336
+ if (dos) return dosEntry(dos);
337
+ return null;
338
+ }
339
+
340
+ function unixEntry(match: RegExpExecArray) {
341
+ const kind = match[1];
342
+ const month = MONTHS.indexOf(match[3].toLowerCase());
343
+ const day = Number(match[4]);
344
+ const timeOrYear = match[5];
345
+ // A link's line is `name -> target`.
346
+ const name = kind == "l" ? match[6].split(" -> ")[0] : match[6];
347
+ const entry: FtpEntry = { name, size: Number(match[2]), isDirectory: kind == "d", modified: unixDate(month, day, timeOrYear) };
348
+ return entry;
349
+ }
350
+
351
+ /** `Oct 1 21:05` is this year's - last year's if that is still to come; `Oct 1 2023` is that day's. */
352
+ function unixDate(month: number, day: number, timeOrYear: string) {
353
+ const colon = timeOrYear.indexOf(":");
354
+ if (colon < 0) return new Date(utc(Number(timeOrYear), month, day, 0, 0));
355
+
356
+ const hour = Number(timeOrYear.slice(0, colon));
357
+ const minute = Number(timeOrYear.slice(colon + 1));
358
+ const now = Date.now();
359
+ const year = new Date(now).getUTCFullYear();
360
+ const thisYear = utc(year, month, day, hour, minute);
361
+ // A day ahead allows for the server's time zone.
362
+ return new Date(thisYear > now + 86_400_000 ? utc(year - 1, month, day, hour, minute) : thisYear);
363
+ }
364
+
365
+ function dosEntry(match: RegExpExecArray) {
366
+ const shortYear = Number(match[3]);
367
+ const year = match[3].length == 4 ? shortYear : shortYear < 70 ? 2000 + shortYear : 1900 + shortYear;
368
+ const hour = Number(match[4]) % 12 + (match[6].toUpperCase() == "PM" ? 12 : 0);
369
+ const folder = match[7].toUpperCase() == "<DIR>";
370
+ const modified = new Date(utc(year, Number(match[1]) - 1, Number(match[2]), hour, Number(match[5])));
371
+ const entry: FtpEntry = { name: match[8], size: folder ? 0 : Number(match[7]), isDirectory: folder, modified };
372
+ return entry;
373
+ }
374
+
375
+ /** Milliseconds since 1970 of a UTC time, to the minute. */
376
+ function utc(year: number, month: number, day: number, hour: number, minute: number) {
377
+ return Date.UTC(year, month, day, hour, minute, 0, 0);
378
+ }