@dnax/core 0.77.9-alpha-1 → 0.78.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/app/hono.ts +3 -3
- package/app/index.ts +1 -1
- package/index.ts +1 -2
- package/lib/media/sftp.ts +657 -103
- package/lib/media/sftp_.txt +120 -0
- package/package.json +3 -4
package/app/hono.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { serveStatic, getConnInfo } from "hono/bun";
|
|
|
14
14
|
import { consola } from "consola";
|
|
15
15
|
|
|
16
16
|
import { cors } from "hono/cors";
|
|
17
|
-
import { asyncLocalStorage, sessionStorage,requestStorage } from "../lib/asyncLocalStorage";
|
|
17
|
+
import { asyncLocalStorage, sessionStorage, requestStorage } from "../lib/asyncLocalStorage";
|
|
18
18
|
import { crypt } from "../lib/crypto";
|
|
19
19
|
import { plugins } from "../lib/plugins";
|
|
20
20
|
import {
|
|
@@ -305,7 +305,7 @@ function HonoInstance(): typeof app {
|
|
|
305
305
|
?.get("content-type")
|
|
306
306
|
?.match(/(multipart\/form-data)/) ||
|
|
307
307
|
c.req?.raw?.headers.get("content-type") ==
|
|
308
|
-
|
|
308
|
+
"application/x-www-form-urlencoded"
|
|
309
309
|
) {
|
|
310
310
|
parseBody =
|
|
311
311
|
(await c?.req.parseBody({
|
|
@@ -818,7 +818,7 @@ function HonoInstance(): typeof app {
|
|
|
818
818
|
if (Cfg?.api?.onError && typeof Cfg?.api?.onError == "function") {
|
|
819
819
|
try {
|
|
820
820
|
await Cfg?.api?.onError(errorFormated, err);
|
|
821
|
-
} catch (e) {}
|
|
821
|
+
} catch (e) { }
|
|
822
822
|
}
|
|
823
823
|
return c.json(errorFormated);
|
|
824
824
|
}
|
package/app/index.ts
CHANGED
package/index.ts
CHANGED
|
@@ -6,13 +6,12 @@ import * as v from "joi";
|
|
|
6
6
|
import { $ } from "bun";
|
|
7
7
|
import define from "./define";
|
|
8
8
|
import * as utils from "./utils";
|
|
9
|
-
import { app } from "./app/hono";
|
|
10
9
|
import { FilesystemSftpAdapter, MinioAdapter } from "./lib/media";
|
|
11
10
|
import { crypt } from "./lib/crypto";
|
|
12
11
|
import { plugins } from "./lib/plugins";
|
|
13
12
|
import { contextStorage } from "./lib/asyncLocalStorage";
|
|
14
13
|
import { Cron as Task } from "croner";
|
|
15
|
-
import { serveStatic } from "hono/bun";
|
|
14
|
+
//import { serveStatic } from "hono/bun";
|
|
16
15
|
import { useDatabaseTools } from "./driver/mongo/database";
|
|
17
16
|
import { useCache } from "./lib/bento";
|
|
18
17
|
// Adapter
|
package/lib/media/sftp.ts
CHANGED
|
@@ -1,116 +1,670 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import chokidar from "chokidar";
|
|
6
6
|
import fs from "fs-extra";
|
|
7
7
|
import type { Collection } from "../../types";
|
|
8
|
+
import consola from "consola";
|
|
8
9
|
const BASE_DIR = "/uploads/";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// TYPES & INTERFACES
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
interface SftpConfig {
|
|
16
|
+
host: string;
|
|
17
|
+
user: string;
|
|
18
|
+
username?: string;
|
|
19
|
+
port?: number;
|
|
20
|
+
password?: string;
|
|
21
|
+
identityFile?: string;
|
|
22
|
+
remoteDir?: string;
|
|
23
|
+
ignorePatterns?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FileInfo {
|
|
27
|
+
name: string;
|
|
28
|
+
size: number;
|
|
29
|
+
isDirectory: boolean;
|
|
30
|
+
modified: Date;
|
|
31
|
+
permissions: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface UploadOptions {
|
|
35
|
+
onProgress?: (percent: number) => void;
|
|
36
|
+
overwrite?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DownloadOptions {
|
|
40
|
+
onProgress?: (percent: number) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// FILESYSTEM SFTP ADAPTER
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
19
47
|
class FilesystemSftpAdapter {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
port: 22,
|
|
26
|
-
} as ConfigType
|
|
27
|
-
) {
|
|
28
|
-
this.config = config;
|
|
29
|
-
this.type = "sftp";
|
|
30
|
-
this.conn = new Client({
|
|
31
|
-
captureRejections: true,
|
|
32
|
-
//captureRejections: true,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
this.connect()
|
|
36
|
-
.then((e) => {
|
|
37
|
-
e.conn.end();
|
|
38
|
-
})
|
|
39
|
-
.catch((err) => {
|
|
40
|
-
consola.error(`SFTP: Failed to connect ${this.config.host}`.red);
|
|
41
|
-
});
|
|
42
|
-
// this.conn.
|
|
43
|
-
return this;
|
|
44
|
-
}
|
|
48
|
+
private config: SftpConfig;
|
|
49
|
+
private connected: boolean = false;
|
|
50
|
+
private remoteDir: string;
|
|
51
|
+
private ignorePatterns: string[];
|
|
52
|
+
type: "sftp";
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
54
|
+
constructor(config: SftpConfig) {
|
|
55
|
+
this.type = "sftp";
|
|
56
|
+
if (!config.username) config.username = "root";
|
|
57
|
+
config.user = config.username;
|
|
58
|
+
this.config = config;
|
|
59
|
+
this.remoteDir = config.remoteDir || "/";
|
|
60
|
+
this.ignorePatterns = config.ignorePatterns || [];
|
|
61
|
+
this.connect()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --------------------------------------------------------------------------
|
|
65
|
+
// PRIVATE HELPERS
|
|
66
|
+
// --------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
private get destination(): string {
|
|
69
|
+
return `${this.config.user}@${this.config.host}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private resolvePath(remotePath: string): string {
|
|
73
|
+
if (remotePath.startsWith("/")) {
|
|
74
|
+
return remotePath;
|
|
75
|
+
}
|
|
76
|
+
return join(this.remoteDir, remotePath).replace(/\\/g, "/");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private shouldIgnore(filePath: string): boolean {
|
|
80
|
+
if (this.ignorePatterns.length === 0) return false;
|
|
81
|
+
|
|
82
|
+
const fileName = filePath.split("/").pop() || filePath;
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
for (const pattern of this.ignorePatterns) {
|
|
85
|
+
// Support glob patterns: *, **, ?
|
|
86
|
+
const regexPattern = pattern
|
|
87
|
+
.replace(/\./g, "\\.")
|
|
88
|
+
.replace(/\*\*/g, "{{DOUBLE_STAR}}")
|
|
89
|
+
.replace(/\*/g, "[^/]*")
|
|
90
|
+
.replace(/{{DOUBLE_STAR}}/g, ".*")
|
|
91
|
+
.replace(/\?/g, ".");
|
|
92
|
+
|
|
93
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
94
|
+
|
|
95
|
+
if (regex.test(fileName) || regex.test(filePath)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async getLocalFiles(localDir: string, baseDir = ""): Promise<string[]> {
|
|
104
|
+
const files: string[] = [];
|
|
105
|
+
const entries = await $`ls -1 ${localDir}`.quiet().text();
|
|
106
|
+
|
|
107
|
+
for (const entry of entries.trim().split("\n").filter(Boolean)) {
|
|
108
|
+
const fullPath = join(localDir, entry);
|
|
109
|
+
const relativePath = baseDir ? `${baseDir}/${entry}` : entry;
|
|
110
|
+
|
|
111
|
+
if (this.shouldIgnore(relativePath) || this.shouldIgnore(entry)) {
|
|
112
|
+
continue;
|
|
78
113
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
|
|
115
|
+
const isDir = await $`test -d ${fullPath} && echo yes || echo no`.quiet().text();
|
|
116
|
+
|
|
117
|
+
if (isDir.trim() === "yes") {
|
|
118
|
+
const subFiles = await this.getLocalFiles(fullPath, relativePath);
|
|
119
|
+
files.push(...subFiles);
|
|
120
|
+
} else {
|
|
121
|
+
files.push(relativePath);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return files;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private get portArg(): string {
|
|
129
|
+
return this.config.port ? `-P ${this.config.port}` : "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private get sshPortArg(): string {
|
|
133
|
+
return this.config.port ? `-p ${this.config.port}` : "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private get identityArg(): string {
|
|
137
|
+
if (!this.config.identityFile) return "";
|
|
138
|
+
const keyPath = this.config.identityFile.replace("~", process.env.HOME || "");
|
|
139
|
+
return `-i ${keyPath}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async execWithPassword(command: string): Promise<string> {
|
|
143
|
+
const { password } = this.config;
|
|
144
|
+
|
|
145
|
+
if (!password) {
|
|
146
|
+
const result = await $`sh -c ${command}`.quiet();
|
|
147
|
+
return result.text();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const expectScript = `
|
|
151
|
+
set timeout 30
|
|
152
|
+
spawn sh -c "${command.replace(/"/g, '\\"')}"
|
|
153
|
+
expect {
|
|
154
|
+
"password:" {
|
|
155
|
+
send "${password}\\r"
|
|
156
|
+
exp_continue
|
|
157
|
+
}
|
|
158
|
+
"Password:" {
|
|
159
|
+
send "${password}\\r"
|
|
160
|
+
exp_continue
|
|
88
161
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
sftp.fastPut(
|
|
93
|
-
localPath,
|
|
94
|
-
remotePath,
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
mode: 0o777,
|
|
98
|
-
// concurrency
|
|
99
|
-
},
|
|
100
|
-
(err: any) => {
|
|
101
|
-
conn.end();
|
|
102
|
-
if (err) {
|
|
103
|
-
console.error(err?.message || err);
|
|
104
|
-
}
|
|
105
|
-
if (err) {
|
|
106
|
-
reject(err?.message);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
resolve(true);
|
|
110
|
-
}
|
|
111
|
-
);
|
|
112
|
-
});
|
|
162
|
+
"yes/no" {
|
|
163
|
+
send "yes\\r"
|
|
164
|
+
exp_continue
|
|
113
165
|
}
|
|
166
|
+
eof
|
|
114
167
|
}
|
|
168
|
+
catch wait result
|
|
169
|
+
exit [lindex $result 3]
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
const scriptFile = join(tmpdir(), `expect-${Date.now()}.exp`);
|
|
173
|
+
await Bun.write(scriptFile, expectScript);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const result = await $`expect ${scriptFile}`.quiet();
|
|
177
|
+
return result.text();
|
|
178
|
+
} finally {
|
|
179
|
+
await $`rm -f ${scriptFile}`.quiet().nothrow();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async sshExec(command: string): Promise<string> {
|
|
184
|
+
const cmd = `ssh ${this.identityArg} ${this.sshPortArg} -o StrictHostKeyChecking=accept-new ${this.destination} ${command}`;
|
|
185
|
+
return await this.execWithPassword(cmd);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private cleanOutput(output: string): string[] {
|
|
189
|
+
return output
|
|
190
|
+
.split("\n")
|
|
191
|
+
.filter((line) => {
|
|
192
|
+
const lower = line.toLowerCase();
|
|
193
|
+
return (
|
|
194
|
+
line.trim() &&
|
|
195
|
+
!lower.includes("password") &&
|
|
196
|
+
!lower.includes("spawn") &&
|
|
197
|
+
!lower.includes("expect")
|
|
198
|
+
);
|
|
199
|
+
})
|
|
200
|
+
.map((line) => line.trim());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --------------------------------------------------------------------------
|
|
204
|
+
// CONNECTION
|
|
205
|
+
// --------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
async connect(): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
// consola.info(`Connecting to ${this.destination}`.blue);
|
|
210
|
+
const cmd = `ssh ${this.identityArg} ${this.sshPortArg} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${this.destination} echo connected`;
|
|
211
|
+
const output = await this.execWithPassword(cmd);
|
|
212
|
+
|
|
213
|
+
if (!output.includes("connected")) {
|
|
214
|
+
throw new Error("Connection verification failed");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
this.connected = true;
|
|
218
|
+
consola.log(`✓ SFTP Connected to ${this.config.host}`.green);
|
|
219
|
+
} catch (error: any) {
|
|
220
|
+
// throw new Error(`Connexion échouée: ${error?.message}`);
|
|
221
|
+
consola.error(`Connexion échouée: ${error?.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async disconnect(): Promise<void> {
|
|
226
|
+
this.connected = false;
|
|
227
|
+
console.log(`✓ Déconnecté de ${this.config.host}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
isConnected(): boolean {
|
|
231
|
+
return this.connected;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --------------------------------------------------------------------------
|
|
235
|
+
// UPLOAD
|
|
236
|
+
// --------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
async syncCollectionMedia(col: Collection) {
|
|
239
|
+
if (!this.config?.remoteDir) this.config.remoteDir = "/home/" + col?.slug;
|
|
240
|
+
|
|
241
|
+
let visibility = col?.media?.visibility || "public";
|
|
242
|
+
let dirToWatch = path.join(process.cwd(), BASE_DIR, col?.slug, visibility);
|
|
243
|
+
dirToWatch = path.resolve(dirToWatch);
|
|
244
|
+
const watcher = chokidar.watch(dirToWatch, {
|
|
245
|
+
persistent: true,
|
|
246
|
+
ignoreInitial: true,
|
|
247
|
+
ignored: this.config.ignorePatterns || [],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
watcher.on("all", (event, filePath) => {
|
|
251
|
+
// console.log(`SFTP: ${event} ${filePath}`);
|
|
252
|
+
if (event == "change" || event == "add") {
|
|
253
|
+
//consola.info(`SFTP: ${event} ${filePath}`);
|
|
254
|
+
let remotePath = path.join(
|
|
255
|
+
this.config.remoteDir || '/home/' + col?.slug,
|
|
256
|
+
path.basename(filePath)
|
|
257
|
+
);
|
|
258
|
+
this.upload(filePath, remotePath).catch(err => {
|
|
259
|
+
consola.error(`${err?.message}`.red);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async upload(
|
|
265
|
+
localPath: string,
|
|
266
|
+
remotePath: string,
|
|
267
|
+
options?: UploadOptions
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const file = Bun.file(localPath);
|
|
270
|
+
|
|
271
|
+
if (!(await file.exists())) {
|
|
272
|
+
throw new Error(`Fichier local introuvable: ${localPath}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const fileName = localPath.split("/").pop() || localPath;
|
|
276
|
+
if (this.shouldIgnore(fileName) || this.shouldIgnore(localPath)) {
|
|
277
|
+
console.log(`⊘ Ignoré: ${localPath}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
282
|
+
|
|
283
|
+
if (!options?.overwrite) {
|
|
284
|
+
const exists = await this.exists(resolvedPath);
|
|
285
|
+
if (exists) {
|
|
286
|
+
throw new Error(`Fichier distant existe déjà: ${resolvedPath}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${localPath} ${this.destination}:${resolvedPath}`;
|
|
291
|
+
|
|
292
|
+
await this.execWithPassword(cmd);
|
|
293
|
+
console.log(`✓ Upload: ${localPath} → ${resolvedPath}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async uploadMultiple(
|
|
297
|
+
files: { local: string; remote: string }[],
|
|
298
|
+
options?: UploadOptions
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
const filteredFiles = files.filter((file) => {
|
|
301
|
+
const fileName = file.local.split("/").pop() || file.local;
|
|
302
|
+
return !this.shouldIgnore(fileName) && !this.shouldIgnore(file.local);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const total = filteredFiles.length;
|
|
306
|
+
let uploaded = 0;
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < total; i++) {
|
|
309
|
+
const file = filteredFiles[i];
|
|
310
|
+
await this.upload(file.local, file.remote, { ...options, overwrite: options?.overwrite });
|
|
311
|
+
uploaded++;
|
|
312
|
+
|
|
313
|
+
if (options?.onProgress) {
|
|
314
|
+
options.onProgress(Math.round((uploaded / total) * 100));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(`✓ ${uploaded} fichiers uploadés`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async uploadDirectory(
|
|
322
|
+
localDir: string,
|
|
323
|
+
remoteDir?: string,
|
|
324
|
+
options?: UploadOptions
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const targetDir = remoteDir ? this.resolvePath(remoteDir) : this.remoteDir;
|
|
327
|
+
|
|
328
|
+
// Get all files respecting ignorePatterns
|
|
329
|
+
const localFiles = await this.getLocalFiles(localDir);
|
|
330
|
+
|
|
331
|
+
// Create remote directory structure
|
|
332
|
+
const dirs = new Set<string>();
|
|
333
|
+
for (const file of localFiles) {
|
|
334
|
+
const dir = file.split("/").slice(0, -1).join("/");
|
|
335
|
+
if (dir) dirs.add(dir);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Create directories
|
|
339
|
+
for (const dir of Array.from(dirs).sort()) {
|
|
340
|
+
const remoteDirPath = `${targetDir}/${dir}`;
|
|
341
|
+
try {
|
|
342
|
+
await this.mkdir(remoteDirPath, true);
|
|
343
|
+
} catch {
|
|
344
|
+
// Directory might already exist
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Upload files
|
|
349
|
+
const total = localFiles.length;
|
|
350
|
+
let uploaded = 0;
|
|
351
|
+
|
|
352
|
+
for (const file of localFiles) {
|
|
353
|
+
const localPath = join(localDir, file);
|
|
354
|
+
const remotePath = `${targetDir}/${file}`;
|
|
355
|
+
|
|
356
|
+
await this.upload(localPath, remotePath, { overwrite: true });
|
|
357
|
+
uploaded++;
|
|
358
|
+
|
|
359
|
+
if (options?.onProgress) {
|
|
360
|
+
options.onProgress(Math.round((uploaded / total) * 100));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
console.log(`✓ Dossier uploadé: ${localDir} → ${targetDir} (${uploaded} fichiers)`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --------------------------------------------------------------------------
|
|
368
|
+
// DOWNLOAD
|
|
369
|
+
// --------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
async download(
|
|
372
|
+
remotePath: string,
|
|
373
|
+
localPath: string,
|
|
374
|
+
options?: DownloadOptions
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
377
|
+
const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${this.destination}:${resolvedPath} ${localPath}`;
|
|
378
|
+
|
|
379
|
+
await this.execWithPassword(cmd);
|
|
380
|
+
console.log(`✓ Download: ${resolvedPath} → ${localPath}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async downloadMultiple(
|
|
384
|
+
files: { remote: string; local: string }[],
|
|
385
|
+
options?: DownloadOptions
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
const total = files.length;
|
|
388
|
+
|
|
389
|
+
for (let i = 0; i < total; i++) {
|
|
390
|
+
const file = files[i];
|
|
391
|
+
await this.download(file.remote, file.local);
|
|
392
|
+
|
|
393
|
+
if (options?.onProgress) {
|
|
394
|
+
options.onProgress(Math.round(((i + 1) / total) * 100));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
console.log(`✓ ${total} fichiers téléchargés`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async downloadDirectory(
|
|
402
|
+
remoteDir: string,
|
|
403
|
+
localDir: string,
|
|
404
|
+
options?: DownloadOptions
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
const resolvedPath = this.resolvePath(remoteDir);
|
|
407
|
+
const cmd = `scp -r ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${this.destination}:${resolvedPath} ${localDir}`;
|
|
408
|
+
|
|
409
|
+
await this.execWithPassword(cmd);
|
|
410
|
+
console.log(`✓ Dossier téléchargé: ${resolvedPath} → ${localDir}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --------------------------------------------------------------------------
|
|
414
|
+
// FILE OPERATIONS
|
|
415
|
+
// --------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
async list(remotePath?: string): Promise<string[]> {
|
|
418
|
+
const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
|
|
419
|
+
const output = await this.sshExec(`ls -1 "${resolvedPath}"`);
|
|
420
|
+
return this.cleanOutput(output);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async listDetailed(remotePath?: string): Promise<FileInfo[]> {
|
|
424
|
+
const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
|
|
425
|
+
const output = await this.sshExec(
|
|
426
|
+
`ls -la --time-style=+%s "${resolvedPath}" | tail -n +2`
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const lines = this.cleanOutput(output);
|
|
430
|
+
const files: FileInfo[] = [];
|
|
431
|
+
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
const parts = line.split(/\s+/);
|
|
434
|
+
if (parts.length >= 7) {
|
|
435
|
+
const permissions = parts[0];
|
|
436
|
+
const size = parseInt(parts[4], 10);
|
|
437
|
+
const timestamp = parseInt(parts[5], 10);
|
|
438
|
+
const name = parts.slice(6).join(" ");
|
|
439
|
+
|
|
440
|
+
if (name && name !== "." && name !== "..") {
|
|
441
|
+
files.push({
|
|
442
|
+
name,
|
|
443
|
+
size,
|
|
444
|
+
isDirectory: permissions.startsWith("d"),
|
|
445
|
+
modified: new Date(timestamp * 1000),
|
|
446
|
+
permissions,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return files;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async exists(remotePath: string): Promise<boolean> {
|
|
456
|
+
try {
|
|
457
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
458
|
+
const output = await this.sshExec(
|
|
459
|
+
`test -e "${resolvedPath}" && echo "EXISTS" || echo "NOT_EXISTS"`
|
|
460
|
+
);
|
|
461
|
+
return output.includes("EXISTS");
|
|
462
|
+
} catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async isDirectory(remotePath: string): Promise<boolean> {
|
|
468
|
+
try {
|
|
469
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
470
|
+
const output = await this.sshExec(
|
|
471
|
+
`test -d "${resolvedPath}" && echo "YES" || echo "NO"`
|
|
472
|
+
);
|
|
473
|
+
return output.includes("YES");
|
|
474
|
+
} catch {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async isFile(remotePath: string): Promise<boolean> {
|
|
480
|
+
try {
|
|
481
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
482
|
+
const output = await this.sshExec(
|
|
483
|
+
`test -f "${resolvedPath}" && echo "YES" || echo "NO"`
|
|
484
|
+
);
|
|
485
|
+
return output.includes("YES");
|
|
486
|
+
} catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async stat(remotePath: string): Promise<FileInfo> {
|
|
492
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
493
|
+
const output = await this.sshExec(
|
|
494
|
+
`stat -c '%n %s %F %Y %A' "${resolvedPath}"`
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const line = this.cleanOutput(output)[0];
|
|
498
|
+
const parts = line.split(" ");
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
name: parts[0],
|
|
502
|
+
size: parseInt(parts[1], 10),
|
|
503
|
+
isDirectory: parts[2] === "directory",
|
|
504
|
+
modified: new Date(parseInt(parts[3], 10) * 1000),
|
|
505
|
+
permissions: parts[4],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async size(remotePath: string): Promise<number> {
|
|
510
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
511
|
+
const output = await this.sshExec(`stat -c '%s' "${resolvedPath}"`);
|
|
512
|
+
return parseInt(this.cleanOutput(output)[0], 10);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// --------------------------------------------------------------------------
|
|
516
|
+
// DIRECTORY OPERATIONS
|
|
517
|
+
// --------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
async mkdir(remotePath: string, recursive = false): Promise<void> {
|
|
520
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
521
|
+
const flag = recursive ? "-p" : "";
|
|
522
|
+
await this.sshExec(`mkdir ${flag} "${resolvedPath}"`);
|
|
523
|
+
console.log(`✓ Dossier créé: ${resolvedPath}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async rmdir(remotePath: string, recursive = false): Promise<void> {
|
|
527
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
528
|
+
const flag = recursive ? "-rf" : "-d";
|
|
529
|
+
await this.sshExec(`rm ${flag} "${resolvedPath}"`);
|
|
530
|
+
console.log(`✓ Dossier supprimé: ${resolvedPath}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --------------------------------------------------------------------------
|
|
534
|
+
// FILE MANIPULATION
|
|
535
|
+
// --------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
async delete(remotePath: string): Promise<void> {
|
|
538
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
539
|
+
await this.sshExec(`rm -f "${resolvedPath}"`);
|
|
540
|
+
console.log(`✓ Fichier supprimé: ${resolvedPath}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async rename(oldPath: string, newPath: string): Promise<void> {
|
|
544
|
+
const resolvedOld = this.resolvePath(oldPath);
|
|
545
|
+
const resolvedNew = this.resolvePath(newPath);
|
|
546
|
+
await this.sshExec(`mv "${resolvedOld}" "${resolvedNew}"`);
|
|
547
|
+
console.log(`✓ Renommé: ${resolvedOld} → ${resolvedNew}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async copy(srcPath: string, destPath: string): Promise<void> {
|
|
551
|
+
const resolvedSrc = this.resolvePath(srcPath);
|
|
552
|
+
const resolvedDest = this.resolvePath(destPath);
|
|
553
|
+
await this.sshExec(`cp "${resolvedSrc}" "${resolvedDest}"`);
|
|
554
|
+
console.log(`✓ Copié: ${resolvedSrc} → ${resolvedDest}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async chmod(remotePath: string, mode: string | number): Promise<void> {
|
|
558
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
559
|
+
await this.sshExec(`chmod ${mode} "${resolvedPath}"`);
|
|
560
|
+
console.log(`✓ Permissions: ${resolvedPath} → ${mode}`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async chown(remotePath: string, owner: string, group?: string): Promise<void> {
|
|
564
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
565
|
+
const ownerGroup = group ? `${owner}:${group}` : owner;
|
|
566
|
+
await this.sshExec(`chown ${ownerGroup} "${resolvedPath}"`);
|
|
567
|
+
console.log(`✓ Propriétaire: ${resolvedPath} → ${ownerGroup}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// --------------------------------------------------------------------------
|
|
571
|
+
// CONTENT OPERATIONS
|
|
572
|
+
// --------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
async readFile(remotePath: string): Promise<string> {
|
|
575
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
576
|
+
return await this.sshExec(`cat "${resolvedPath}"`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async writeFile(remotePath: string, content: string): Promise<void> {
|
|
580
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
581
|
+
const tempFile = join(tmpdir(), `write-${Date.now()}`);
|
|
582
|
+
await Bun.write(tempFile, content);
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${tempFile} ${this.destination}:${resolvedPath}`;
|
|
586
|
+
await this.execWithPassword(cmd);
|
|
587
|
+
console.log(`✓ Fichier écrit: ${resolvedPath}`);
|
|
588
|
+
} finally {
|
|
589
|
+
await $`rm -f ${tempFile}`.quiet().nothrow();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async appendFile(remotePath: string, content: string): Promise<void> {
|
|
594
|
+
const resolvedPath = this.resolvePath(remotePath);
|
|
595
|
+
const escaped = content.replace(/'/g, "'\\''");
|
|
596
|
+
await this.sshExec(`echo '${escaped}' >> "${resolvedPath}"`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --------------------------------------------------------------------------
|
|
600
|
+
// UTILITY
|
|
601
|
+
// --------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
async exec(command: string): Promise<string> {
|
|
604
|
+
return await this.sshExec(command);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async df(remotePath?: string): Promise<{ total: number; used: number; available: number }> {
|
|
608
|
+
const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
|
|
609
|
+
const output = await this.sshExec(`df -B1 "${resolvedPath}" | tail -1`);
|
|
610
|
+
const parts = this.cleanOutput(output)[0].split(/\s+/);
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
total: parseInt(parts[1], 10),
|
|
614
|
+
used: parseInt(parts[2], 10),
|
|
615
|
+
available: parseInt(parts[3], 10),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async whoami(): Promise<string> {
|
|
620
|
+
const output = await this.sshExec("whoami");
|
|
621
|
+
return this.cleanOutput(output)[0];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async pwd(): Promise<string> {
|
|
625
|
+
const output = await this.sshExec("pwd");
|
|
626
|
+
return this.cleanOutput(output)[0];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// --------------------------------------------------------------------------
|
|
630
|
+
// GETTERS & SETTERS
|
|
631
|
+
// --------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
getRemoteDir(): string {
|
|
634
|
+
return this.remoteDir;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
setRemoteDir(dir: string): void {
|
|
638
|
+
this.remoteDir = dir;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
getIgnorePatterns(): string[] {
|
|
642
|
+
return [...this.ignorePatterns];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
setIgnorePatterns(patterns: string[]): void {
|
|
646
|
+
this.ignorePatterns = patterns;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
addIgnorePattern(pattern: string): void {
|
|
650
|
+
if (!this.ignorePatterns.includes(pattern)) {
|
|
651
|
+
this.ignorePatterns.push(pattern);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
removeIgnorePattern(pattern: string): void {
|
|
656
|
+
this.ignorePatterns = this.ignorePatterns.filter((p) => p !== pattern);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ============================================================================
|
|
661
|
+
// EXPORTS
|
|
662
|
+
// ============================================================================
|
|
115
663
|
|
|
116
|
-
export {
|
|
664
|
+
export {
|
|
665
|
+
FilesystemSftpAdapter,
|
|
666
|
+
type SftpConfig,
|
|
667
|
+
type FileInfo,
|
|
668
|
+
type UploadOptions,
|
|
669
|
+
type DownloadOptions,
|
|
670
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Client } from "ssh2";
|
|
2
|
+
import type { SFTPWrapper } from "ssh2";
|
|
3
|
+
import { consola } from "consola";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import fs from "fs-extra";
|
|
7
|
+
import type { Collection } from "../../types";
|
|
8
|
+
const BASE_DIR = "/uploads/";
|
|
9
|
+
type ConfigType = {
|
|
10
|
+
host: string;
|
|
11
|
+
port?: number;
|
|
12
|
+
username: string;
|
|
13
|
+
password: string;
|
|
14
|
+
remoteDir: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
ignorePatterns?: string[];
|
|
17
|
+
//privateKeyPath?: string;
|
|
18
|
+
};
|
|
19
|
+
class FilesystemSftpAdapter {
|
|
20
|
+
conn: Client;
|
|
21
|
+
type: "sftp";
|
|
22
|
+
config: ConfigType;
|
|
23
|
+
constructor(
|
|
24
|
+
config = {
|
|
25
|
+
port: 22,
|
|
26
|
+
} as ConfigType
|
|
27
|
+
) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.type = "sftp";
|
|
30
|
+
this.conn = new Client();
|
|
31
|
+
|
|
32
|
+
this.connect()
|
|
33
|
+
.then((e) => {
|
|
34
|
+
e.conn.end();
|
|
35
|
+
})
|
|
36
|
+
.catch((err) => {
|
|
37
|
+
consola.error(`SFTP: Failed to connect ${this.config.host}`.red);
|
|
38
|
+
});
|
|
39
|
+
// this.conn.
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async syncCollectionMedia(col: Collection) {
|
|
48
|
+
if (!this.config?.remoteDir) this.config.remoteDir = "/home/" + col?.slug;
|
|
49
|
+
|
|
50
|
+
let visibility = col?.media?.visibility || "public";
|
|
51
|
+
let dirToWatch = path.join(process.cwd(), BASE_DIR, col?.slug, visibility);
|
|
52
|
+
dirToWatch = path.resolve(dirToWatch);
|
|
53
|
+
const watcher = chokidar.watch(dirToWatch, {
|
|
54
|
+
persistent: true,
|
|
55
|
+
ignoreInitial: true,
|
|
56
|
+
ignored: this.config.ignorePatterns || [],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
watcher.on("all", (event, filePath) => {
|
|
60
|
+
if (event == "change" || event == "add") {
|
|
61
|
+
//consola.info(`SFTP: ${event} ${filePath}`);
|
|
62
|
+
let remotePath = path.join(
|
|
63
|
+
this.config.remoteDir,
|
|
64
|
+
path.basename(filePath)
|
|
65
|
+
);
|
|
66
|
+
this.put(filePath, remotePath);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
connect(): Promise<{ sftp: SFTPWrapper; conn: Client }> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
this.conn
|
|
74
|
+
.on("ready", () => {
|
|
75
|
+
this.conn.sftp((err, sftp) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
this.conn.end();
|
|
78
|
+
return reject(err?.message);
|
|
79
|
+
}
|
|
80
|
+
resolve({
|
|
81
|
+
sftp: sftp,
|
|
82
|
+
conn: this.conn,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
})
|
|
86
|
+
.on("error", (err) => {
|
|
87
|
+
this.conn.end();
|
|
88
|
+
reject(err?.message)
|
|
89
|
+
})
|
|
90
|
+
.connect(this.config);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async put(localPath: string, remotePath: string) {
|
|
94
|
+
let { sftp, conn } = await this.connect();
|
|
95
|
+
return new Promise(async (resolve, reject) => {
|
|
96
|
+
sftp.fastPut(
|
|
97
|
+
localPath,
|
|
98
|
+
remotePath,
|
|
99
|
+
{
|
|
100
|
+
|
|
101
|
+
mode: 0o777,
|
|
102
|
+
// concurrency
|
|
103
|
+
},
|
|
104
|
+
(err: any) => {
|
|
105
|
+
conn.end();
|
|
106
|
+
if (err) {
|
|
107
|
+
console.error(err?.message || err);
|
|
108
|
+
}
|
|
109
|
+
if (err) {
|
|
110
|
+
reject(err?.message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
resolve(true);
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { FilesystemSftpAdapter };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dnax/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {},
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"@types/uuid": "^11.0.0"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"typescript": "^5.9.
|
|
18
|
+
"typescript": "^5.9.3"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@colors/colors": "^1.6.0",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dot-object": "2.1.5",
|
|
34
34
|
"fs-extra": "^11.2.0",
|
|
35
35
|
"generate-unique-id": "^2.0.3",
|
|
36
|
-
"hono": "4.
|
|
36
|
+
"hono": "4.11.7",
|
|
37
37
|
"joi": "18.0.2",
|
|
38
38
|
"json-joy": "16.8.0",
|
|
39
39
|
"jsonwebtoken": "^9.0.3",
|
|
@@ -48,7 +48,6 @@
|
|
|
48
48
|
"radash": "^12.1.0",
|
|
49
49
|
"rfc6902": "^5.1.2",
|
|
50
50
|
"sharp": "^0.34.5",
|
|
51
|
-
"ssh2": "^1.17.0",
|
|
52
51
|
"ufo": "^1.5.4",
|
|
53
52
|
"urlencode": "^2.0.0",
|
|
54
53
|
"uuid": "^13.0.0"
|