planche 0.4.1 → 0.5.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/README.md +24 -13
- package/dist/index.js +1307 -1101
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,584 +8,510 @@ import { parseArgs } from "util";
|
|
|
8
8
|
|
|
9
9
|
// src/server.ts
|
|
10
10
|
import express from "express";
|
|
11
|
-
import { timingSafeEqual } from "crypto";
|
|
11
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
12
12
|
|
|
13
13
|
// ../shared/contract.ts
|
|
14
14
|
var PROTOCOL_VERSION = 2;
|
|
15
15
|
|
|
16
|
-
// src/
|
|
17
|
-
import {
|
|
16
|
+
// src/ssh-tunnel.ts
|
|
17
|
+
import { createServer } from "net";
|
|
18
|
+
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
19
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
20
|
+
import { homedir as homedir2 } from "os";
|
|
21
|
+
import { basename as basename2, join as join2 } from "path";
|
|
22
|
+
import { Client } from "ssh2";
|
|
18
23
|
|
|
19
|
-
// src/
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
8: "bigint",
|
|
31
|
-
9: "mediumint",
|
|
32
|
-
10: "date",
|
|
33
|
-
11: "time",
|
|
34
|
-
12: "datetime",
|
|
35
|
-
13: "year",
|
|
36
|
-
14: "newdate",
|
|
37
|
-
15: "varchar",
|
|
38
|
-
16: "bit",
|
|
39
|
-
245: "json",
|
|
40
|
-
246: "newdecimal",
|
|
41
|
-
247: "enum",
|
|
42
|
-
248: "set",
|
|
43
|
-
249: "tinyblob",
|
|
44
|
-
250: "mediumblob",
|
|
45
|
-
251: "longblob",
|
|
46
|
-
252: "blob",
|
|
47
|
-
253: "varstring",
|
|
48
|
-
254: "string",
|
|
49
|
-
255: "geometry"
|
|
50
|
-
};
|
|
51
|
-
function getTypeName(typeId) {
|
|
52
|
-
return MYSQL_TYPES[typeId] ?? "unknown";
|
|
24
|
+
// src/ssh-config.ts
|
|
25
|
+
import { readFile, readdir } from "fs/promises";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { dirname, isAbsolute, join, basename } from "path";
|
|
28
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
29
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
30
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
31
|
+
(f) => join(homedir(), ".ssh", f)
|
|
32
|
+
);
|
|
33
|
+
function expandHome(path) {
|
|
34
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
53
35
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
port: config.port,
|
|
58
|
-
user: config.user,
|
|
59
|
-
password: config.password,
|
|
60
|
-
database: config.database || void 0,
|
|
61
|
-
dateStrings: true,
|
|
62
|
-
charset: config.charset || "utf8mb4"
|
|
63
|
-
};
|
|
36
|
+
function globToRegExp(pattern) {
|
|
37
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
38
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
64
39
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
type = "mysql";
|
|
74
|
-
label;
|
|
75
|
-
/**
|
|
76
|
-
* Default schema the session is currently in, or null when unknown (after
|
|
77
|
-
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
78
|
-
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
79
|
-
* whole session on every call, silently rolling back open transactions and
|
|
80
|
-
* dropping temporary tables and user variables.
|
|
81
|
-
*/
|
|
82
|
-
currentDatabase;
|
|
83
|
-
async query(sql2, database) {
|
|
84
|
-
if (database && database !== this.currentDatabase) {
|
|
85
|
-
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
86
|
-
this.currentDatabase = database;
|
|
87
|
-
}
|
|
88
|
-
const [results, fields] = await this.connection.query(sql2);
|
|
89
|
-
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
90
|
-
if (Array.isArray(results) && fields) {
|
|
91
|
-
const fieldPackets = fields;
|
|
92
|
-
const mappedFields = fieldPackets.map((field) => ({
|
|
93
|
-
name: field.name,
|
|
94
|
-
orgName: field.orgName ?? field.name,
|
|
95
|
-
type: getTypeName(field.type ?? 253),
|
|
96
|
-
table: field.table ?? "",
|
|
97
|
-
orgTable: field.orgTable ?? "",
|
|
98
|
-
db: field.db ?? "",
|
|
99
|
-
maxLength: field.length ?? 0
|
|
100
|
-
}));
|
|
101
|
-
const records = results.map(
|
|
102
|
-
(row) => fieldPackets.map((field) => row[field.name])
|
|
103
|
-
);
|
|
104
|
-
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
105
|
-
}
|
|
106
|
-
const header = results;
|
|
107
|
-
return {
|
|
108
|
-
fields: [],
|
|
109
|
-
records: [],
|
|
110
|
-
affectedRows: header.affectedRows ?? 0,
|
|
111
|
-
insertId: header.insertId ?? 0,
|
|
112
|
-
isResultQuery: false
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
async close() {
|
|
116
|
-
await this.connection.end();
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
var mysqlDriverFactory = {
|
|
120
|
-
async open(config) {
|
|
121
|
-
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
122
|
-
return new MySqlDriver(connection, config);
|
|
123
|
-
},
|
|
124
|
-
async test(config) {
|
|
125
|
-
const connection = await mysql.createConnection({
|
|
126
|
-
...toConnectionOptions(config),
|
|
127
|
-
database: void 0
|
|
128
|
-
});
|
|
129
|
-
await connection.end();
|
|
130
|
-
},
|
|
131
|
-
parseConfig(body) {
|
|
132
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
133
|
-
const user = typeof body.user === "string" ? body.user : "";
|
|
134
|
-
if (!host || !user) {
|
|
135
|
-
throw new Error("host and user are required");
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
driver: "mysql",
|
|
139
|
-
host,
|
|
140
|
-
port: Number(body.port) || 3306,
|
|
141
|
-
user,
|
|
142
|
-
password: typeof body.password === "string" ? body.password : "",
|
|
143
|
-
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
144
|
-
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
|
|
145
|
-
};
|
|
40
|
+
function hostPatternMatches(patterns, host) {
|
|
41
|
+
let matched = false;
|
|
42
|
+
for (const raw of patterns) {
|
|
43
|
+
const negated = raw.startsWith("!");
|
|
44
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
45
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
46
|
+
if (negated) return false;
|
|
47
|
+
matched = true;
|
|
146
48
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// src/drivers/postgres.ts
|
|
150
|
-
import pg from "pg";
|
|
151
|
-
var TEXT_OIDS = [
|
|
152
|
-
20,
|
|
153
|
-
// int8
|
|
154
|
-
1700,
|
|
155
|
-
// numeric
|
|
156
|
-
1082,
|
|
157
|
-
// date
|
|
158
|
-
1114,
|
|
159
|
-
// timestamp
|
|
160
|
-
1184,
|
|
161
|
-
// timestamptz
|
|
162
|
-
1083,
|
|
163
|
-
// time
|
|
164
|
-
1266,
|
|
165
|
-
// timetz
|
|
166
|
-
1186
|
|
167
|
-
// interval
|
|
168
|
-
];
|
|
169
|
-
var types = new pg.TypeOverrides();
|
|
170
|
-
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
171
|
-
var BUILTIN_TYPES = {
|
|
172
|
-
16: "bool",
|
|
173
|
-
17: "bytea",
|
|
174
|
-
18: "char",
|
|
175
|
-
19: "name",
|
|
176
|
-
20: "int8",
|
|
177
|
-
21: "int2",
|
|
178
|
-
23: "int4",
|
|
179
|
-
25: "text",
|
|
180
|
-
26: "oid",
|
|
181
|
-
114: "json",
|
|
182
|
-
142: "xml",
|
|
183
|
-
700: "float4",
|
|
184
|
-
701: "float8",
|
|
185
|
-
1042: "bpchar",
|
|
186
|
-
1043: "varchar",
|
|
187
|
-
1082: "date",
|
|
188
|
-
1083: "time",
|
|
189
|
-
1114: "timestamp",
|
|
190
|
-
1184: "timestamptz",
|
|
191
|
-
1186: "interval",
|
|
192
|
-
1266: "timetz",
|
|
193
|
-
1700: "numeric",
|
|
194
|
-
2950: "uuid",
|
|
195
|
-
3802: "jsonb",
|
|
196
|
-
1560: "bit",
|
|
197
|
-
1562: "varbit",
|
|
198
|
-
600: "point",
|
|
199
|
-
869: "inet",
|
|
200
|
-
650: "cidr",
|
|
201
|
-
829: "macaddr",
|
|
202
|
-
1007: "_int4",
|
|
203
|
-
1009: "_text"
|
|
204
|
-
};
|
|
205
|
-
function toClientConfig(config) {
|
|
206
|
-
return {
|
|
207
|
-
host: config.host,
|
|
208
|
-
port: config.port,
|
|
209
|
-
user: config.user,
|
|
210
|
-
password: config.password,
|
|
211
|
-
database: config.database,
|
|
212
|
-
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
213
|
-
types
|
|
214
|
-
};
|
|
49
|
+
return matched;
|
|
215
50
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
51
|
+
function tokenize(line) {
|
|
52
|
+
const tokens = [];
|
|
53
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
54
|
+
let m;
|
|
55
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
56
|
+
return tokens;
|
|
57
|
+
}
|
|
58
|
+
function parseLine(line) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
61
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
62
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
63
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
64
|
+
}
|
|
65
|
+
async function expandGlob(path) {
|
|
66
|
+
const base = basename(path);
|
|
67
|
+
if (!/[*?]/.test(base)) return [path];
|
|
68
|
+
const dir = dirname(path);
|
|
69
|
+
try {
|
|
70
|
+
const re = globToRegExp(base);
|
|
71
|
+
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
222
74
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
currentSchema = null;
|
|
232
|
-
typeNames = { ...BUILTIN_TYPES };
|
|
233
|
-
/**
|
|
234
|
-
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
235
|
-
* the frontend needs schema/table/column names to decide whether a grid is
|
|
236
|
-
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
237
|
-
*/
|
|
238
|
-
relations = /* @__PURE__ */ new Map();
|
|
239
|
-
async loadTypeNames() {
|
|
240
|
-
try {
|
|
241
|
-
const res = await this.client.query(
|
|
242
|
-
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
243
|
-
);
|
|
244
|
-
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
245
|
-
} catch {
|
|
246
|
-
}
|
|
75
|
+
}
|
|
76
|
+
async function loadLines(file, seen) {
|
|
77
|
+
if (seen.has(file)) return [];
|
|
78
|
+
seen.add(file);
|
|
79
|
+
try {
|
|
80
|
+
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
247
83
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
84
|
+
}
|
|
85
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
86
|
+
const result = { identityFiles: [] };
|
|
87
|
+
if (!file) return result;
|
|
88
|
+
const seen = /* @__PURE__ */ new Set();
|
|
89
|
+
async function walk(path, active) {
|
|
90
|
+
let blockActive = active;
|
|
91
|
+
for (const line of await loadLines(path, seen)) {
|
|
92
|
+
const d = parseLine(line);
|
|
93
|
+
if (!d) continue;
|
|
94
|
+
switch (d.keyword) {
|
|
95
|
+
case "host":
|
|
96
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
97
|
+
continue;
|
|
98
|
+
case "match":
|
|
99
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
100
|
+
continue;
|
|
101
|
+
case "include":
|
|
102
|
+
for (const inc of d.args) {
|
|
103
|
+
const target = expandHome(inc);
|
|
104
|
+
const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
|
|
105
|
+
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!blockActive) continue;
|
|
110
|
+
const value = d.args[0];
|
|
111
|
+
if (value === void 0) continue;
|
|
112
|
+
switch (d.keyword) {
|
|
113
|
+
case "hostname":
|
|
114
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
115
|
+
break;
|
|
116
|
+
case "port":
|
|
117
|
+
result.port ??= Number(value) || void 0;
|
|
118
|
+
break;
|
|
119
|
+
case "user":
|
|
120
|
+
result.user ??= value;
|
|
121
|
+
break;
|
|
122
|
+
case "identityfile":
|
|
123
|
+
for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
|
|
124
|
+
break;
|
|
125
|
+
case "identityagent":
|
|
126
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
127
|
+
break;
|
|
128
|
+
case "proxyjump":
|
|
129
|
+
result.proxyJump ??= value;
|
|
130
|
+
break;
|
|
268
131
|
}
|
|
269
|
-
} catch {
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
async query(sql2, database) {
|
|
273
|
-
if (database && database !== this.currentSchema) {
|
|
274
|
-
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
275
|
-
this.currentSchema = database;
|
|
276
|
-
}
|
|
277
|
-
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
278
|
-
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
279
|
-
const results = Array.isArray(result) ? result : [result];
|
|
280
|
-
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
281
|
-
if (last.fields.length > 0) {
|
|
282
|
-
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
283
|
-
const fields = last.fields.map((field) => {
|
|
284
|
-
const rel = this.relations.get(field.tableID);
|
|
285
|
-
return {
|
|
286
|
-
name: field.name,
|
|
287
|
-
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
288
|
-
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
289
|
-
table: rel?.table ?? "",
|
|
290
|
-
orgTable: rel?.table ?? "",
|
|
291
|
-
db: rel?.schema ?? "",
|
|
292
|
-
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
293
|
-
};
|
|
294
|
-
});
|
|
295
|
-
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
296
132
|
}
|
|
297
|
-
return {
|
|
298
|
-
fields: [],
|
|
299
|
-
records: [],
|
|
300
|
-
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
301
|
-
insertId: 0,
|
|
302
|
-
isResultQuery: false
|
|
303
|
-
};
|
|
304
133
|
}
|
|
305
|
-
|
|
306
|
-
|
|
134
|
+
await walk(file, true);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/ssh-tunnel.ts
|
|
139
|
+
var TunnelError = class extends Error {
|
|
140
|
+
constructor(message, status) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.status = status;
|
|
143
|
+
this.name = "TunnelError";
|
|
307
144
|
}
|
|
145
|
+
status;
|
|
308
146
|
};
|
|
309
|
-
var
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return driver;
|
|
316
|
-
},
|
|
317
|
-
async test(config) {
|
|
318
|
-
const client = new pg.Client(toClientConfig(config));
|
|
319
|
-
await client.connect();
|
|
320
|
-
await client.end();
|
|
321
|
-
},
|
|
322
|
-
parseConfig(body) {
|
|
323
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
324
|
-
const user = typeof body.user === "string" ? body.user : "";
|
|
325
|
-
if (!host || !user) {
|
|
326
|
-
throw new Error("host and user are required");
|
|
327
|
-
}
|
|
328
|
-
return {
|
|
329
|
-
driver: "postgres",
|
|
330
|
-
host,
|
|
331
|
-
port: Number(body.port) || 5432,
|
|
332
|
-
user,
|
|
333
|
-
password: typeof body.password === "string" ? body.password : "",
|
|
334
|
-
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
335
|
-
ssl: body.ssl === true,
|
|
336
|
-
sslVerify: body.sslVerify === true
|
|
337
|
-
};
|
|
147
|
+
var HostKeyError = class extends TunnelError {
|
|
148
|
+
hostKey;
|
|
149
|
+
constructor(host, port, type, fingerprint2) {
|
|
150
|
+
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
151
|
+
this.name = "HostKeyError";
|
|
152
|
+
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
338
153
|
}
|
|
339
154
|
};
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
const
|
|
348
|
-
|
|
155
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
156
|
+
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
157
|
+
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
158
|
+
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
159
|
+
function parseSshConfig(value) {
|
|
160
|
+
if (value === void 0 || value === null) return void 0;
|
|
161
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
162
|
+
const body = value;
|
|
163
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
164
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
165
|
+
const auth = body.auth;
|
|
166
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
167
|
+
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
168
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
169
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
170
|
+
}
|
|
171
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
172
|
+
const config = {
|
|
173
|
+
host,
|
|
174
|
+
port: Number(body.port) || 22,
|
|
175
|
+
user,
|
|
176
|
+
auth,
|
|
177
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
178
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
179
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
180
|
+
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
181
|
+
};
|
|
182
|
+
return config;
|
|
349
183
|
}
|
|
350
|
-
function
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
return "text";
|
|
184
|
+
function keyType(blob) {
|
|
185
|
+
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
186
|
+
const len = blob.readUInt32BE(0);
|
|
187
|
+
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
188
|
+
return blob.subarray(4, 4 + len).toString("ascii");
|
|
356
189
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
190
|
+
function knownHostsEntryMatches(field, patterns) {
|
|
191
|
+
if (field.startsWith("|1|")) {
|
|
192
|
+
const [, , salt, hash] = field.split("|");
|
|
193
|
+
if (!salt || !hash) return false;
|
|
194
|
+
const key = Buffer.from(salt, "base64");
|
|
195
|
+
const want = Buffer.from(hash, "base64");
|
|
196
|
+
return patterns.some((p) => {
|
|
197
|
+
const got = createHmac("sha1", key).update(p).digest();
|
|
198
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
199
|
+
});
|
|
363
200
|
}
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
201
|
+
const entries = field.toLowerCase().split(",");
|
|
202
|
+
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
203
|
+
}
|
|
204
|
+
function hostPatterns(host, port) {
|
|
205
|
+
const h = host.toLowerCase();
|
|
206
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
207
|
+
}
|
|
208
|
+
async function readKnownHosts(file) {
|
|
209
|
+
try {
|
|
210
|
+
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
367
213
|
}
|
|
368
|
-
return resolved;
|
|
369
214
|
}
|
|
370
|
-
function
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
215
|
+
async function lookupHostKey(host, port, blob) {
|
|
216
|
+
const patterns = hostPatterns(host, port);
|
|
217
|
+
const type = keyType(blob);
|
|
218
|
+
const encoded = blob.toString("base64");
|
|
219
|
+
let sawType = false;
|
|
220
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
221
|
+
for (const raw of lines) {
|
|
222
|
+
const line = raw.trim();
|
|
223
|
+
if (!line || line.startsWith("#") || line.startsWith("@")) continue;
|
|
224
|
+
const [hosts, lineType, lineKey] = line.split(/\s+/);
|
|
225
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
226
|
+
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
227
|
+
if (lineType !== type) continue;
|
|
228
|
+
if (lineKey === encoded) return "match";
|
|
229
|
+
sawType = true;
|
|
375
230
|
}
|
|
376
|
-
return
|
|
377
|
-
readonly: config.readonly ?? false,
|
|
378
|
-
fileMustExist: !isMemory && !config.createIfMissing
|
|
379
|
-
});
|
|
231
|
+
return sawType ? "mismatch" : "unknown";
|
|
380
232
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
233
|
+
async function rememberHostKey(host, port, blob) {
|
|
234
|
+
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
235
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
236
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
237
|
+
`, { mode: 384 });
|
|
238
|
+
}
|
|
239
|
+
function fingerprint(blob) {
|
|
240
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
241
|
+
}
|
|
242
|
+
async function resolveConfig(ssh) {
|
|
243
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
244
|
+
if (cfg.proxyJump) {
|
|
245
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
385
246
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
throw err;
|
|
399
|
-
}
|
|
400
|
-
if (!statement.reader) {
|
|
401
|
-
const info = statement.run();
|
|
402
|
-
return {
|
|
403
|
-
fields: [],
|
|
404
|
-
records: [],
|
|
405
|
-
affectedRows: info.changes,
|
|
406
|
-
insertId: Number(info.lastInsertRowid),
|
|
407
|
-
isResultQuery: false
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
const columns = statement.columns();
|
|
411
|
-
const records = statement.raw(true).all();
|
|
412
|
-
const fields = columns.map((col, index) => {
|
|
413
|
-
const declared = normalizeDeclaredType(col.type);
|
|
414
|
-
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
415
|
-
return {
|
|
416
|
-
name: col.name,
|
|
417
|
-
orgName: col.column ?? col.name,
|
|
418
|
-
type,
|
|
419
|
-
table: col.table ?? "",
|
|
420
|
-
orgTable: col.table ?? "",
|
|
421
|
-
db: col.database ?? "",
|
|
422
|
-
maxLength: 0
|
|
423
|
-
};
|
|
424
|
-
});
|
|
425
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
247
|
+
const resolved = {
|
|
248
|
+
...ssh,
|
|
249
|
+
host: cfg.hostName ?? ssh.host,
|
|
250
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
251
|
+
user: ssh.user || cfg.user || ""
|
|
252
|
+
};
|
|
253
|
+
if (!resolved.user) {
|
|
254
|
+
throw new TunnelError(
|
|
255
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
256
|
+
400
|
|
257
|
+
);
|
|
426
258
|
}
|
|
427
|
-
|
|
428
|
-
if (
|
|
259
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
260
|
+
if (SSH_CONFIG_DISABLED) {
|
|
261
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
262
|
+
}
|
|
263
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
264
|
+
for (const file of candidates) {
|
|
429
265
|
try {
|
|
430
|
-
|
|
266
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
267
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
268
|
+
break;
|
|
431
269
|
} catch {
|
|
432
270
|
}
|
|
433
271
|
}
|
|
434
|
-
|
|
272
|
+
if (!resolved.privateKey) {
|
|
273
|
+
throw new TunnelError(
|
|
274
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
|
|
275
|
+
400
|
|
276
|
+
);
|
|
277
|
+
}
|
|
435
278
|
}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
279
|
+
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
280
|
+
}
|
|
281
|
+
function toConnectConfig(ssh) {
|
|
282
|
+
const base = {
|
|
283
|
+
host: ssh.host,
|
|
284
|
+
port: ssh.port,
|
|
285
|
+
username: ssh.user,
|
|
286
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
287
|
+
keepaliveInterval: 3e4
|
|
288
|
+
};
|
|
289
|
+
switch (ssh.auth) {
|
|
290
|
+
case "password":
|
|
291
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
292
|
+
case "key":
|
|
293
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
294
|
+
case "agent": {
|
|
295
|
+
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
296
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
297
|
+
return { ...base, agent };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function describeSshError(err, ssh) {
|
|
302
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
303
|
+
if (err instanceof TunnelError) return err;
|
|
304
|
+
switch (err.level) {
|
|
305
|
+
case "client-authentication":
|
|
306
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
307
|
+
case "client-timeout":
|
|
308
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
309
|
+
default:
|
|
310
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
441
311
|
}
|
|
442
|
-
return null;
|
|
443
312
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
313
|
+
async function openSshTunnel(requested, target) {
|
|
314
|
+
const ssh = await resolveConfig(requested);
|
|
315
|
+
const client = new Client();
|
|
316
|
+
const connectConfig = toConnectConfig(ssh);
|
|
317
|
+
let verifyError;
|
|
318
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
319
|
+
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
320
|
+
const fp = fingerprint(key);
|
|
321
|
+
if (result === "mismatch") {
|
|
322
|
+
verifyError = new TunnelError(
|
|
323
|
+
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
324
|
+
400
|
|
325
|
+
);
|
|
326
|
+
verified(false);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (result === "unknown") {
|
|
330
|
+
if (ssh.acceptHostKey !== fp) {
|
|
331
|
+
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
332
|
+
verified(false);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
336
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
337
|
+
}
|
|
338
|
+
verified(true);
|
|
339
|
+
}).catch((err) => {
|
|
340
|
+
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
341
|
+
verified(false);
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
await new Promise((resolve2, reject) => {
|
|
345
|
+
client.once("ready", resolve2);
|
|
346
|
+
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
450
347
|
try {
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
},
|
|
456
|
-
parseConfig(body) {
|
|
457
|
-
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
458
|
-
if (!file) {
|
|
459
|
-
throw new Error("file is required");
|
|
348
|
+
client.connect(connectConfig);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
reject(describeSshError(err, ssh));
|
|
460
351
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
352
|
+
});
|
|
353
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
354
|
+
let forwardError;
|
|
355
|
+
const server = createServer((socket) => {
|
|
356
|
+
sockets.add(socket);
|
|
357
|
+
socket.once("close", () => sockets.delete(socket));
|
|
358
|
+
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
359
|
+
if (err) {
|
|
360
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
361
|
+
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
362
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
363
|
+
console.error(`[ssh] ${forwardError}`);
|
|
364
|
+
socket.destroy();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
socket.pipe(stream).pipe(socket);
|
|
368
|
+
stream.once("close", () => socket.destroy());
|
|
369
|
+
socket.once("close", () => stream.destroy());
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
await new Promise((resolve2, reject) => {
|
|
373
|
+
server.once("error", reject);
|
|
374
|
+
server.listen(0, "127.0.0.1", () => resolve2());
|
|
375
|
+
}).catch((err) => {
|
|
376
|
+
client.end();
|
|
377
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
378
|
+
});
|
|
379
|
+
const { port } = server.address();
|
|
380
|
+
let closed = false;
|
|
381
|
+
const close = () => new Promise((resolve2) => {
|
|
382
|
+
if (closed) return resolve2();
|
|
383
|
+
closed = true;
|
|
384
|
+
for (const s of sockets) s.destroy();
|
|
385
|
+
server.close(() => {
|
|
386
|
+
client.end();
|
|
387
|
+
resolve2();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
client.once("close", () => void close());
|
|
391
|
+
return {
|
|
392
|
+
host: "127.0.0.1",
|
|
393
|
+
port,
|
|
394
|
+
get forwardError() {
|
|
395
|
+
return forwardError;
|
|
396
|
+
},
|
|
397
|
+
close
|
|
398
|
+
};
|
|
399
|
+
}
|
|
469
400
|
|
|
470
|
-
// src/
|
|
471
|
-
import
|
|
472
|
-
|
|
401
|
+
// src/tunneling.ts
|
|
402
|
+
import { randomUUID } from "crypto";
|
|
403
|
+
|
|
404
|
+
// src/drivers/mysql.ts
|
|
405
|
+
import mysql from "mysql2/promise";
|
|
406
|
+
var MYSQL_TYPES = {
|
|
407
|
+
0: "decimal",
|
|
408
|
+
1: "tinyint",
|
|
409
|
+
2: "smallint",
|
|
410
|
+
3: "int",
|
|
411
|
+
4: "float",
|
|
412
|
+
5: "double",
|
|
413
|
+
6: "null",
|
|
414
|
+
7: "timestamp",
|
|
415
|
+
8: "bigint",
|
|
416
|
+
9: "mediumint",
|
|
417
|
+
10: "date",
|
|
418
|
+
11: "time",
|
|
419
|
+
12: "datetime",
|
|
420
|
+
13: "year",
|
|
421
|
+
14: "newdate",
|
|
422
|
+
15: "varchar",
|
|
423
|
+
16: "bit",
|
|
424
|
+
245: "json",
|
|
425
|
+
246: "newdecimal",
|
|
426
|
+
247: "enum",
|
|
427
|
+
248: "set",
|
|
428
|
+
249: "tinyblob",
|
|
429
|
+
250: "mediumblob",
|
|
430
|
+
251: "longblob",
|
|
431
|
+
252: "blob",
|
|
432
|
+
253: "varstring",
|
|
433
|
+
254: "string",
|
|
434
|
+
255: "geometry"
|
|
435
|
+
};
|
|
436
|
+
function getTypeName(typeId) {
|
|
437
|
+
return MYSQL_TYPES[typeId] ?? "unknown";
|
|
438
|
+
}
|
|
439
|
+
function toConnectionOptions(config) {
|
|
473
440
|
return {
|
|
474
|
-
|
|
441
|
+
host: config.host,
|
|
475
442
|
port: config.port,
|
|
476
443
|
user: config.user,
|
|
477
444
|
password: config.password,
|
|
478
|
-
database: config.database,
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
options: {
|
|
482
|
-
encrypt: config.ssl === true,
|
|
483
|
-
trustServerCertificate: true,
|
|
484
|
-
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
485
|
-
useUTC: false,
|
|
486
|
-
enableArithAbort: true
|
|
487
|
-
},
|
|
488
|
-
requestTimeout: 0
|
|
445
|
+
database: config.database || void 0,
|
|
446
|
+
dateStrings: true,
|
|
447
|
+
charset: config.charset || "utf8mb4"
|
|
489
448
|
};
|
|
490
449
|
}
|
|
491
|
-
var
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
function normalizeValue(value) {
|
|
498
|
-
if (value instanceof Date) return formatDate(value);
|
|
499
|
-
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
500
|
-
return value;
|
|
501
|
-
}
|
|
502
|
-
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
503
|
-
var MssqlDriver = class {
|
|
504
|
-
constructor(pool, config) {
|
|
505
|
-
this.pool = pool;
|
|
506
|
-
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
450
|
+
var USE_STATEMENT = /^\s*USE\s/i;
|
|
451
|
+
var MySqlDriver = class {
|
|
452
|
+
constructor(connection, config) {
|
|
453
|
+
this.connection = connection;
|
|
454
|
+
this.label = `${config.user}@${config.host}:${config.port}`;
|
|
455
|
+
this.currentDatabase = config.database ?? null;
|
|
507
456
|
}
|
|
508
|
-
|
|
509
|
-
type = "
|
|
457
|
+
connection;
|
|
458
|
+
type = "mysql";
|
|
510
459
|
label;
|
|
511
|
-
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
512
|
-
sources = /* @__PURE__ */ new Map();
|
|
513
460
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
461
|
+
* Default schema the session is currently in, or null when unknown (after
|
|
462
|
+
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
463
|
+
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
464
|
+
* whole session on every call, silently rolling back open transactions and
|
|
465
|
+
* dropping temporary tables and user variables.
|
|
517
466
|
*/
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const req = this.pool.request();
|
|
524
|
-
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
525
|
-
const res = await req.query(
|
|
526
|
-
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
527
|
-
);
|
|
528
|
-
result = res.recordset.map((r) => ({
|
|
529
|
-
schema: r.source_schema ?? "",
|
|
530
|
-
table: r.source_table ?? "",
|
|
531
|
-
column: r.source_column ?? "",
|
|
532
|
-
type: (r.system_type_name ?? "").toLowerCase()
|
|
533
|
-
}));
|
|
534
|
-
} catch {
|
|
535
|
-
result = null;
|
|
536
|
-
}
|
|
537
|
-
if (this.sources.size > 200) this.sources.clear();
|
|
538
|
-
this.sources.set(text, result);
|
|
539
|
-
return result;
|
|
540
|
-
}
|
|
541
|
-
async query(text) {
|
|
542
|
-
const req = this.pool.request();
|
|
543
|
-
req.arrayRowMode = true;
|
|
544
|
-
const res = await req.query(text);
|
|
545
|
-
const sets = res.recordsets;
|
|
546
|
-
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
547
|
-
const columns = last?.columns;
|
|
548
|
-
if (last && columns) {
|
|
549
|
-
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
550
|
-
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
551
|
-
const fields = meta.map((col, i) => {
|
|
552
|
-
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
553
|
-
const typeName = col.type?.declaration ?? "";
|
|
554
|
-
return {
|
|
555
|
-
name: col.name,
|
|
556
|
-
orgName: src?.column || col.name,
|
|
557
|
-
type: src?.type || typeName.toLowerCase(),
|
|
558
|
-
table: src?.table ?? "",
|
|
559
|
-
orgTable: src?.table ?? "",
|
|
560
|
-
db: src?.schema ?? "",
|
|
561
|
-
maxLength: typeof col.length === "number" ? col.length : 0
|
|
562
|
-
};
|
|
563
|
-
});
|
|
564
|
-
const records = last.map((row) => row.map(normalizeValue));
|
|
565
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
467
|
+
currentDatabase;
|
|
468
|
+
async query(sql2, database) {
|
|
469
|
+
if (database && database !== this.currentDatabase) {
|
|
470
|
+
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
471
|
+
this.currentDatabase = database;
|
|
566
472
|
}
|
|
473
|
+
const [results, fields] = await this.connection.query(sql2);
|
|
474
|
+
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
475
|
+
if (Array.isArray(results) && fields) {
|
|
476
|
+
const fieldPackets = fields;
|
|
477
|
+
const mappedFields = fieldPackets.map((field) => ({
|
|
478
|
+
name: field.name,
|
|
479
|
+
orgName: field.orgName ?? field.name,
|
|
480
|
+
type: getTypeName(field.type ?? 253),
|
|
481
|
+
table: field.table ?? "",
|
|
482
|
+
orgTable: field.orgTable ?? "",
|
|
483
|
+
db: field.db ?? "",
|
|
484
|
+
maxLength: field.length ?? 0
|
|
485
|
+
}));
|
|
486
|
+
const records = results.map(
|
|
487
|
+
(row) => fieldPackets.map((field) => row[field.name])
|
|
488
|
+
);
|
|
489
|
+
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
490
|
+
}
|
|
491
|
+
const header = results;
|
|
567
492
|
return {
|
|
568
493
|
fields: [],
|
|
569
494
|
records: [],
|
|
570
|
-
affectedRows:
|
|
571
|
-
insertId: 0,
|
|
495
|
+
affectedRows: header.affectedRows ?? 0,
|
|
496
|
+
insertId: header.insertId ?? 0,
|
|
572
497
|
isResultQuery: false
|
|
573
498
|
};
|
|
574
499
|
}
|
|
575
500
|
async close() {
|
|
576
|
-
await this.
|
|
501
|
+
await this.connection.end();
|
|
577
502
|
}
|
|
578
503
|
};
|
|
579
|
-
var
|
|
504
|
+
var mysqlDriverFactory = {
|
|
580
505
|
async open(config) {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
return new MssqlDriver(pool, config);
|
|
506
|
+
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
507
|
+
return new MySqlDriver(connection, config);
|
|
584
508
|
},
|
|
585
509
|
async test(config) {
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
510
|
+
const connection = await mysql.createConnection({
|
|
511
|
+
...toConnectionOptions(config),
|
|
512
|
+
database: void 0
|
|
513
|
+
});
|
|
514
|
+
await connection.end();
|
|
589
515
|
},
|
|
590
516
|
parseConfig(body) {
|
|
591
517
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -594,146 +520,189 @@ var mssqlDriverFactory = {
|
|
|
594
520
|
throw new Error("host and user are required");
|
|
595
521
|
}
|
|
596
522
|
return {
|
|
597
|
-
driver: "
|
|
523
|
+
driver: "mysql",
|
|
598
524
|
host,
|
|
599
|
-
port: Number(body.port) ||
|
|
525
|
+
port: Number(body.port) || 3306,
|
|
600
526
|
user,
|
|
601
527
|
password: typeof body.password === "string" ? body.password : "",
|
|
602
|
-
database: typeof body.database === "string" && body.database ? body.database :
|
|
603
|
-
|
|
528
|
+
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
529
|
+
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
|
|
604
530
|
};
|
|
605
531
|
}
|
|
606
532
|
};
|
|
607
533
|
|
|
608
|
-
// src/drivers/
|
|
609
|
-
import
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
534
|
+
// src/drivers/postgres.ts
|
|
535
|
+
import pg from "pg";
|
|
536
|
+
var TEXT_OIDS = [
|
|
537
|
+
20,
|
|
538
|
+
// int8
|
|
539
|
+
1700,
|
|
540
|
+
// numeric
|
|
541
|
+
1082,
|
|
542
|
+
// date
|
|
543
|
+
1114,
|
|
544
|
+
// timestamp
|
|
545
|
+
1184,
|
|
546
|
+
// timestamptz
|
|
547
|
+
1083,
|
|
548
|
+
// time
|
|
549
|
+
1266,
|
|
550
|
+
// timetz
|
|
551
|
+
1186
|
|
552
|
+
// interval
|
|
627
553
|
];
|
|
628
|
-
var
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
554
|
+
var types = new pg.TypeOverrides();
|
|
555
|
+
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
556
|
+
var BUILTIN_TYPES = {
|
|
557
|
+
16: "bool",
|
|
558
|
+
17: "bytea",
|
|
559
|
+
18: "char",
|
|
560
|
+
19: "name",
|
|
561
|
+
20: "int8",
|
|
562
|
+
21: "int2",
|
|
563
|
+
23: "int4",
|
|
564
|
+
25: "text",
|
|
565
|
+
26: "oid",
|
|
566
|
+
114: "json",
|
|
567
|
+
142: "xml",
|
|
568
|
+
700: "float4",
|
|
569
|
+
701: "float8",
|
|
570
|
+
1042: "bpchar",
|
|
571
|
+
1043: "varchar",
|
|
572
|
+
1082: "date",
|
|
573
|
+
1083: "time",
|
|
574
|
+
1114: "timestamp",
|
|
575
|
+
1184: "timestamptz",
|
|
576
|
+
1186: "interval",
|
|
577
|
+
1266: "timetz",
|
|
578
|
+
1700: "numeric",
|
|
579
|
+
2950: "uuid",
|
|
580
|
+
3802: "jsonb",
|
|
581
|
+
1560: "bit",
|
|
582
|
+
1562: "varbit",
|
|
583
|
+
600: "point",
|
|
584
|
+
869: "inet",
|
|
585
|
+
650: "cidr",
|
|
586
|
+
829: "macaddr",
|
|
587
|
+
1007: "_int4",
|
|
588
|
+
1009: "_text"
|
|
589
|
+
};
|
|
590
|
+
function toClientConfig(config) {
|
|
591
|
+
return {
|
|
592
|
+
host: config.host,
|
|
593
|
+
port: config.port,
|
|
594
|
+
user: config.user,
|
|
595
|
+
password: config.password,
|
|
596
|
+
database: config.database,
|
|
597
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
598
|
+
types
|
|
599
|
+
};
|
|
645
600
|
}
|
|
646
|
-
var
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
this.
|
|
601
|
+
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
602
|
+
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
603
|
+
var PostgresDriver = class {
|
|
604
|
+
constructor(client, config) {
|
|
605
|
+
this.client = client;
|
|
606
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
651
607
|
}
|
|
652
|
-
|
|
653
|
-
type = "
|
|
608
|
+
client;
|
|
609
|
+
type = "postgres";
|
|
654
610
|
label;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
611
|
+
/**
|
|
612
|
+
* Schema currently first in `search_path`, or null when unknown (after the
|
|
613
|
+
* client set it itself). The app's "database" for a Postgres connection is
|
|
614
|
+
* a schema of the bound database, switched with `SET search_path`.
|
|
615
|
+
*/
|
|
616
|
+
currentSchema = null;
|
|
617
|
+
typeNames = { ...BUILTIN_TYPES };
|
|
618
|
+
/**
|
|
619
|
+
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
620
|
+
* the frontend needs schema/table/column names to decide whether a grid is
|
|
621
|
+
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
622
|
+
*/
|
|
623
|
+
relations = /* @__PURE__ */ new Map();
|
|
624
|
+
async loadTypeNames() {
|
|
625
|
+
try {
|
|
626
|
+
const res = await this.client.query(
|
|
627
|
+
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
628
|
+
);
|
|
629
|
+
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
661
632
|
}
|
|
662
|
-
async
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
if (cached !== void 0) return cached;
|
|
666
|
-
let out = null;
|
|
633
|
+
async resolveRelations(tableIds) {
|
|
634
|
+
const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
|
|
635
|
+
if (missing.length === 0) return;
|
|
667
636
|
try {
|
|
668
|
-
const res = await this.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
637
|
+
const res = await this.client.query(
|
|
638
|
+
`SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
|
|
639
|
+
FROM pg_class c
|
|
640
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
641
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
642
|
+
WHERE c.oid = ANY($1::oid[])`,
|
|
643
|
+
[missing]
|
|
672
644
|
);
|
|
673
|
-
|
|
645
|
+
for (const row of res.rows) {
|
|
646
|
+
const oid = Number(row.oid);
|
|
647
|
+
let rel = this.relations.get(oid);
|
|
648
|
+
if (!rel) {
|
|
649
|
+
rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
|
|
650
|
+
this.relations.set(oid, rel);
|
|
651
|
+
}
|
|
652
|
+
rel.columns.set(Number(row.attnum), row.attname);
|
|
653
|
+
}
|
|
674
654
|
} catch {
|
|
675
|
-
out = null;
|
|
676
655
|
}
|
|
677
|
-
if (this.columns.size > 200) this.columns.clear();
|
|
678
|
-
this.columns.set(key, out);
|
|
679
|
-
return out;
|
|
680
656
|
}
|
|
681
657
|
async query(sql2, database) {
|
|
682
658
|
if (database && database !== this.currentSchema) {
|
|
683
|
-
await this.
|
|
659
|
+
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
684
660
|
this.currentSchema = database;
|
|
685
661
|
}
|
|
686
|
-
const
|
|
687
|
-
if (
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (END_TX.test(text)) this.manualTx = false;
|
|
695
|
-
if (res.metaData && res.metaData.length > 0) {
|
|
696
|
-
const source = singleSourceTable(text);
|
|
697
|
-
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
698
|
-
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
699
|
-
const fields = res.metaData.map((col) => {
|
|
700
|
-
const fromTable = known?.has(col.name) ?? false;
|
|
662
|
+
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
663
|
+
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
664
|
+
const results = Array.isArray(result) ? result : [result];
|
|
665
|
+
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
666
|
+
if (last.fields.length > 0) {
|
|
667
|
+
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
668
|
+
const fields = last.fields.map((field) => {
|
|
669
|
+
const rel = this.relations.get(field.tableID);
|
|
701
670
|
return {
|
|
702
|
-
name:
|
|
703
|
-
orgName:
|
|
704
|
-
type:
|
|
705
|
-
table:
|
|
706
|
-
orgTable:
|
|
707
|
-
db:
|
|
708
|
-
maxLength:
|
|
671
|
+
name: field.name,
|
|
672
|
+
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
673
|
+
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
674
|
+
table: rel?.table ?? "",
|
|
675
|
+
orgTable: rel?.table ?? "",
|
|
676
|
+
db: rel?.schema ?? "",
|
|
677
|
+
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
709
678
|
};
|
|
710
679
|
});
|
|
711
|
-
|
|
712
|
-
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
680
|
+
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
713
681
|
}
|
|
714
|
-
return {
|
|
682
|
+
return {
|
|
683
|
+
fields: [],
|
|
684
|
+
records: [],
|
|
685
|
+
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
686
|
+
insertId: 0,
|
|
687
|
+
isResultQuery: false
|
|
688
|
+
};
|
|
715
689
|
}
|
|
716
690
|
async close() {
|
|
717
|
-
await this.
|
|
691
|
+
await this.client.end();
|
|
718
692
|
}
|
|
719
693
|
};
|
|
720
|
-
|
|
721
|
-
return {
|
|
722
|
-
user: config.user,
|
|
723
|
-
password: config.password,
|
|
724
|
-
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
|
-
var oracleDriverFactory = {
|
|
694
|
+
var postgresDriverFactory = {
|
|
728
695
|
async open(config) {
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
696
|
+
const client = new pg.Client(toClientConfig(config));
|
|
697
|
+
await client.connect();
|
|
698
|
+
const driver = new PostgresDriver(client, config);
|
|
699
|
+
await driver.loadTypeNames();
|
|
732
700
|
return driver;
|
|
733
701
|
},
|
|
734
702
|
async test(config) {
|
|
735
|
-
const
|
|
736
|
-
await
|
|
703
|
+
const client = new pg.Client(toClientConfig(config));
|
|
704
|
+
await client.connect();
|
|
705
|
+
await client.end();
|
|
737
706
|
},
|
|
738
707
|
parseConfig(body) {
|
|
739
708
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -742,525 +711,647 @@ var oracleDriverFactory = {
|
|
|
742
711
|
throw new Error("host and user are required");
|
|
743
712
|
}
|
|
744
713
|
return {
|
|
745
|
-
driver: "
|
|
714
|
+
driver: "postgres",
|
|
746
715
|
host,
|
|
747
|
-
port: Number(body.port) ||
|
|
716
|
+
port: Number(body.port) || 5432,
|
|
748
717
|
user,
|
|
749
718
|
password: typeof body.password === "string" ? body.password : "",
|
|
750
|
-
|
|
719
|
+
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
720
|
+
ssl: body.ssl === true,
|
|
721
|
+
sslVerify: body.sslVerify === true
|
|
751
722
|
};
|
|
752
723
|
}
|
|
753
724
|
};
|
|
754
725
|
|
|
755
|
-
// src/drivers/
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
status;
|
|
763
|
-
};
|
|
764
|
-
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
765
|
-
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
766
|
-
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
767
|
-
function singleSourceTable2(sql2) {
|
|
768
|
-
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
769
|
-
if (!m) return null;
|
|
770
|
-
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
771
|
-
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
772
|
-
const raw = m[1];
|
|
773
|
-
const first = raw[0];
|
|
774
|
-
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
775
|
-
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
776
|
-
if (first === "[") return raw.slice(1, -1);
|
|
777
|
-
return raw;
|
|
778
|
-
}
|
|
779
|
-
function normalizeDeclaredType2(declared) {
|
|
780
|
-
if (typeof declared !== "string") return null;
|
|
726
|
+
// src/drivers/sqlite.ts
|
|
727
|
+
import Database from "better-sqlite3";
|
|
728
|
+
import { existsSync, realpathSync } from "fs";
|
|
729
|
+
import { homedir as homedir3 } from "os";
|
|
730
|
+
import { basename as basename3, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
|
|
731
|
+
function normalizeDeclaredType(declared) {
|
|
732
|
+
if (!declared) return null;
|
|
781
733
|
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
782
734
|
return base || null;
|
|
783
735
|
}
|
|
784
|
-
function
|
|
736
|
+
function inferTypeFromValue(value) {
|
|
785
737
|
if (value === null || value === void 0) return "null";
|
|
786
738
|
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
787
|
-
if (
|
|
739
|
+
if (typeof value === "bigint") return "bigint";
|
|
740
|
+
if (Buffer.isBuffer(value)) return "blob";
|
|
788
741
|
return "text";
|
|
789
742
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
743
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
744
|
+
var DEFAULT_ROOT = homedir3();
|
|
745
|
+
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
746
|
+
var ALLOW_URI = process.env.PLANCHE_SQLITE_URI === "1";
|
|
747
|
+
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
748
|
+
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
749
|
+
function expandHome2(path) {
|
|
750
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir3(), path.slice(2)) : path;
|
|
796
751
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
headers;
|
|
802
|
-
/** Declared column types per table, for result-source resolution. */
|
|
803
|
-
columns = /* @__PURE__ */ new Map();
|
|
804
|
-
constructor(config) {
|
|
805
|
-
this.label = `d1:${config.databaseId}`;
|
|
806
|
-
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
807
|
-
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
808
|
-
}
|
|
809
|
-
async raw(sql2) {
|
|
810
|
-
let res;
|
|
752
|
+
function realish(file) {
|
|
753
|
+
let probe = file;
|
|
754
|
+
const rest = [];
|
|
755
|
+
for (; ; ) {
|
|
811
756
|
try {
|
|
812
|
-
|
|
813
|
-
} catch (err) {
|
|
814
|
-
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
815
|
-
}
|
|
816
|
-
let body = null;
|
|
817
|
-
try {
|
|
818
|
-
body = await res.json();
|
|
757
|
+
return rest.length ? join3(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
|
|
819
758
|
} catch {
|
|
759
|
+
const parent = dirname2(probe);
|
|
760
|
+
if (parent === probe) return file;
|
|
761
|
+
rest.unshift(basename3(probe));
|
|
762
|
+
probe = parent;
|
|
820
763
|
}
|
|
821
|
-
if (!res.ok || !body?.success) {
|
|
822
|
-
const message = body?.errors?.map((e) => e.message).join("; ") || `Cloudflare API responded ${res.status}`;
|
|
823
|
-
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
824
|
-
}
|
|
825
|
-
const result = body.result?.[0];
|
|
826
|
-
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
827
|
-
return result;
|
|
828
764
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
765
|
+
}
|
|
766
|
+
function isInside(path, root) {
|
|
767
|
+
return path === root || path.startsWith(root + sep);
|
|
768
|
+
}
|
|
769
|
+
function resolveFile(file, forCreate = false) {
|
|
770
|
+
if (file === ":memory:") return file;
|
|
771
|
+
if (file.startsWith("file:")) {
|
|
772
|
+
if (!ALLOW_URI || SQLITE_DIR) throw new Error("SQLite URI filenames are disabled (set PLANCHE_SQLITE_URI=1 on the agent to allow them)");
|
|
773
|
+
return file;
|
|
774
|
+
}
|
|
775
|
+
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
|
|
776
|
+
if (SQLITE_DIR) {
|
|
777
|
+
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
778
|
+
} else {
|
|
779
|
+
const root = realish(DEFAULT_ROOT);
|
|
780
|
+
if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
|
|
781
|
+
const parts = relative(root, resolved).split(sep);
|
|
782
|
+
if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
|
|
783
|
+
if (DENIED_UNDER_HOME.includes(parts[0])) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
|
|
784
|
+
}
|
|
785
|
+
if (forCreate && !DB_EXTENSIONS.test(resolved)) {
|
|
786
|
+
throw new Error("A new SQLite file must end with .db, .sqlite or .sqlite3");
|
|
787
|
+
}
|
|
788
|
+
return resolved;
|
|
789
|
+
}
|
|
790
|
+
function openDatabase(config) {
|
|
791
|
+
const file = resolveFile(config.file, config.createIfMissing === true);
|
|
792
|
+
const isMemory = file === ":memory:";
|
|
793
|
+
if (!isMemory && !config.createIfMissing && !existsSync(file)) {
|
|
794
|
+
throw new Error(`SQLite file not found: ${config.file}`);
|
|
795
|
+
}
|
|
796
|
+
const db = new Database(file, {
|
|
797
|
+
readonly: config.readonly ?? false,
|
|
798
|
+
fileMustExist: !isMemory && !config.createIfMissing
|
|
799
|
+
});
|
|
800
|
+
db.pragma("trusted_schema = OFF");
|
|
801
|
+
return db;
|
|
802
|
+
}
|
|
803
|
+
var ATTACH = /\bATTACH\b/i;
|
|
804
|
+
function stripLiteralsAndComments(sql2) {
|
|
805
|
+
return sql2.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""').replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
806
|
+
}
|
|
807
|
+
function assertNoAttach(sql2) {
|
|
808
|
+
if (!ALLOW_ATTACH && ATTACH.test(stripLiteralsAndComments(sql2))) {
|
|
809
|
+
throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
var SqliteDriver = class {
|
|
813
|
+
constructor(db, config) {
|
|
814
|
+
this.db = db;
|
|
815
|
+
this.label = config.file;
|
|
848
816
|
}
|
|
817
|
+
db;
|
|
818
|
+
type = "sqlite";
|
|
819
|
+
label;
|
|
849
820
|
async query(sql2) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
records: [
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
isResultQuery: true
|
|
861
|
-
};
|
|
862
|
-
}
|
|
863
|
-
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
864
|
-
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
865
|
-
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
821
|
+
assertNoAttach(sql2);
|
|
822
|
+
let statement;
|
|
823
|
+
try {
|
|
824
|
+
statement = this.db.prepare(sql2);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
827
|
+
this.db.exec(sql2);
|
|
828
|
+
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
829
|
+
}
|
|
830
|
+
throw err;
|
|
866
831
|
}
|
|
867
|
-
if (
|
|
868
|
-
|
|
869
|
-
const columns = result.results?.columns ?? [];
|
|
870
|
-
const records = result.results?.rows ?? [];
|
|
871
|
-
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
832
|
+
if (!statement.reader) {
|
|
833
|
+
const info = statement.run();
|
|
872
834
|
return {
|
|
873
835
|
fields: [],
|
|
874
836
|
records: [],
|
|
875
|
-
affectedRows:
|
|
876
|
-
insertId:
|
|
837
|
+
affectedRows: info.changes,
|
|
838
|
+
insertId: Number(info.lastInsertRowid),
|
|
877
839
|
isResultQuery: false
|
|
878
840
|
};
|
|
879
841
|
}
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
const fields = columns.map((
|
|
883
|
-
const
|
|
884
|
-
const type =
|
|
842
|
+
const columns = statement.columns();
|
|
843
|
+
const records = statement.raw(true).all();
|
|
844
|
+
const fields = columns.map((col, index) => {
|
|
845
|
+
const declared = normalizeDeclaredType(col.type);
|
|
846
|
+
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
885
847
|
return {
|
|
886
|
-
name,
|
|
887
|
-
orgName: name,
|
|
848
|
+
name: col.name,
|
|
849
|
+
orgName: col.column ?? col.name,
|
|
888
850
|
type,
|
|
889
|
-
table:
|
|
890
|
-
orgTable:
|
|
891
|
-
db:
|
|
851
|
+
table: col.table ?? "",
|
|
852
|
+
orgTable: col.table ?? "",
|
|
853
|
+
db: col.database ?? "",
|
|
892
854
|
maxLength: 0
|
|
893
855
|
};
|
|
894
856
|
});
|
|
895
857
|
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
896
858
|
}
|
|
897
|
-
isDdl(sql2) {
|
|
898
|
-
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
899
|
-
}
|
|
900
859
|
async close() {
|
|
901
|
-
this.
|
|
860
|
+
if (this.db.inTransaction) {
|
|
861
|
+
try {
|
|
862
|
+
this.db.exec("ROLLBACK");
|
|
863
|
+
} catch {
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
this.db.close();
|
|
902
867
|
}
|
|
903
868
|
};
|
|
904
|
-
|
|
869
|
+
function firstNonNull(records, index) {
|
|
870
|
+
for (const row of records) {
|
|
871
|
+
const value = row[index];
|
|
872
|
+
if (value !== null && value !== void 0) return value;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
var sqliteDriverFactory = {
|
|
905
877
|
async open(config) {
|
|
906
|
-
|
|
907
|
-
await driver.raw("SELECT 1");
|
|
908
|
-
return driver;
|
|
878
|
+
return new SqliteDriver(openDatabase(config), config);
|
|
909
879
|
},
|
|
910
880
|
async test(config) {
|
|
911
|
-
|
|
881
|
+
const db = openDatabase(config);
|
|
882
|
+
try {
|
|
883
|
+
db.prepare("SELECT 1").get();
|
|
884
|
+
} finally {
|
|
885
|
+
db.close();
|
|
886
|
+
}
|
|
912
887
|
},
|
|
913
888
|
parseConfig(body) {
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (!accountId || !databaseId || !apiToken) {
|
|
918
|
-
throw new Error("accountId, databaseId and apiToken are required");
|
|
889
|
+
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
890
|
+
if (!file) {
|
|
891
|
+
throw new Error("file is required");
|
|
919
892
|
}
|
|
920
|
-
return {
|
|
893
|
+
return {
|
|
894
|
+
driver: "sqlite",
|
|
895
|
+
file,
|
|
896
|
+
createIfMissing: body.createIfMissing === true,
|
|
897
|
+
readonly: body.readonly === true
|
|
898
|
+
};
|
|
921
899
|
}
|
|
922
900
|
};
|
|
923
901
|
|
|
924
|
-
// src/
|
|
925
|
-
import
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
function globToRegExp(pattern) {
|
|
945
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
946
|
-
return new RegExp(`^${escaped}$`, "i");
|
|
947
|
-
}
|
|
948
|
-
function hostPatternMatches(patterns, host) {
|
|
949
|
-
let matched = false;
|
|
950
|
-
for (const raw of patterns) {
|
|
951
|
-
const negated = raw.startsWith("!");
|
|
952
|
-
const pattern = negated ? raw.slice(1) : raw;
|
|
953
|
-
if (!globToRegExp(pattern).test(host)) continue;
|
|
954
|
-
if (negated) return false;
|
|
955
|
-
matched = true;
|
|
956
|
-
}
|
|
957
|
-
return matched;
|
|
902
|
+
// src/drivers/mssql.ts
|
|
903
|
+
import sql from "mssql";
|
|
904
|
+
function toPoolConfig(config) {
|
|
905
|
+
return {
|
|
906
|
+
server: config.host,
|
|
907
|
+
port: config.port,
|
|
908
|
+
user: config.user,
|
|
909
|
+
password: config.password,
|
|
910
|
+
database: config.database,
|
|
911
|
+
// One physical connection: manual transactions and USE need session affinity.
|
|
912
|
+
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
913
|
+
options: {
|
|
914
|
+
encrypt: config.ssl === true,
|
|
915
|
+
trustServerCertificate: true,
|
|
916
|
+
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
917
|
+
useUTC: false,
|
|
918
|
+
enableArithAbort: true
|
|
919
|
+
},
|
|
920
|
+
requestTimeout: 0
|
|
921
|
+
};
|
|
958
922
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
return tokens;
|
|
923
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
924
|
+
function formatDate(d) {
|
|
925
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
926
|
+
const ms = d.getMilliseconds();
|
|
927
|
+
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
|
|
965
928
|
}
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
if (
|
|
969
|
-
|
|
970
|
-
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
971
|
-
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
929
|
+
function normalizeValue(value) {
|
|
930
|
+
if (value instanceof Date) return formatDate(value);
|
|
931
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
932
|
+
return value;
|
|
972
933
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
} catch {
|
|
979
|
-
return [];
|
|
934
|
+
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
935
|
+
var MssqlDriver = class {
|
|
936
|
+
constructor(pool, config) {
|
|
937
|
+
this.pool = pool;
|
|
938
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
980
939
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
case "hostname":
|
|
1010
|
-
result.hostName ??= value.replace(/%h/g, host);
|
|
1011
|
-
break;
|
|
1012
|
-
case "port":
|
|
1013
|
-
result.port ??= Number(value) || void 0;
|
|
1014
|
-
break;
|
|
1015
|
-
case "user":
|
|
1016
|
-
result.user ??= value;
|
|
1017
|
-
break;
|
|
1018
|
-
case "identityfile":
|
|
1019
|
-
for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
|
|
1020
|
-
break;
|
|
1021
|
-
case "identityagent":
|
|
1022
|
-
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
1023
|
-
break;
|
|
1024
|
-
case "proxyjump":
|
|
1025
|
-
result.proxyJump ??= value;
|
|
1026
|
-
break;
|
|
1027
|
-
}
|
|
940
|
+
pool;
|
|
941
|
+
type = "mssql";
|
|
942
|
+
label;
|
|
943
|
+
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
944
|
+
sources = /* @__PURE__ */ new Map();
|
|
945
|
+
/**
|
|
946
|
+
* The `mssql` driver does not report which table a result column comes
|
|
947
|
+
* from; ask the server to describe the statement instead so the grid can
|
|
948
|
+
* decide whether it is editable and address rows by primary key.
|
|
949
|
+
*/
|
|
950
|
+
async describe(text) {
|
|
951
|
+
const cached = this.sources.get(text);
|
|
952
|
+
if (cached !== void 0) return cached;
|
|
953
|
+
let result = null;
|
|
954
|
+
try {
|
|
955
|
+
const req = this.pool.request();
|
|
956
|
+
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
957
|
+
const res = await req.query(
|
|
958
|
+
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
959
|
+
);
|
|
960
|
+
result = res.recordset.map((r) => ({
|
|
961
|
+
schema: r.source_schema ?? "",
|
|
962
|
+
table: r.source_table ?? "",
|
|
963
|
+
column: r.source_column ?? "",
|
|
964
|
+
type: (r.system_type_name ?? "").toLowerCase()
|
|
965
|
+
}));
|
|
966
|
+
} catch {
|
|
967
|
+
result = null;
|
|
1028
968
|
}
|
|
969
|
+
if (this.sources.size > 200) this.sources.clear();
|
|
970
|
+
this.sources.set(text, result);
|
|
971
|
+
return result;
|
|
1029
972
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
973
|
+
async query(text) {
|
|
974
|
+
const req = this.pool.request();
|
|
975
|
+
req.arrayRowMode = true;
|
|
976
|
+
const res = await req.query(text);
|
|
977
|
+
const sets = res.recordsets;
|
|
978
|
+
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
979
|
+
const columns = last?.columns;
|
|
980
|
+
if (last && columns) {
|
|
981
|
+
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
982
|
+
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
983
|
+
const fields = meta.map((col, i) => {
|
|
984
|
+
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
985
|
+
const typeName = col.type?.declaration ?? "";
|
|
986
|
+
return {
|
|
987
|
+
name: col.name,
|
|
988
|
+
orgName: src?.column || col.name,
|
|
989
|
+
type: src?.type || typeName.toLowerCase(),
|
|
990
|
+
table: src?.table ?? "",
|
|
991
|
+
orgTable: src?.table ?? "",
|
|
992
|
+
db: src?.schema ?? "",
|
|
993
|
+
maxLength: typeof col.length === "number" ? col.length : 0
|
|
994
|
+
};
|
|
995
|
+
});
|
|
996
|
+
const records = last.map((row) => row.map(normalizeValue));
|
|
997
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
fields: [],
|
|
1001
|
+
records: [],
|
|
1002
|
+
affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
|
|
1003
|
+
insertId: 0,
|
|
1004
|
+
isResultQuery: false
|
|
1005
|
+
};
|
|
1040
1006
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
var CONNECT_TIMEOUT_MS = 2e4;
|
|
1044
|
-
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
1045
|
-
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
1046
|
-
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
1047
|
-
function parseSshConfig(value) {
|
|
1048
|
-
if (value === void 0 || value === null) return void 0;
|
|
1049
|
-
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
1050
|
-
const body = value;
|
|
1051
|
-
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1052
|
-
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
1053
|
-
const auth = body.auth;
|
|
1054
|
-
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
1055
|
-
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
1056
|
-
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
1007
|
+
async close() {
|
|
1008
|
+
await this.pool.close();
|
|
1057
1009
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1010
|
+
};
|
|
1011
|
+
var mssqlDriverFactory = {
|
|
1012
|
+
async open(config) {
|
|
1013
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1014
|
+
await pool.connect();
|
|
1015
|
+
return new MssqlDriver(pool, config);
|
|
1016
|
+
},
|
|
1017
|
+
async test(config) {
|
|
1018
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1019
|
+
await pool.connect();
|
|
1020
|
+
await pool.close();
|
|
1021
|
+
},
|
|
1022
|
+
parseConfig(body) {
|
|
1023
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1024
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1025
|
+
if (!host || !user) {
|
|
1026
|
+
throw new Error("host and user are required");
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
driver: "mssql",
|
|
1030
|
+
host,
|
|
1031
|
+
port: Number(body.port) || 1433,
|
|
1032
|
+
user,
|
|
1033
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1034
|
+
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
1035
|
+
ssl: body.ssl === true
|
|
1036
|
+
};
|
|
1083
1037
|
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// src/drivers/oracle.ts
|
|
1041
|
+
import oracledb from "oracledb";
|
|
1042
|
+
oracledb.autoCommit = true;
|
|
1043
|
+
oracledb.fetchAsString = [oracledb.CLOB];
|
|
1044
|
+
var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
|
|
1045
|
+
var pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
1046
|
+
function formatDate2(d) {
|
|
1047
|
+
const ms = d.getMilliseconds();
|
|
1048
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
|
|
1084
1049
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
let sawType = false;
|
|
1090
|
-
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
1091
|
-
for (const raw of lines) {
|
|
1092
|
-
const line = raw.trim();
|
|
1093
|
-
if (!line || line.startsWith("#") || line.startsWith("@") || line.startsWith("|")) continue;
|
|
1094
|
-
const [hosts, lineType, lineKey] = line.split(/\s+/);
|
|
1095
|
-
if (!hosts || !lineType || !lineKey) continue;
|
|
1096
|
-
if (!hosts.toLowerCase().split(",").some((h) => patterns.includes(h))) continue;
|
|
1097
|
-
if (lineType !== type) continue;
|
|
1098
|
-
if (lineKey === encoded) return "match";
|
|
1099
|
-
sawType = true;
|
|
1100
|
-
}
|
|
1101
|
-
return sawType ? "mismatch" : "unknown";
|
|
1050
|
+
function normalizeValue2(value) {
|
|
1051
|
+
if (value instanceof Date) return formatDate2(value);
|
|
1052
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
1053
|
+
return value;
|
|
1102
1054
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1055
|
+
var SESSION_FORMATS = [
|
|
1056
|
+
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
|
|
1057
|
+
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
|
|
1058
|
+
"ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
|
|
1059
|
+
];
|
|
1060
|
+
var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
1061
|
+
var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
|
|
1062
|
+
function normalizeStatement(sql2) {
|
|
1063
|
+
const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
|
|
1064
|
+
if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
1065
|
+
return trimmed.replace(/;\s*$/, "");
|
|
1108
1066
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1067
|
+
var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
|
|
1068
|
+
var START_TX = /^\s*SET\s+TRANSACTION\b/i;
|
|
1069
|
+
var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
|
|
1070
|
+
function singleSourceTable(sql2) {
|
|
1071
|
+
const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
|
|
1072
|
+
if (!m) return null;
|
|
1073
|
+
const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
|
|
1074
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1075
|
+
const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
|
|
1076
|
+
return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
|
|
1111
1077
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1078
|
+
var OracleDriver = class {
|
|
1079
|
+
constructor(conn, config) {
|
|
1080
|
+
this.conn = conn;
|
|
1081
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
1082
|
+
this.currentSchema = config.user.toUpperCase();
|
|
1116
1083
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
400
|
|
1127
|
-
);
|
|
1084
|
+
conn;
|
|
1085
|
+
type = "oracle";
|
|
1086
|
+
label;
|
|
1087
|
+
currentSchema;
|
|
1088
|
+
manualTx = false;
|
|
1089
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
1090
|
+
columns = /* @__PURE__ */ new Map();
|
|
1091
|
+
async init() {
|
|
1092
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
1128
1093
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1094
|
+
async tableColumns(schema, table) {
|
|
1095
|
+
const key = `${schema}.${table}`;
|
|
1096
|
+
const cached = this.columns.get(key);
|
|
1097
|
+
if (cached !== void 0) return cached;
|
|
1098
|
+
let out = null;
|
|
1099
|
+
try {
|
|
1100
|
+
const res = await this.conn.execute(
|
|
1101
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
1102
|
+
{ o: schema, t: table },
|
|
1103
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
1104
|
+
);
|
|
1105
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
1106
|
+
} catch {
|
|
1107
|
+
out = null;
|
|
1132
1108
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1109
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1110
|
+
this.columns.set(key, out);
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
async query(sql2, database) {
|
|
1114
|
+
if (database && database !== this.currentSchema) {
|
|
1115
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
1116
|
+
this.currentSchema = database;
|
|
1141
1117
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1118
|
+
const text = normalizeStatement(sql2);
|
|
1119
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
1120
|
+
const res = await this.conn.execute(text, [], {
|
|
1121
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
1122
|
+
fetchTypeHandler,
|
|
1123
|
+
autoCommit: !this.manualTx
|
|
1124
|
+
});
|
|
1125
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
1126
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
1127
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
1128
|
+
const source = singleSourceTable(text);
|
|
1129
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
1130
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
1131
|
+
const fields = res.metaData.map((col) => {
|
|
1132
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
1133
|
+
return {
|
|
1134
|
+
name: col.name,
|
|
1135
|
+
orgName: col.name,
|
|
1136
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
1137
|
+
table: fromTable ? source.table : "",
|
|
1138
|
+
orgTable: fromTable ? source.table : "",
|
|
1139
|
+
db: fromTable ? schema : "",
|
|
1140
|
+
maxLength: col.byteSize ?? 0
|
|
1141
|
+
};
|
|
1142
|
+
});
|
|
1143
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
1144
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1147
1145
|
}
|
|
1146
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
1148
1147
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1148
|
+
async close() {
|
|
1149
|
+
await this.conn.close();
|
|
1151
1150
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
username: ssh.user,
|
|
1159
|
-
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
1160
|
-
keepaliveInterval: 3e4
|
|
1151
|
+
};
|
|
1152
|
+
function connectionAttributes(config) {
|
|
1153
|
+
return {
|
|
1154
|
+
user: config.user,
|
|
1155
|
+
password: config.password,
|
|
1156
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
1161
1157
|
};
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1158
|
+
}
|
|
1159
|
+
var oracleDriverFactory = {
|
|
1160
|
+
async open(config) {
|
|
1161
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1162
|
+
const driver = new OracleDriver(conn, config);
|
|
1163
|
+
await driver.init();
|
|
1164
|
+
return driver;
|
|
1165
|
+
},
|
|
1166
|
+
async test(config) {
|
|
1167
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1168
|
+
await conn.close();
|
|
1169
|
+
},
|
|
1170
|
+
parseConfig(body) {
|
|
1171
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1172
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1173
|
+
if (!host || !user) {
|
|
1174
|
+
throw new Error("host and user are required");
|
|
1171
1175
|
}
|
|
1176
|
+
return {
|
|
1177
|
+
driver: "oracle",
|
|
1178
|
+
host,
|
|
1179
|
+
port: Number(body.port) || 1521,
|
|
1180
|
+
user,
|
|
1181
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1182
|
+
serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
// src/drivers/d1.ts
|
|
1188
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
1189
|
+
var D1Error = class extends Error {
|
|
1190
|
+
constructor(message, status) {
|
|
1191
|
+
super(message);
|
|
1192
|
+
this.status = status;
|
|
1172
1193
|
}
|
|
1194
|
+
status;
|
|
1195
|
+
};
|
|
1196
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
1197
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
1198
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
1199
|
+
function singleSourceTable2(sql2) {
|
|
1200
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
1201
|
+
if (!m) return null;
|
|
1202
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
1203
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1204
|
+
const raw = m[1];
|
|
1205
|
+
const first = raw[0];
|
|
1206
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
1207
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
1208
|
+
if (first === "[") return raw.slice(1, -1);
|
|
1209
|
+
return raw;
|
|
1210
|
+
}
|
|
1211
|
+
function normalizeDeclaredType2(declared) {
|
|
1212
|
+
if (typeof declared !== "string") return null;
|
|
1213
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
1214
|
+
return base || null;
|
|
1173
1215
|
}
|
|
1174
|
-
function
|
|
1175
|
-
|
|
1176
|
-
if (
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1216
|
+
function inferTypeFromValue2(value) {
|
|
1217
|
+
if (value === null || value === void 0) return "null";
|
|
1218
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
1219
|
+
if (Array.isArray(value)) return "blob";
|
|
1220
|
+
return "text";
|
|
1221
|
+
}
|
|
1222
|
+
function firstNonNull2(records, index) {
|
|
1223
|
+
for (const row of records) {
|
|
1224
|
+
const value = row[index];
|
|
1225
|
+
if (value !== null && value !== void 0) return value;
|
|
1184
1226
|
}
|
|
1227
|
+
return null;
|
|
1185
1228
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
if (result === "unknown") {
|
|
1202
|
-
console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
|
|
1203
|
-
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
1204
|
-
}
|
|
1205
|
-
verified(true);
|
|
1206
|
-
});
|
|
1207
|
-
};
|
|
1208
|
-
await new Promise((resolve2, reject) => {
|
|
1209
|
-
client.once("ready", resolve2);
|
|
1210
|
-
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1229
|
+
var D1Driver = class {
|
|
1230
|
+
type = "d1";
|
|
1231
|
+
label;
|
|
1232
|
+
url;
|
|
1233
|
+
headers;
|
|
1234
|
+
/** Declared column types per table, for result-source resolution. */
|
|
1235
|
+
columns = /* @__PURE__ */ new Map();
|
|
1236
|
+
constructor(config) {
|
|
1237
|
+
this.label = `d1:${config.databaseId}`;
|
|
1238
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
|
|
1239
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
1240
|
+
}
|
|
1241
|
+
async raw(sql2) {
|
|
1242
|
+
let res;
|
|
1211
1243
|
try {
|
|
1212
|
-
|
|
1244
|
+
res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
|
|
1213
1245
|
} catch (err) {
|
|
1214
|
-
|
|
1246
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1215
1247
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1248
|
+
let body = null;
|
|
1249
|
+
try {
|
|
1250
|
+
body = await res.json();
|
|
1251
|
+
} catch {
|
|
1252
|
+
}
|
|
1253
|
+
if (!res.ok || !body?.success) {
|
|
1254
|
+
const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
|
|
1255
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
1256
|
+
}
|
|
1257
|
+
const result = body.result?.[0];
|
|
1258
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
1259
|
+
return result;
|
|
1260
|
+
}
|
|
1261
|
+
async tableColumns(table) {
|
|
1262
|
+
const cached = this.columns.get(table);
|
|
1263
|
+
if (cached !== void 0) return cached;
|
|
1264
|
+
let out = null;
|
|
1265
|
+
try {
|
|
1266
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
1267
|
+
const cols = info.results?.columns ?? [];
|
|
1268
|
+
const name = cols.indexOf("name");
|
|
1269
|
+
const type = cols.indexOf("type");
|
|
1270
|
+
const rows = info.results?.rows ?? [];
|
|
1271
|
+
if (rows.length > 0 && name >= 0) {
|
|
1272
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
1230
1273
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1274
|
+
} catch {
|
|
1275
|
+
out = null;
|
|
1276
|
+
}
|
|
1277
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1278
|
+
this.columns.set(table, out);
|
|
1279
|
+
return out;
|
|
1280
|
+
}
|
|
1281
|
+
async query(sql2) {
|
|
1282
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
1283
|
+
return {
|
|
1284
|
+
fields: [
|
|
1285
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1286
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1287
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
1288
|
+
],
|
|
1289
|
+
records: [[0, "main", ""]],
|
|
1290
|
+
affectedRows: 0,
|
|
1291
|
+
insertId: 0,
|
|
1292
|
+
isResultQuery: true
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
1296
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
1297
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
1298
|
+
}
|
|
1299
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
1300
|
+
const result = await this.raw(text);
|
|
1301
|
+
const columns = result.results?.columns ?? [];
|
|
1302
|
+
const records = result.results?.rows ?? [];
|
|
1303
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
1304
|
+
return {
|
|
1305
|
+
fields: [],
|
|
1306
|
+
records: [],
|
|
1307
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
1308
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
1309
|
+
isResultQuery: false
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
1313
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
1314
|
+
const fields = columns.map((name, index) => {
|
|
1315
|
+
const fromTable = known?.has(name) ?? false;
|
|
1316
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
1317
|
+
return {
|
|
1318
|
+
name,
|
|
1319
|
+
orgName: name,
|
|
1320
|
+
type,
|
|
1321
|
+
table: fromTable && source ? source : "",
|
|
1322
|
+
orgTable: fromTable && source ? source : "",
|
|
1323
|
+
db: fromTable ? "main" : "",
|
|
1324
|
+
maxLength: 0
|
|
1325
|
+
};
|
|
1252
1326
|
});
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1327
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1328
|
+
}
|
|
1329
|
+
isDdl(sql2) {
|
|
1330
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
1331
|
+
}
|
|
1332
|
+
async close() {
|
|
1333
|
+
this.columns.clear();
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
var d1DriverFactory = {
|
|
1337
|
+
async open(config) {
|
|
1338
|
+
const driver = new D1Driver(config);
|
|
1339
|
+
await driver.raw("SELECT 1");
|
|
1340
|
+
return driver;
|
|
1341
|
+
},
|
|
1342
|
+
async test(config) {
|
|
1343
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
1344
|
+
},
|
|
1345
|
+
parseConfig(body) {
|
|
1346
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1347
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1348
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
1349
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
1350
|
+
throw new Error("accountId, databaseId and apiToken are required");
|
|
1351
|
+
}
|
|
1352
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1264
1355
|
|
|
1265
1356
|
// src/drivers/index.ts
|
|
1266
1357
|
var factories = {
|
|
@@ -1453,16 +1544,50 @@ function errorStatus(err) {
|
|
|
1453
1544
|
const status = err?.status;
|
|
1454
1545
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
1455
1546
|
}
|
|
1456
|
-
function fail(res, status, message) {
|
|
1457
|
-
res.status(status).json({ success: false, message });
|
|
1547
|
+
function fail(res, status, message, extra = {}) {
|
|
1548
|
+
res.status(status).json({ success: false, message, ...extra });
|
|
1549
|
+
}
|
|
1550
|
+
function errorExtra(err) {
|
|
1551
|
+
if (err instanceof HostKeyError) {
|
|
1552
|
+
const body = { code: "host-key-unknown", hostKey: err.hostKey };
|
|
1553
|
+
return body;
|
|
1554
|
+
}
|
|
1555
|
+
return {};
|
|
1458
1556
|
}
|
|
1459
1557
|
var errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
1460
|
-
function
|
|
1461
|
-
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1464
|
-
return given.length === want.length && timingSafeEqual(given, want);
|
|
1558
|
+
function secretEquals(given, expected) {
|
|
1559
|
+
const a = createHash2("sha256").update(given).digest();
|
|
1560
|
+
const b = createHash2("sha256").update(expected).digest();
|
|
1561
|
+
return timingSafeEqual2(a, b);
|
|
1465
1562
|
}
|
|
1563
|
+
function bearerOf(header) {
|
|
1564
|
+
return header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
1565
|
+
}
|
|
1566
|
+
var AuthThrottle = class {
|
|
1567
|
+
constructor(max = 10, windowMs = 6e4, lockMs = 3e4) {
|
|
1568
|
+
this.max = max;
|
|
1569
|
+
this.windowMs = windowMs;
|
|
1570
|
+
this.lockMs = lockMs;
|
|
1571
|
+
}
|
|
1572
|
+
max;
|
|
1573
|
+
windowMs;
|
|
1574
|
+
lockMs;
|
|
1575
|
+
failures = [];
|
|
1576
|
+
lockedUntil = 0;
|
|
1577
|
+
locked() {
|
|
1578
|
+
return Date.now() < this.lockedUntil;
|
|
1579
|
+
}
|
|
1580
|
+
fail() {
|
|
1581
|
+
const now = Date.now();
|
|
1582
|
+
this.failures = this.failures.filter((t) => now - t < this.windowMs);
|
|
1583
|
+
this.failures.push(now);
|
|
1584
|
+
if (this.failures.length >= this.max) {
|
|
1585
|
+
this.lockedUntil = now + this.lockMs;
|
|
1586
|
+
this.failures = [];
|
|
1587
|
+
console.warn(`[agent] Too many bad tokens; refusing authentication for ${this.lockMs / 1e3}s`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1466
1591
|
function accessControl(allowed) {
|
|
1467
1592
|
return (req, res, next) => {
|
|
1468
1593
|
const origin = req.headers.origin;
|
|
@@ -1496,6 +1621,20 @@ function createAgent(options) {
|
|
|
1496
1621
|
const app = express();
|
|
1497
1622
|
const manager = new ConnectionManager();
|
|
1498
1623
|
const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
|
|
1624
|
+
const allowedHosts = options.allowedHosts ? new Set(options.allowedHosts.map((h) => h.toLowerCase())) : null;
|
|
1625
|
+
const throttle = new AuthThrottle();
|
|
1626
|
+
let pairing = options.pairingCode;
|
|
1627
|
+
app.set("env", "production");
|
|
1628
|
+
app.disable("x-powered-by");
|
|
1629
|
+
app.disable("etag");
|
|
1630
|
+
const authorized = (header) => {
|
|
1631
|
+
if (throttle.locked()) return false;
|
|
1632
|
+
const given = bearerOf(header);
|
|
1633
|
+
if (given === null) return false;
|
|
1634
|
+
const ok = secretEquals(given, options.authToken);
|
|
1635
|
+
if (!ok) throttle.fail();
|
|
1636
|
+
return ok;
|
|
1637
|
+
};
|
|
1499
1638
|
if (options.logRequests) {
|
|
1500
1639
|
app.use((req, _res, next) => {
|
|
1501
1640
|
const hasAuth = !!req.headers.authorization;
|
|
@@ -1503,19 +1642,19 @@ function createAgent(options) {
|
|
|
1503
1642
|
next();
|
|
1504
1643
|
});
|
|
1505
1644
|
}
|
|
1506
|
-
app.use(accessControl(allowedOrigins));
|
|
1507
|
-
app.use(express.json({ limit: options.bodyLimit }));
|
|
1508
1645
|
app.use((req, res, next) => {
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1646
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1647
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1648
|
+
if (allowedHosts) {
|
|
1649
|
+
const host = req.headers.host?.toLowerCase();
|
|
1650
|
+
if (!host || !allowedHosts.has(host)) {
|
|
1651
|
+
fail(res, 403, "Invalid Host header");
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1516
1654
|
}
|
|
1517
1655
|
next();
|
|
1518
1656
|
});
|
|
1657
|
+
app.use(accessControl(allowedOrigins));
|
|
1519
1658
|
app.get("/health", (req, res) => {
|
|
1520
1659
|
const body = {
|
|
1521
1660
|
success: true,
|
|
@@ -1526,10 +1665,41 @@ function createAgent(options) {
|
|
|
1526
1665
|
uptime: process.uptime()
|
|
1527
1666
|
};
|
|
1528
1667
|
if (req.headers.authorization !== void 0) {
|
|
1529
|
-
|
|
1668
|
+
if (throttle.locked()) {
|
|
1669
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
body.authorized = authorized(req.headers.authorization);
|
|
1673
|
+
}
|
|
1674
|
+
res.json(body);
|
|
1675
|
+
});
|
|
1676
|
+
app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
|
|
1677
|
+
if (throttle.locked()) {
|
|
1678
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const code = typeof req.body?.code === "string" ? req.body.code : "";
|
|
1682
|
+
if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
|
|
1683
|
+
throttle.fail();
|
|
1684
|
+
fail(res, 401, "Pairing code is invalid or has expired; restart the agent for a new link");
|
|
1685
|
+
return;
|
|
1530
1686
|
}
|
|
1687
|
+
pairing = void 0;
|
|
1688
|
+
const body = { success: true, token: options.authToken };
|
|
1531
1689
|
res.json(body);
|
|
1532
1690
|
});
|
|
1691
|
+
app.use((req, res, next) => {
|
|
1692
|
+
if (throttle.locked()) {
|
|
1693
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
if (!authorized(req.headers.authorization)) {
|
|
1697
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
next();
|
|
1701
|
+
});
|
|
1702
|
+
app.use(express.json({ limit: options.bodyLimit }));
|
|
1533
1703
|
app.post("/connect", async (req, res) => {
|
|
1534
1704
|
try {
|
|
1535
1705
|
const config = parseDriverConfig(req.body ?? {});
|
|
@@ -1538,7 +1708,7 @@ function createAgent(options) {
|
|
|
1538
1708
|
} catch (err) {
|
|
1539
1709
|
const message = errorMessage(err);
|
|
1540
1710
|
console.error("[connect] Error:", message);
|
|
1541
|
-
fail(res, errorStatus(err), message);
|
|
1711
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
1542
1712
|
}
|
|
1543
1713
|
});
|
|
1544
1714
|
app.post("/query", async (req, res) => {
|
|
@@ -1596,9 +1766,17 @@ function createAgent(options) {
|
|
|
1596
1766
|
} catch (err) {
|
|
1597
1767
|
const message = errorMessage(err);
|
|
1598
1768
|
console.error("[test-connection] Error:", message);
|
|
1599
|
-
fail(res, errorStatus(err), message);
|
|
1769
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
1600
1770
|
}
|
|
1601
1771
|
});
|
|
1772
|
+
app.use((_req, res) => fail(res, 404, "Not found"));
|
|
1773
|
+
app.use((err, _req, res, _next) => {
|
|
1774
|
+
const e = err;
|
|
1775
|
+
const status = e?.type === "entity.too.large" ? 413 : typeof e?.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 400;
|
|
1776
|
+
console.error("[agent] Request error:", e?.message ?? String(err));
|
|
1777
|
+
if (res.headersSent) return;
|
|
1778
|
+
fail(res, status, status === 413 ? "Request body too large" : status === 400 ? "Malformed request" : "Request failed");
|
|
1779
|
+
});
|
|
1602
1780
|
return { app, manager };
|
|
1603
1781
|
}
|
|
1604
1782
|
|
|
@@ -1623,6 +1801,7 @@ Options:
|
|
|
1623
1801
|
--app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
|
|
1624
1802
|
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
1625
1803
|
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
1804
|
+
--allow-remote Required with a non-loopback --addr (see README: no TLS)
|
|
1626
1805
|
--no-open Do not open the browser
|
|
1627
1806
|
--verbose Log every request
|
|
1628
1807
|
-v, --version Print the version
|
|
@@ -1634,13 +1813,17 @@ function splitList(value) {
|
|
|
1634
1813
|
}
|
|
1635
1814
|
function originOf(url) {
|
|
1636
1815
|
try {
|
|
1637
|
-
|
|
1816
|
+
const parsed = new URL(url);
|
|
1817
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1818
|
+
return parsed.origin === "null" ? null : parsed.origin;
|
|
1638
1819
|
} catch {
|
|
1639
1820
|
return null;
|
|
1640
1821
|
}
|
|
1641
1822
|
}
|
|
1823
|
+
var LOOPBACK_ADDRS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
1824
|
+
var MIN_TOKEN_LENGTH = 16;
|
|
1642
1825
|
function openInBrowser(url) {
|
|
1643
|
-
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["
|
|
1826
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
1644
1827
|
try {
|
|
1645
1828
|
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
|
|
1646
1829
|
} catch {
|
|
@@ -1664,6 +1847,7 @@ function main() {
|
|
|
1664
1847
|
origin: { type: "string", multiple: true },
|
|
1665
1848
|
open: { type: "boolean" },
|
|
1666
1849
|
verbose: { type: "boolean" },
|
|
1850
|
+
"allow-remote": { type: "boolean" },
|
|
1667
1851
|
version: { type: "boolean", short: "v" },
|
|
1668
1852
|
help: { type: "boolean", short: "h" }
|
|
1669
1853
|
},
|
|
@@ -1693,37 +1877,59 @@ function main() {
|
|
|
1693
1877
|
const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
|
|
1694
1878
|
const appOrigin = originOf(appUrl);
|
|
1695
1879
|
if (!appOrigin) {
|
|
1696
|
-
console.error(`Invalid --app URL: ${appUrl}`);
|
|
1880
|
+
console.error(`Invalid --app URL (http or https only): ${appUrl}`);
|
|
1881
|
+
process.exit(2);
|
|
1882
|
+
}
|
|
1883
|
+
const loopback = LOOPBACK_ADDRS.has(addr);
|
|
1884
|
+
if (!loopback) {
|
|
1885
|
+
if (!flags["allow-remote"]) {
|
|
1886
|
+
console.error(`Refusing to bind to ${addr}: the agent speaks plain HTTP, so database passwords and SSH keys would cross the network unencrypted.`);
|
|
1887
|
+
console.error("Put it behind a TLS reverse proxy or an SSH port forward, then pass --allow-remote together with --token (or PLANCHE_AUTH_TOKEN).");
|
|
1888
|
+
process.exit(2);
|
|
1889
|
+
}
|
|
1890
|
+
if (!flags.token && !process.env.PLANCHE_AUTH_TOKEN) {
|
|
1891
|
+
console.error("--allow-remote needs an explicit --token / PLANCHE_AUTH_TOKEN.");
|
|
1892
|
+
process.exit(2);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if ((flags.token || process.env.PLANCHE_AUTH_TOKEN) && authToken.length < MIN_TOKEN_LENGTH) {
|
|
1896
|
+
console.error(`The auth token must be at least ${MIN_TOKEN_LENGTH} characters (got ${authToken.length}). Generate one with: openssl rand -base64 24`);
|
|
1697
1897
|
process.exit(2);
|
|
1698
1898
|
}
|
|
1699
|
-
const
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1899
|
+
const extraOrigins = [...flags.origin ?? [], ...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)];
|
|
1900
|
+
for (const o of extraOrigins) {
|
|
1901
|
+
if (originOf(o) !== o.replace(/\/+$/, "")) {
|
|
1902
|
+
console.error(`Invalid --origin (expected scheme://host[:port]): ${o}`);
|
|
1903
|
+
process.exit(2);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
|
|
1907
|
+
const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
|
|
1908
|
+
const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
|
|
1909
|
+
const agentUrl = `http://${addr}:${port}`;
|
|
1705
1910
|
const { app, manager } = createAgent({
|
|
1706
1911
|
authToken,
|
|
1707
1912
|
allowedOrigins,
|
|
1708
1913
|
bodyLimit,
|
|
1709
1914
|
version: VERSION,
|
|
1710
|
-
logRequests: flags.verbose ?? false
|
|
1915
|
+
logRequests: flags.verbose ?? false,
|
|
1916
|
+
allowedHosts: loopback ? [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
|
|
1917
|
+
pairingCode
|
|
1711
1918
|
});
|
|
1712
|
-
const
|
|
1713
|
-
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&token=${encodeURIComponent(authToken)}`;
|
|
1919
|
+
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
|
|
1714
1920
|
const server = app.listen(port, addr, () => {
|
|
1715
1921
|
const line = "-".repeat(72);
|
|
1716
1922
|
console.log(line);
|
|
1717
1923
|
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
1718
1924
|
console.log(line);
|
|
1719
|
-
console.log(`Open the app with:
|
|
1925
|
+
console.log(`Open the app with (link valid for 10 minutes, single use):
|
|
1720
1926
|
|
|
1721
1927
|
${pairUrl}
|
|
1722
1928
|
`);
|
|
1723
|
-
console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token: ${authToken}`);
|
|
1929
|
+
console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token (for manual setup): ${authToken}`);
|
|
1724
1930
|
console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
|
|
1725
|
-
if (!
|
|
1726
|
-
console.
|
|
1931
|
+
if (!loopback) {
|
|
1932
|
+
console.error(`WARNING: bound to ${addr} without TLS \u2014 anyone who can reach this port and knows the token can use your databases, and anyone on the path can read the traffic.`);
|
|
1727
1933
|
}
|
|
1728
1934
|
console.log(line);
|
|
1729
1935
|
if (shouldOpen) openInBrowser(pairUrl);
|