@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3
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/apps/agent/src/cloudflare.mjs +858 -733
- package/apps/agent/src/files.mjs +162 -0
- package/apps/agent/src/host-tools.mjs +518 -0
- package/apps/agent/src/native.mjs +157 -32
- package/apps/agent/src/runtime.mjs +70 -37
- package/apps/agent/src/server.mjs +61 -6
- package/apps/panel/app/api/[...path]/route.js +96 -14
- package/apps/panel/app/globals.css +313 -7
- package/apps/panel/app/layout.jsx +2 -1
- package/apps/panel/app/login/page.jsx +3 -8
- package/apps/panel/app/page.jsx +17 -7
- package/apps/panel/app/ui/cloudflare.jsx +72 -47
- package/apps/panel/app/ui/confirm.jsx +109 -0
- package/apps/panel/app/ui/dashboard.jsx +87 -72
- package/apps/panel/app/ui/databases.jsx +168 -59
- package/apps/panel/app/ui/project-tools.jsx +604 -0
- package/apps/panel/app/ui/toast.jsx +90 -0
- package/apps/panel/lib/agent.mjs +13 -2
- package/apps/panel/lib/databases.mjs +120 -0
- package/apps/panel/lib/request-origin.mjs +29 -0
- package/package.json +1 -1
- package/packages/shared/index.mjs +49 -0
- package/scripts/panel-control.mjs +214 -214
- package/scripts/service-supervisor.mjs +164 -164
- package/scripts/windows-boot.mjs +259 -259
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
const TEXT_LIMIT = 1024 * 1024;
|
|
5
|
+
const UPLOAD_LIMIT = 32 * 1024 * 1024;
|
|
6
|
+
const DOWNLOAD_LIMIT = 256 * 1024 * 1024;
|
|
7
|
+
const LIST_LIMIT = 500;
|
|
8
|
+
|
|
9
|
+
function fail(message, status = 400) {
|
|
10
|
+
return Object.assign(Error(message), { status });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function safeRelative(value) {
|
|
14
|
+
const raw = String(value ?? "")
|
|
15
|
+
.replaceAll("\\", "/")
|
|
16
|
+
.trim();
|
|
17
|
+
if (!raw || raw === ".") return "";
|
|
18
|
+
if (raw.includes("\0") || raw.startsWith("/") || /^[a-zA-Z]:/.test(raw))
|
|
19
|
+
throw fail("Use a path inside the application directory");
|
|
20
|
+
const parts = [];
|
|
21
|
+
for (const part of raw.split("/")) {
|
|
22
|
+
if (!part || part === ".") continue;
|
|
23
|
+
if (part === ".." || part.includes(":") || part.includes("\0"))
|
|
24
|
+
throw fail("Use a path inside the application directory");
|
|
25
|
+
parts.push(part);
|
|
26
|
+
}
|
|
27
|
+
return parts.join("/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function inside(root, relative, { allowMissing = false } = {}) {
|
|
31
|
+
const safe = safeRelative(relative);
|
|
32
|
+
const realRoot = await realpath(root);
|
|
33
|
+
if (!safe) {
|
|
34
|
+
return { root: realRoot, target: realRoot, relative: "", stat: await lstat(realRoot) };
|
|
35
|
+
}
|
|
36
|
+
const target = path.resolve(realRoot, safe);
|
|
37
|
+
const parent = path.dirname(target);
|
|
38
|
+
let realParent;
|
|
39
|
+
try {
|
|
40
|
+
realParent = await realpath(parent);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === "ENOENT") throw fail("Folder not found", 404);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
const parentRel = path.relative(realRoot, realParent);
|
|
46
|
+
if (parentRel.startsWith("..") || path.isAbsolute(parentRel))
|
|
47
|
+
throw fail("Use a path inside the application directory");
|
|
48
|
+
let stat = null;
|
|
49
|
+
try {
|
|
50
|
+
stat = await lstat(target);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT") throw error;
|
|
53
|
+
if (!allowMissing) throw fail("File not found", 404);
|
|
54
|
+
}
|
|
55
|
+
if (stat?.isSymbolicLink()) throw fail("Links are not allowed");
|
|
56
|
+
if (stat) {
|
|
57
|
+
const realTarget = await realpath(target);
|
|
58
|
+
const rel = path.relative(realRoot, realTarget);
|
|
59
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
60
|
+
throw fail("Use a path inside the application directory");
|
|
61
|
+
}
|
|
62
|
+
return { root: realRoot, target, relative: safe, stat };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function projectAppRoot(folderPath) {
|
|
66
|
+
if (!folderPath) throw fail("Deploy the project before opening files", 409);
|
|
67
|
+
const root = path.resolve(folderPath, "app");
|
|
68
|
+
await mkdir(root, { recursive: true });
|
|
69
|
+
return realpath(root);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function listFiles(root, relative = "") {
|
|
73
|
+
const located = await inside(root, relative);
|
|
74
|
+
if (!located.stat?.isDirectory()) throw fail("Folder not found", 404);
|
|
75
|
+
const names = await readdir(located.target);
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const name of names.sort((a, b) => a.localeCompare(b))) {
|
|
78
|
+
if (entries.length >= LIST_LIMIT) break;
|
|
79
|
+
const stat = await lstat(path.join(located.target, name));
|
|
80
|
+
entries.push({
|
|
81
|
+
name,
|
|
82
|
+
kind: stat.isSymbolicLink()
|
|
83
|
+
? "link"
|
|
84
|
+
: stat.isDirectory()
|
|
85
|
+
? "dir"
|
|
86
|
+
: "file",
|
|
87
|
+
size: stat.isDirectory() ? null : stat.size,
|
|
88
|
+
modified: stat.mtime.toISOString(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
path: located.relative,
|
|
93
|
+
entries,
|
|
94
|
+
truncated: names.length > LIST_LIMIT,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readFileText(root, relative) {
|
|
99
|
+
const located = await inside(root, relative);
|
|
100
|
+
if (!located.relative) throw fail("Choose a file");
|
|
101
|
+
if (!located.stat?.isFile()) throw fail("Choose a file");
|
|
102
|
+
if (located.stat.size > TEXT_LIMIT)
|
|
103
|
+
return { path: located.relative, binary: true, size: located.stat.size };
|
|
104
|
+
const buffer = await readFile(located.target);
|
|
105
|
+
if (buffer.includes(0))
|
|
106
|
+
return { path: located.relative, binary: true, size: located.stat.size };
|
|
107
|
+
return { path: located.relative, binary: false, content: buffer.toString("utf8") };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function writeFileText(root, relative, content) {
|
|
111
|
+
if (typeof content !== "string") throw fail("File content must be text");
|
|
112
|
+
if (content.length > TEXT_LIMIT) throw fail("File is larger than 1 MB", 413);
|
|
113
|
+
const safe = safeRelative(relative);
|
|
114
|
+
if (!safe) throw fail("Choose a file");
|
|
115
|
+
const located = await inside(root, safe, { allowMissing: true });
|
|
116
|
+
if (located.stat?.isDirectory()) throw fail("Choose a file name");
|
|
117
|
+
await writeFile(located.target, content, "utf8");
|
|
118
|
+
return { ok: true, path: safe };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function createDirectory(root, relative) {
|
|
122
|
+
const safe = safeRelative(relative);
|
|
123
|
+
if (!safe) throw fail("Choose a folder name");
|
|
124
|
+
const located = await inside(root, safe, { allowMissing: true });
|
|
125
|
+
if (located.stat) throw fail("That name already exists", 409);
|
|
126
|
+
await mkdir(located.target);
|
|
127
|
+
return { ok: true, path: safe };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function removePath(root, relative) {
|
|
131
|
+
const safe = safeRelative(relative);
|
|
132
|
+
if (!safe) throw fail("Choose a file or folder");
|
|
133
|
+
const located = await inside(root, safe);
|
|
134
|
+
if (located.target === located.root) throw fail("The application directory cannot be removed");
|
|
135
|
+
await rm(located.target, { recursive: true, force: false });
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function saveUpload(root, relative, source) {
|
|
140
|
+
const safe = safeRelative(relative);
|
|
141
|
+
if (!safe) throw fail("Choose a file name");
|
|
142
|
+
const located = await inside(root, safe, { allowMissing: true });
|
|
143
|
+
if (located.stat?.isDirectory()) throw fail("Choose a file name");
|
|
144
|
+
const chunks = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
for await (const chunk of source) {
|
|
147
|
+
size += chunk.length;
|
|
148
|
+
if (size > UPLOAD_LIMIT) throw fail("File is larger than 32 MB", 413);
|
|
149
|
+
chunks.push(chunk);
|
|
150
|
+
}
|
|
151
|
+
await writeFile(located.target, Buffer.concat(chunks));
|
|
152
|
+
return { ok: true, path: safe, size };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function openDownload(root, relative) {
|
|
156
|
+
const located = await inside(root, relative);
|
|
157
|
+
if (!located.stat?.isFile()) throw fail("Choose a file");
|
|
158
|
+
if (located.stat.size > DOWNLOAD_LIMIT)
|
|
159
|
+
throw fail("File is larger than 256 MB", 413);
|
|
160
|
+
const name = path.basename(located.target).replace(/[^A-Za-z0-9._-]/g, "_") || "download";
|
|
161
|
+
return { file: located.target, name };
|
|
162
|
+
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
chmod,
|
|
6
|
+
mkdir,
|
|
7
|
+
readdir,
|
|
8
|
+
readFile,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { Readable, Transform } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
15
|
+
import { createGunzip } from "node:zlib";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import yauzl from "yauzl";
|
|
18
|
+
|
|
19
|
+
const HOSTS = new Set([
|
|
20
|
+
"github.com",
|
|
21
|
+
"release-assets.githubusercontent.com",
|
|
22
|
+
"objects.githubusercontent.com",
|
|
23
|
+
"github-releases.githubusercontent.com",
|
|
24
|
+
"windows.php.net",
|
|
25
|
+
"downloads.php.net",
|
|
26
|
+
"www.php.net",
|
|
27
|
+
"dl.static-php.dev",
|
|
28
|
+
]);
|
|
29
|
+
const PHP_VERSION = "8.4.26";
|
|
30
|
+
const LINUX_PHP_VERSION = "8.4.23";
|
|
31
|
+
const CADDY_VERSION = "2.11.7";
|
|
32
|
+
const WINDOWS = {
|
|
33
|
+
php: {
|
|
34
|
+
version: PHP_VERSION,
|
|
35
|
+
file: `php-${PHP_VERSION}-nts-Win32-vs17-x64.zip`,
|
|
36
|
+
url: `https://windows.php.net/downloads/releases/php-${PHP_VERSION}-nts-Win32-vs17-x64.zip`,
|
|
37
|
+
sha256:
|
|
38
|
+
"da68394f9193b7f6b89d0c76861a4034ae10efee7fd55a7255d8118c2acf70d7",
|
|
39
|
+
},
|
|
40
|
+
caddy: {
|
|
41
|
+
version: CADDY_VERSION,
|
|
42
|
+
file: `caddy_${CADDY_VERSION}_windows_amd64.zip`,
|
|
43
|
+
url: `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_windows_amd64.zip`,
|
|
44
|
+
sha256:
|
|
45
|
+
"0a1edc0b799512051c57071ce0e798d3f2cf67dc98171366f1d2b326072e3b06",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const LINUX_PHP_SHA256 = {
|
|
49
|
+
x86_64:
|
|
50
|
+
"a7119303b87687e87b7ae71929ff64970882aeb5a8ae8befab641396e1179280",
|
|
51
|
+
aarch64:
|
|
52
|
+
"b3f16bb29920be756c803601200cc8f65655fab6e377d66d21dccd4296ea75c8",
|
|
53
|
+
};
|
|
54
|
+
const LINUX_CADDY_SHA512 = {
|
|
55
|
+
amd64:
|
|
56
|
+
"a7a433a1b133efc3c8d10eb0b99d52a24b5ef5c322dc77f5282182b1c0402139ab83f3a99f0c52409df77d20123fb0b523edad8a66d8f5e49136197bf61ef0e7",
|
|
57
|
+
arm64:
|
|
58
|
+
"3db36ba90c7a6e8dda40ee3dd71fa08844c76b5fb08f61b31e5e78d2ed38e71c51dc7baed875e50d1ca1279196e84302967237386ae87c91ae9f2aaceada682e",
|
|
59
|
+
};
|
|
60
|
+
const EXTENSIONS = [
|
|
61
|
+
"curl",
|
|
62
|
+
"fileinfo",
|
|
63
|
+
"mbstring",
|
|
64
|
+
"openssl",
|
|
65
|
+
"pdo_mysql",
|
|
66
|
+
"mysqli",
|
|
67
|
+
"gd",
|
|
68
|
+
];
|
|
69
|
+
const pending = new Map();
|
|
70
|
+
|
|
71
|
+
export function assertDownloadUrl(value) {
|
|
72
|
+
const url = new URL(value);
|
|
73
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
74
|
+
throw Error("Unexpected download address");
|
|
75
|
+
if (!HOSTS.has(url.hostname)) throw Error("Unexpected download host");
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function exists(file) {
|
|
80
|
+
try {
|
|
81
|
+
await access(file);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checksumOf(artifact) {
|
|
89
|
+
if (artifact.sha256) return ["sha256", artifact.sha256];
|
|
90
|
+
if (artifact.sha512) return ["sha512", artifact.sha512];
|
|
91
|
+
throw Error("Missing checksum");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hashFile(file, algorithm) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const hash = createHash(algorithm);
|
|
97
|
+
createReadStream(file)
|
|
98
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
99
|
+
.on("error", reject)
|
|
100
|
+
.on("end", () => resolve(hash.digest("hex")));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function downloadOnce(artifact, destination, algorithm, digest, fetchImpl) {
|
|
105
|
+
const temporary = `${destination}.${randomUUID()}.part`;
|
|
106
|
+
let current = artifact.url;
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
110
|
+
assertDownloadUrl(current);
|
|
111
|
+
response = await fetchImpl(current, {
|
|
112
|
+
redirect: "manual",
|
|
113
|
+
signal: AbortSignal.timeout(30 * 60 * 1000),
|
|
114
|
+
});
|
|
115
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
116
|
+
const location = response.headers.get("location");
|
|
117
|
+
if (!location) throw Error("Download redirect was empty");
|
|
118
|
+
current = new URL(location, current).href;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (!response?.ok || !response.body) throw Error("Download failed");
|
|
124
|
+
const hash = createHash(algorithm);
|
|
125
|
+
let length = 0;
|
|
126
|
+
await pipeline(
|
|
127
|
+
Readable.fromWeb(response.body),
|
|
128
|
+
new Transform({
|
|
129
|
+
transform(chunk, encoding, callback) {
|
|
130
|
+
length += chunk.length;
|
|
131
|
+
if (length > 80 * 1024 * 1024) callback(Error("Download too large"));
|
|
132
|
+
else {
|
|
133
|
+
hash.update(chunk);
|
|
134
|
+
callback(null, chunk);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
createWriteStream(temporary, { flags: "wx" }),
|
|
139
|
+
);
|
|
140
|
+
if (hash.digest("hex") !== digest) throw Error("Checksum mismatch");
|
|
141
|
+
await rm(destination, { force: true });
|
|
142
|
+
await rename(temporary, destination);
|
|
143
|
+
return destination;
|
|
144
|
+
} finally {
|
|
145
|
+
await rm(temporary, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function download(artifact, directory, fetchImpl) {
|
|
150
|
+
await mkdir(directory, { recursive: true });
|
|
151
|
+
const destination = path.join(directory, artifact.file);
|
|
152
|
+
const [algorithm, digest] = checksumOf(artifact);
|
|
153
|
+
if (
|
|
154
|
+
(await exists(destination)) &&
|
|
155
|
+
(await hashFile(destination, algorithm)) === digest
|
|
156
|
+
)
|
|
157
|
+
return destination;
|
|
158
|
+
let last;
|
|
159
|
+
for (let attempt = 1; attempt <= 4; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
return await downloadOnce(
|
|
162
|
+
artifact,
|
|
163
|
+
destination,
|
|
164
|
+
algorithm,
|
|
165
|
+
digest,
|
|
166
|
+
fetchImpl,
|
|
167
|
+
);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
last = error;
|
|
170
|
+
if (
|
|
171
|
+
/Checksum mismatch|Unexpected download|Download too large/.test(
|
|
172
|
+
error.message || "",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw Error(
|
|
179
|
+
`Could not download ${artifact.file}. Check the network and deploy again. ${last?.message || ""}`.trim(),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractZip(file, destination) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
yauzl.open(
|
|
186
|
+
file,
|
|
187
|
+
{ lazyEntries: true, validateEntrySizes: true },
|
|
188
|
+
(error, zip) => {
|
|
189
|
+
if (error) return reject(error);
|
|
190
|
+
let total = 0;
|
|
191
|
+
const fail = (error) => {
|
|
192
|
+
zip.close();
|
|
193
|
+
reject(error);
|
|
194
|
+
};
|
|
195
|
+
zip.on("error", fail);
|
|
196
|
+
zip.on("end", resolve);
|
|
197
|
+
zip.on("entry", (entry) => {
|
|
198
|
+
const filename = entry.fileName.replaceAll("\\", "/");
|
|
199
|
+
const target = path.resolve(destination, filename);
|
|
200
|
+
const rel = path.relative(destination, target);
|
|
201
|
+
if (
|
|
202
|
+
!rel ||
|
|
203
|
+
rel.startsWith("..") ||
|
|
204
|
+
path.isAbsolute(rel) ||
|
|
205
|
+
filename.includes(":")
|
|
206
|
+
)
|
|
207
|
+
return fail(Error("Unsafe archive entry"));
|
|
208
|
+
total += entry.uncompressedSize;
|
|
209
|
+
if (total > 400 * 1024 * 1024)
|
|
210
|
+
return fail(Error("Archive is too large"));
|
|
211
|
+
if (filename.endsWith("/")) {
|
|
212
|
+
mkdir(target, { recursive: true })
|
|
213
|
+
.then(() => zip.readEntry())
|
|
214
|
+
.catch(fail);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
mkdir(path.dirname(target), { recursive: true })
|
|
218
|
+
.then(
|
|
219
|
+
() =>
|
|
220
|
+
new Promise((done, stop) => {
|
|
221
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
222
|
+
if (error) return stop(error);
|
|
223
|
+
pipeline(stream, createWriteStream(target))
|
|
224
|
+
.then(done)
|
|
225
|
+
.catch(stop);
|
|
226
|
+
});
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
.then(() => zip.readEntry())
|
|
230
|
+
.catch(fail);
|
|
231
|
+
});
|
|
232
|
+
zip.readEntry();
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function archiveHome(file) {
|
|
239
|
+
return file.replace(/\.tar\.gz$/i, "").replace(/\.zip$/i, "");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function octal(buffer) {
|
|
243
|
+
const text = buffer.toString("utf8").replace(/\0.*$/, "").trim();
|
|
244
|
+
if (!text) return 0;
|
|
245
|
+
const value = Number.parseInt(text, 8);
|
|
246
|
+
if (!Number.isFinite(value)) throw Error("Invalid archive header");
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function tarName(header) {
|
|
251
|
+
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
252
|
+
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
|
|
253
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function validTarChecksum(header) {
|
|
257
|
+
const recorded = octal(header.subarray(148, 156));
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
|
|
260
|
+
return sum === recorded;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function safeTarget(destination, name) {
|
|
264
|
+
const filename = String(name).replaceAll("\\", "/");
|
|
265
|
+
if (!filename || filename.startsWith("/") || filename.includes(":"))
|
|
266
|
+
throw Error("Unsafe archive entry");
|
|
267
|
+
const target = path.resolve(destination, filename);
|
|
268
|
+
const rel = path.relative(destination, target);
|
|
269
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
270
|
+
throw Error("Unsafe archive entry");
|
|
271
|
+
return target;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function extractTarGz(file, destination) {
|
|
275
|
+
const chunks = [];
|
|
276
|
+
let length = 0;
|
|
277
|
+
await pipeline(
|
|
278
|
+
createReadStream(file),
|
|
279
|
+
createGunzip(),
|
|
280
|
+
new Transform({
|
|
281
|
+
transform(chunk, encoding, callback) {
|
|
282
|
+
length += chunk.length;
|
|
283
|
+
if (length > 400 * 1024 * 1024) callback(Error("Archive is too large"));
|
|
284
|
+
else {
|
|
285
|
+
chunks.push(chunk);
|
|
286
|
+
callback();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const data = Buffer.concat(chunks);
|
|
292
|
+
let offset = 0;
|
|
293
|
+
let pending = "";
|
|
294
|
+
let total = 0;
|
|
295
|
+
while (offset + 512 <= data.length) {
|
|
296
|
+
const header = data.subarray(offset, offset + 512);
|
|
297
|
+
offset += 512;
|
|
298
|
+
if (header.every((byte) => byte === 0)) continue;
|
|
299
|
+
if (!validTarChecksum(header)) throw Error("Archive header is invalid");
|
|
300
|
+
const size = octal(header.subarray(124, 136));
|
|
301
|
+
if (offset + size > data.length) throw Error("Archive is truncated");
|
|
302
|
+
const body = data.subarray(offset, offset + size);
|
|
303
|
+
offset += Math.ceil(size / 512) * 512;
|
|
304
|
+
const type = String.fromCharCode(header[156]);
|
|
305
|
+
if (type === "L") {
|
|
306
|
+
pending = body.toString("utf8").replace(/\0+$/, "");
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (type === "x") {
|
|
310
|
+
const match = /(?:^|\n)\d+ path=([^\n]+)/.exec(body.toString("utf8"));
|
|
311
|
+
if (match) pending = match[1];
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (type === "g" || type === "K") continue;
|
|
315
|
+
const name = pending || tarName(header);
|
|
316
|
+
pending = "";
|
|
317
|
+
if (type === "5" || name.endsWith("/")) {
|
|
318
|
+
await mkdir(safeTarget(destination, name), { recursive: true });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (type === "1" || type === "2") throw Error("Unsafe archive entry");
|
|
322
|
+
if (type !== "0" && type !== "\0") continue;
|
|
323
|
+
total += size;
|
|
324
|
+
if (total > 400 * 1024 * 1024) throw Error("Archive is too large");
|
|
325
|
+
const target = safeTarget(destination, name);
|
|
326
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
327
|
+
await writeFile(target, body);
|
|
328
|
+
const mode = octal(header.subarray(100, 108));
|
|
329
|
+
if (process.platform !== "win32" && mode & 0o111)
|
|
330
|
+
await chmod(target, mode & 0o777);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function extractArchive(file, destination) {
|
|
335
|
+
if (file.endsWith(".tar.gz")) return extractTarGz(file, destination);
|
|
336
|
+
return extractZip(file, destination);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function install(artifact, directory, fetchImpl, log) {
|
|
340
|
+
const home = path.join(directory, archiveHome(artifact.file));
|
|
341
|
+
const marker = path.join(home, ".naviyra-version");
|
|
342
|
+
if ((await exists(marker)) && (await readFile(marker, "utf8")) === artifact.version)
|
|
343
|
+
return home;
|
|
344
|
+
log(`Downloading ${artifact.file}`);
|
|
345
|
+
const archive = await download(artifact, path.join(directory, "cache"), fetchImpl);
|
|
346
|
+
await rm(home, { recursive: true, force: true });
|
|
347
|
+
await mkdir(home, { recursive: true });
|
|
348
|
+
log(`Installing ${artifact.file}`);
|
|
349
|
+
await extractArchive(archive, home);
|
|
350
|
+
await writeFile(marker, artifact.version);
|
|
351
|
+
return home;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function writePhpIni(home) {
|
|
355
|
+
const lines = [
|
|
356
|
+
"engine = On",
|
|
357
|
+
"cgi.force_redirect = 0",
|
|
358
|
+
"cgi.fix_pathinfo = 1",
|
|
359
|
+
"date.timezone = UTC",
|
|
360
|
+
"expose_php = Off",
|
|
361
|
+
"memory_limit = 256M",
|
|
362
|
+
"upload_max_filesize = 32M",
|
|
363
|
+
"post_max_size = 32M",
|
|
364
|
+
];
|
|
365
|
+
const ext = path.join(home, "ext");
|
|
366
|
+
if (await exists(ext)) {
|
|
367
|
+
lines.splice(1, 0, 'extension_dir = "ext"');
|
|
368
|
+
for (const name of EXTENSIONS) {
|
|
369
|
+
if (
|
|
370
|
+
(await exists(path.join(ext, `php_${name}.dll`))) ||
|
|
371
|
+
(await exists(path.join(ext, `${name}.so`)))
|
|
372
|
+
)
|
|
373
|
+
lines.push(`extension=${name}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const ini = path.join(home, "php.ini");
|
|
377
|
+
await writeFile(ini, `${lines.join("\n")}\n`);
|
|
378
|
+
return ini;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function linuxArtifact(kind, arch) {
|
|
382
|
+
const cpu = arch === "arm64" ? "aarch64" : "x86_64";
|
|
383
|
+
if (kind === "php") {
|
|
384
|
+
const file = `php-${LINUX_PHP_VERSION}-fpm-linux-${cpu}.tar.gz`;
|
|
385
|
+
return {
|
|
386
|
+
version: LINUX_PHP_VERSION,
|
|
387
|
+
file,
|
|
388
|
+
url: `https://dl.static-php.dev/static-php-cli/gnu-bulk/${file}`,
|
|
389
|
+
sha256: LINUX_PHP_SHA256[cpu],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const caddyCpu = arch === "arm64" ? "arm64" : "amd64";
|
|
393
|
+
const file = `caddy_${CADDY_VERSION}_linux_${caddyCpu}.tar.gz`;
|
|
394
|
+
return {
|
|
395
|
+
version: CADDY_VERSION,
|
|
396
|
+
file,
|
|
397
|
+
url: `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/${file}`,
|
|
398
|
+
sha512: LINUX_CADDY_SHA512[caddyCpu],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function toolArtifacts(platform = process.platform, arch = process.arch) {
|
|
403
|
+
if (platform === "win32" && arch === "x64")
|
|
404
|
+
return { sapi: "cgi", php: WINDOWS.php, caddy: WINDOWS.caddy };
|
|
405
|
+
if (platform === "linux" && (arch === "x64" || arch === "arm64"))
|
|
406
|
+
return {
|
|
407
|
+
sapi: "fpm",
|
|
408
|
+
php: linuxArtifact("php", arch),
|
|
409
|
+
caddy: linuxArtifact("caddy", arch),
|
|
410
|
+
};
|
|
411
|
+
throw Error(
|
|
412
|
+
"The panel installs PHP automatically on 64-bit Windows and Linux. Set PHP_CGI_BIN and CADDY_BIN on this host.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function assertGlibc(platform) {
|
|
417
|
+
if (platform !== "linux") return;
|
|
418
|
+
if (
|
|
419
|
+
existsSync("/lib/ld-musl-x86_64.so.1") ||
|
|
420
|
+
existsSync("/lib/ld-musl-aarch64.so.1")
|
|
421
|
+
)
|
|
422
|
+
throw Error(
|
|
423
|
+
"The automatic PHP build needs a glibc Linux server such as Debian, Ubuntu, or RHEL. Set PHP_CGI_BIN and CADDY_BIN on Alpine.",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function findFile(directory, names) {
|
|
428
|
+
const wanted = new Set(names);
|
|
429
|
+
const stack = [directory];
|
|
430
|
+
while (stack.length) {
|
|
431
|
+
const current = stack.pop();
|
|
432
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
433
|
+
if (entry.isSymbolicLink()) continue;
|
|
434
|
+
const full = path.join(current, entry.name);
|
|
435
|
+
if (entry.isDirectory()) stack.push(full);
|
|
436
|
+
else if (wanted.has(entry.name)) return full;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function markExecutable(file) {
|
|
443
|
+
if (process.platform === "win32" || !file) return;
|
|
444
|
+
await chmod(file, 0o755);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function configuredTools() {
|
|
448
|
+
const bin = process.env.PHP_CGI_BIN;
|
|
449
|
+
const fpm = /php-fpm(?:\.exe)?$/i.test(path.basename(bin));
|
|
450
|
+
return {
|
|
451
|
+
php: process.env.PHP_BIN || bin,
|
|
452
|
+
phpcgi: fpm ? undefined : bin,
|
|
453
|
+
fpm: fpm ? bin : undefined,
|
|
454
|
+
caddy: process.env.CADDY_BIN,
|
|
455
|
+
ini: process.env.PHP_INI || path.join(path.dirname(bin), "php.ini"),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function ensurePhpTools(
|
|
460
|
+
directory,
|
|
461
|
+
{
|
|
462
|
+
fetchImpl = fetch,
|
|
463
|
+
log = () => {},
|
|
464
|
+
platform = process.platform,
|
|
465
|
+
arch = process.arch,
|
|
466
|
+
} = {},
|
|
467
|
+
) {
|
|
468
|
+
const configured =
|
|
469
|
+
process.env.PHP_CGI_BIN &&
|
|
470
|
+
process.env.CADDY_BIN &&
|
|
471
|
+
path.isAbsolute(process.env.PHP_CGI_BIN) &&
|
|
472
|
+
path.isAbsolute(process.env.CADDY_BIN) &&
|
|
473
|
+
(await exists(process.env.PHP_CGI_BIN)) &&
|
|
474
|
+
(await exists(process.env.CADDY_BIN));
|
|
475
|
+
if (configured) return configuredTools();
|
|
476
|
+
const artifacts = toolArtifacts(platform, arch);
|
|
477
|
+
assertGlibc(platform);
|
|
478
|
+
const key = path.resolve(directory);
|
|
479
|
+
if (!pending.has(key))
|
|
480
|
+
pending.set(
|
|
481
|
+
key,
|
|
482
|
+
(async () => {
|
|
483
|
+
const phpHome = await install(artifacts.php, directory, fetchImpl, log);
|
|
484
|
+
const caddyHome = await install(artifacts.caddy, directory, fetchImpl, log);
|
|
485
|
+
const ini = await writePhpIni(phpHome);
|
|
486
|
+
const fpm =
|
|
487
|
+
artifacts.sapi === "fpm" ? await findFile(phpHome, ["php-fpm"]) : "";
|
|
488
|
+
const cli =
|
|
489
|
+
artifacts.sapi === "fpm" ? await findFile(phpHome, ["php"]) : "";
|
|
490
|
+
const tools =
|
|
491
|
+
artifacts.sapi === "fpm"
|
|
492
|
+
? {
|
|
493
|
+
php: cli || fpm,
|
|
494
|
+
fpm,
|
|
495
|
+
caddy: await findFile(caddyHome, ["caddy"]),
|
|
496
|
+
ini,
|
|
497
|
+
}
|
|
498
|
+
: {
|
|
499
|
+
php: path.join(phpHome, "php.exe"),
|
|
500
|
+
phpcgi: path.join(phpHome, "php-cgi.exe"),
|
|
501
|
+
caddy: path.join(caddyHome, "caddy.exe"),
|
|
502
|
+
ini,
|
|
503
|
+
};
|
|
504
|
+
for (const file of [tools.phpcgi || tools.fpm, tools.caddy]) {
|
|
505
|
+
if (!(await exists(file)))
|
|
506
|
+
throw Error(
|
|
507
|
+
`PHP setup did not produce ${path.basename(file || "binary")}`,
|
|
508
|
+
);
|
|
509
|
+
await markExecutable(file);
|
|
510
|
+
}
|
|
511
|
+
if (cli) await markExecutable(cli);
|
|
512
|
+
return tools;
|
|
513
|
+
})().finally(() => {
|
|
514
|
+
pending.delete(key);
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
return pending.get(key);
|
|
518
|
+
}
|