pi-for-excel-python-bridge 0.1.0-pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/cli.mjs +233 -0
- package/package.json +22 -0
- package/scripts/python-bridge-server.mjs +994 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Mustier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# pi-for-excel-python-bridge
|
|
2
|
+
|
|
3
|
+
Local HTTPS Python / LibreOffice bridge helper for Pi for Excel.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx pi-for-excel-python-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This command:
|
|
12
|
+
|
|
13
|
+
1. Ensures `mkcert` exists (installs via Homebrew on macOS if missing)
|
|
14
|
+
2. Creates certificates in `~/.pi-for-excel/certs/` when needed
|
|
15
|
+
3. Starts the bridge at `https://localhost:3340`
|
|
16
|
+
|
|
17
|
+
Default mode is `stub` (safe simulated responses).
|
|
18
|
+
|
|
19
|
+
For real local execution mode:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
PYTHON_BRIDGE_MODE=real npx pi-for-excel-python-bridge
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then in Pi for Excel:
|
|
26
|
+
|
|
27
|
+
1. Run `/experimental python-bridge-url https://localhost:3340`
|
|
28
|
+
2. (Optional) run `/experimental python-bridge-token <token>` if you set `PYTHON_BRIDGE_TOKEN`
|
|
29
|
+
|
|
30
|
+
## Publishing (maintainers)
|
|
31
|
+
|
|
32
|
+
Package source lives in `pkg/python-bridge/`.
|
|
33
|
+
|
|
34
|
+
Before packing/publishing, `prepack` copies runtime files from repo root:
|
|
35
|
+
|
|
36
|
+
- `scripts/python-bridge-server.mjs`
|
|
37
|
+
|
|
38
|
+
Publish from this directory:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd pkg/python-bridge
|
|
42
|
+
npm publish
|
|
43
|
+
```
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const PACKAGE_TAG = "pi-for-excel-python-bridge";
|
|
11
|
+
const DEFAULT_PORT = "3340";
|
|
12
|
+
|
|
13
|
+
const cliDir = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const bridgeScriptPath = path.join(cliDir, "scripts", "python-bridge-server.mjs");
|
|
15
|
+
|
|
16
|
+
const homeDir = os.homedir();
|
|
17
|
+
const appDir = path.join(homeDir, ".pi-for-excel");
|
|
18
|
+
const certDir = path.join(appDir, "certs");
|
|
19
|
+
const keyPath = path.join(certDir, "key.pem");
|
|
20
|
+
const certPath = path.join(certDir, "cert.pem");
|
|
21
|
+
|
|
22
|
+
function commandExists(command) {
|
|
23
|
+
const whichCommand = process.platform === "win32" ? "where" : "which";
|
|
24
|
+
const result = spawnSync(whichCommand, [command], { stdio: "ignore" });
|
|
25
|
+
return result.status === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function run(command, args, options = {}) {
|
|
29
|
+
const result = spawnSync(command, args, {
|
|
30
|
+
stdio: "inherit",
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (result.error) {
|
|
35
|
+
console.error(`[${PACKAGE_TAG}] Failed to run: ${command}`);
|
|
36
|
+
console.error(result.error.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
41
|
+
process.exit(result.status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (result.signal) {
|
|
45
|
+
console.error(`[${PACKAGE_TAG}] ${command} terminated by signal ${result.signal}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function supportsMkcertCli(command) {
|
|
51
|
+
const result = spawnSync(command, ["-CAROOT"], {
|
|
52
|
+
stdio: "ignore",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (result.error) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result.status === 0 && !result.signal;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveMkcertCommand() {
|
|
63
|
+
const candidates = [];
|
|
64
|
+
|
|
65
|
+
if (process.platform === "darwin") {
|
|
66
|
+
const brewCandidates = ["/opt/homebrew/bin/mkcert", "/usr/local/bin/mkcert"];
|
|
67
|
+
for (const candidate of brewCandidates) {
|
|
68
|
+
if (fs.existsSync(candidate)) {
|
|
69
|
+
candidates.push(candidate);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (commandExists("mkcert")) {
|
|
75
|
+
candidates.push("mkcert");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
if (supportsMkcertCli(candidate)) {
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (process.platform === "darwin") {
|
|
85
|
+
if (!commandExists("brew")) {
|
|
86
|
+
console.error(`[${PACKAGE_TAG}] Homebrew is not installed.`);
|
|
87
|
+
console.error(`[${PACKAGE_TAG}] Install Homebrew first: https://brew.sh`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`[${PACKAGE_TAG}] Installing mkcert via Homebrew...`);
|
|
92
|
+
run("brew", ["install", "mkcert"]);
|
|
93
|
+
|
|
94
|
+
const brewCandidates = ["/opt/homebrew/bin/mkcert", "/usr/local/bin/mkcert", "mkcert"];
|
|
95
|
+
for (const candidate of brewCandidates) {
|
|
96
|
+
if (candidate !== "mkcert" && !fs.existsSync(candidate)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (supportsMkcertCli(candidate)) {
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.error(`[${PACKAGE_TAG}] mkcert is installed but not compatible with required CLI flags.`);
|
|
106
|
+
console.error(`[${PACKAGE_TAG}] Ensure FiloSottile mkcert is used (not the npm mkcert package).`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.error(`[${PACKAGE_TAG}] Please install mkcert, then run this command again.`);
|
|
111
|
+
console.error(`[${PACKAGE_TAG}] Install instructions: https://github.com/FiloSottile/mkcert#installation`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function installMkcertCa(mkcertCommand) {
|
|
116
|
+
const result = spawnSync(mkcertCommand, ["-install"], {
|
|
117
|
+
stdio: "inherit",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!result.error && result.status === 0 && !result.signal) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.error(`[${PACKAGE_TAG}] Failed to install mkcert local CA.`);
|
|
125
|
+
console.error(`[${PACKAGE_TAG}] Run manually: mkcert -install`);
|
|
126
|
+
console.error(`[${PACKAGE_TAG}] If it fails, fix trust-store permissions and retry.`);
|
|
127
|
+
|
|
128
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
129
|
+
process.exit(result.status);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ensureCertificates() {
|
|
136
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const mkcertCommand = resolveMkcertCommand();
|
|
143
|
+
|
|
144
|
+
console.log(`[${PACKAGE_TAG}] Generating local HTTPS certificates...`);
|
|
145
|
+
installMkcertCa(mkcertCommand);
|
|
146
|
+
|
|
147
|
+
run(mkcertCommand, ["-key-file", keyPath, "-cert-file", certPath, "localhost"], {
|
|
148
|
+
cwd: certDir,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
|
152
|
+
console.error(`[${PACKAGE_TAG}] Failed to generate TLS certificates.`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveBridgeConfig() {
|
|
158
|
+
const userArgs = process.argv.slice(2);
|
|
159
|
+
const hasExplicitScheme = userArgs.includes("--https") || userArgs.includes("--http");
|
|
160
|
+
const bridgeArgs = hasExplicitScheme ? userArgs : ["--https", ...userArgs];
|
|
161
|
+
|
|
162
|
+
const usesHttpOnly = bridgeArgs.includes("--http") && !bridgeArgs.includes("--https");
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
bridgeArgs,
|
|
166
|
+
usesHttps: !usesHttpOnly,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function applyDefaultPort(env) {
|
|
171
|
+
const configuredPort = typeof env.PORT === "string" ? env.PORT.trim() : "";
|
|
172
|
+
if (configuredPort.length === 0) {
|
|
173
|
+
env.PORT = DEFAULT_PORT;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function startBridge(bridgeArgs) {
|
|
178
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
179
|
+
console.log(`[${PACKAGE_TAG}] Using certificate directory: ${certDir}`);
|
|
180
|
+
|
|
181
|
+
const childEnv = { ...process.env };
|
|
182
|
+
applyDefaultPort(childEnv);
|
|
183
|
+
|
|
184
|
+
if (typeof childEnv.PI_FOR_EXCEL_CERT_DIR !== "string" || childEnv.PI_FOR_EXCEL_CERT_DIR.trim().length === 0) {
|
|
185
|
+
childEnv.PI_FOR_EXCEL_CERT_DIR = certDir;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const child = spawn(process.execPath, [bridgeScriptPath, ...bridgeArgs], {
|
|
189
|
+
env: childEnv,
|
|
190
|
+
stdio: "inherit",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let shuttingDown = false;
|
|
194
|
+
|
|
195
|
+
const forwardSignal = (signal) => {
|
|
196
|
+
if (shuttingDown) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
shuttingDown = true;
|
|
200
|
+
if (!child.killed) {
|
|
201
|
+
child.kill(signal);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
206
|
+
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
207
|
+
|
|
208
|
+
child.on("exit", (code, signal) => {
|
|
209
|
+
if (signal) {
|
|
210
|
+
process.kill(process.pid, signal);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
process.exit(code ?? 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
child.on("error", (error) => {
|
|
217
|
+
console.error(`[${PACKAGE_TAG}] Failed to start bridge process.`);
|
|
218
|
+
console.error(error.message);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!fs.existsSync(bridgeScriptPath)) {
|
|
224
|
+
console.error(`[${PACKAGE_TAG}] Missing bridge runtime files.`);
|
|
225
|
+
console.error(`[${PACKAGE_TAG}] Reinstall the package or run npm pack again.`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const bridgeConfig = resolveBridgeConfig();
|
|
230
|
+
if (bridgeConfig.usesHttps) {
|
|
231
|
+
ensureCertificates();
|
|
232
|
+
}
|
|
233
|
+
startBridge(bridgeConfig.bridgeArgs);
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-for-excel-python-bridge",
|
|
3
|
+
"version": "0.1.0-pre",
|
|
4
|
+
"description": "One-command local HTTPS Python / LibreOffice bridge helper for Pi for Excel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pi-for-excel-python-bridge": "./cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.mjs",
|
|
11
|
+
"scripts/*.mjs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"prepack": "node ../../scripts/sync-python-bridge-package.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local Python / LibreOffice bridge for Pi for Excel.
|
|
5
|
+
*
|
|
6
|
+
* Modes:
|
|
7
|
+
* - stub (default): deterministic simulated responses for local development.
|
|
8
|
+
* - real: executes local python + libreoffice commands with guardrails.
|
|
9
|
+
*
|
|
10
|
+
* Endpoints:
|
|
11
|
+
* - GET /health
|
|
12
|
+
* - POST /v1/python-run
|
|
13
|
+
* - POST /v1/libreoffice-convert
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import https from "node:https";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
+
import { timingSafeEqual } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
const args = new Set(process.argv.slice(2));
|
|
25
|
+
const useHttps = args.has("--https") || process.env.HTTPS === "1" || process.env.HTTPS === "true";
|
|
26
|
+
const useHttp = args.has("--http");
|
|
27
|
+
|
|
28
|
+
if (useHttps && useHttp) {
|
|
29
|
+
console.error("[pi-for-excel] Invalid args: can't use both --https and --http");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HOST = process.env.HOST || (useHttps ? "localhost" : "127.0.0.1");
|
|
34
|
+
const PORT = Number.parseInt(process.env.PORT || "3340", 10);
|
|
35
|
+
|
|
36
|
+
const MODE_RAW = (process.env.PYTHON_BRIDGE_MODE || "stub").trim().toLowerCase();
|
|
37
|
+
const MODE = MODE_RAW === "real" ? "real" : MODE_RAW === "stub" ? "stub" : null;
|
|
38
|
+
if (!MODE) {
|
|
39
|
+
console.error(`[pi-for-excel] Invalid PYTHON_BRIDGE_MODE: ${MODE_RAW}. Use "stub" or "real".`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PYTHON_BIN = (process.env.PYTHON_BRIDGE_PYTHON_BIN || "python3").trim();
|
|
44
|
+
const LIBREOFFICE_BIN_RAW = (process.env.PYTHON_BRIDGE_LIBREOFFICE_BIN || "").trim();
|
|
45
|
+
const LIBREOFFICE_CANDIDATES = LIBREOFFICE_BIN_RAW.length > 0
|
|
46
|
+
? [LIBREOFFICE_BIN_RAW]
|
|
47
|
+
: ["soffice", "libreoffice"];
|
|
48
|
+
|
|
49
|
+
function resolveOptionalEnvPath(name) {
|
|
50
|
+
const raw = process.env[name];
|
|
51
|
+
if (typeof raw !== "string") return null;
|
|
52
|
+
|
|
53
|
+
const trimmed = raw.trim();
|
|
54
|
+
if (trimmed.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
return path.resolve(trimmed);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const certDir = resolveOptionalEnvPath("PI_FOR_EXCEL_CERT_DIR") ?? path.resolve(process.cwd());
|
|
60
|
+
const keyPath = resolveOptionalEnvPath("PI_FOR_EXCEL_KEY_PATH") ?? path.join(certDir, "key.pem");
|
|
61
|
+
const certPath = resolveOptionalEnvPath("PI_FOR_EXCEL_CERT_PATH") ?? path.join(certDir, "cert.pem");
|
|
62
|
+
|
|
63
|
+
const DEFAULT_ALLOWED_ORIGINS = new Set([
|
|
64
|
+
"https://localhost:3000",
|
|
65
|
+
"https://pi-for-excel.vercel.app",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const MAX_JSON_BODY_BYTES = 512 * 1024;
|
|
69
|
+
const MAX_CODE_LENGTH = 40_000;
|
|
70
|
+
const MAX_INPUT_JSON_LENGTH = 200_000;
|
|
71
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
72
|
+
|
|
73
|
+
const PYTHON_DEFAULT_TIMEOUT_MS = 10_000;
|
|
74
|
+
const PYTHON_MIN_TIMEOUT_MS = 100;
|
|
75
|
+
const PYTHON_MAX_TIMEOUT_MS = 120_000;
|
|
76
|
+
|
|
77
|
+
const LIBREOFFICE_DEFAULT_TIMEOUT_MS = 60_000;
|
|
78
|
+
const LIBREOFFICE_MIN_TIMEOUT_MS = 1_000;
|
|
79
|
+
const LIBREOFFICE_MAX_TIMEOUT_MS = 300_000;
|
|
80
|
+
|
|
81
|
+
const LIBREOFFICE_TARGET_FORMATS = new Set(["csv", "pdf", "xlsx"]);
|
|
82
|
+
const RESULT_JSON_MARKER = "__PI_FOR_EXCEL_RESULT_JSON_V1__";
|
|
83
|
+
|
|
84
|
+
const allowedOrigins = (() => {
|
|
85
|
+
const raw = process.env.ALLOWED_ORIGINS;
|
|
86
|
+
if (!raw) return DEFAULT_ALLOWED_ORIGINS;
|
|
87
|
+
|
|
88
|
+
const custom = new Set(
|
|
89
|
+
raw
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((value) => value.trim())
|
|
92
|
+
.filter(Boolean),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return custom.size > 0 ? custom : DEFAULT_ALLOWED_ORIGINS;
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
const authToken = (() => {
|
|
99
|
+
const raw = process.env.PYTHON_BRIDGE_TOKEN;
|
|
100
|
+
if (typeof raw !== "string") return "";
|
|
101
|
+
return raw.trim();
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
const PYTHON_WRAPPER_CODE = [
|
|
105
|
+
"import json",
|
|
106
|
+
"import os",
|
|
107
|
+
"import sys",
|
|
108
|
+
"import traceback",
|
|
109
|
+
"",
|
|
110
|
+
"raw_input = os.environ.get('PI_INPUT_JSON', '')",
|
|
111
|
+
"input_data = None",
|
|
112
|
+
"if raw_input:",
|
|
113
|
+
" input_data = json.loads(raw_input)",
|
|
114
|
+
"",
|
|
115
|
+
"user_code = os.environ.get('PI_USER_CODE', '')",
|
|
116
|
+
"scope = {'__name__': '__main__', 'input_data': input_data}",
|
|
117
|
+
"",
|
|
118
|
+
"try:",
|
|
119
|
+
" exec(user_code, scope, scope)",
|
|
120
|
+
"except Exception:",
|
|
121
|
+
" traceback.print_exc()",
|
|
122
|
+
" raise",
|
|
123
|
+
"",
|
|
124
|
+
"if 'result' in scope:",
|
|
125
|
+
" try:",
|
|
126
|
+
` print('${RESULT_JSON_MARKER}')`,
|
|
127
|
+
" print(json.dumps(scope['result'], ensure_ascii=False))",
|
|
128
|
+
" except Exception as exc:",
|
|
129
|
+
" print(f'[pi-python-bridge] result serialization error: {exc}', file=sys.stderr)",
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
class HttpError extends Error {
|
|
133
|
+
/**
|
|
134
|
+
* @param {number} status
|
|
135
|
+
* @param {string} message
|
|
136
|
+
*/
|
|
137
|
+
constructor(status, message) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "HttpError";
|
|
140
|
+
this.status = status;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
* @returns {value is Record<string, unknown>}
|
|
147
|
+
*/
|
|
148
|
+
function isRecord(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string | undefined} origin
|
|
154
|
+
*/
|
|
155
|
+
function isAllowedOrigin(origin) {
|
|
156
|
+
return typeof origin === "string" && allowedOrigins.has(origin);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string | undefined} addr
|
|
161
|
+
*/
|
|
162
|
+
function isLoopbackAddress(addr) {
|
|
163
|
+
if (!addr) return false;
|
|
164
|
+
if (addr === "::1" || addr === "0:0:0:0:0:0:0:1") return true;
|
|
165
|
+
if (addr.startsWith("127.")) return true;
|
|
166
|
+
if (addr.startsWith("::ffff:127.")) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {http.IncomingMessage} req
|
|
172
|
+
* @param {http.ServerResponse} res
|
|
173
|
+
*/
|
|
174
|
+
function setCorsHeaders(req, res) {
|
|
175
|
+
const origin = req.headers.origin;
|
|
176
|
+
if (isAllowedOrigin(origin)) {
|
|
177
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
178
|
+
res.setHeader("Vary", "Origin");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
182
|
+
res.setHeader(
|
|
183
|
+
"Access-Control-Allow-Headers",
|
|
184
|
+
req.headers["access-control-request-headers"] || "content-type,authorization",
|
|
185
|
+
);
|
|
186
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {http.ServerResponse} res
|
|
191
|
+
* @param {number} status
|
|
192
|
+
* @param {unknown} payload
|
|
193
|
+
*/
|
|
194
|
+
function respondJson(res, status, payload) {
|
|
195
|
+
res.statusCode = status;
|
|
196
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
197
|
+
res.end(JSON.stringify(payload));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {http.ServerResponse} res
|
|
202
|
+
* @param {number} status
|
|
203
|
+
* @param {string} text
|
|
204
|
+
*/
|
|
205
|
+
function respondText(res, status, text) {
|
|
206
|
+
res.statusCode = status;
|
|
207
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
208
|
+
res.end(text);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string | undefined} headerValue
|
|
213
|
+
*/
|
|
214
|
+
function extractBearerToken(headerValue) {
|
|
215
|
+
if (typeof headerValue !== "string") return null;
|
|
216
|
+
const prefix = "Bearer ";
|
|
217
|
+
if (!headerValue.startsWith(prefix)) return null;
|
|
218
|
+
const token = headerValue.slice(prefix.length).trim();
|
|
219
|
+
return token.length > 0 ? token : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} left
|
|
224
|
+
* @param {string} right
|
|
225
|
+
*/
|
|
226
|
+
function secureEquals(left, right) {
|
|
227
|
+
const leftBuffer = Buffer.from(left);
|
|
228
|
+
const rightBuffer = Buffer.from(right);
|
|
229
|
+
if (leftBuffer.length !== rightBuffer.length) return false;
|
|
230
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {http.IncomingMessage} req
|
|
235
|
+
*/
|
|
236
|
+
function isAuthorized(req) {
|
|
237
|
+
if (!authToken) return true;
|
|
238
|
+
|
|
239
|
+
const candidate = extractBearerToken(req.headers.authorization);
|
|
240
|
+
if (!candidate) return false;
|
|
241
|
+
|
|
242
|
+
return secureEquals(candidate, authToken);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {http.IncomingMessage} req
|
|
247
|
+
*/
|
|
248
|
+
async function readJsonBody(req) {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let size = 0;
|
|
251
|
+
|
|
252
|
+
for await (const chunk of req) {
|
|
253
|
+
const part = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
254
|
+
size += part.length;
|
|
255
|
+
|
|
256
|
+
if (size > MAX_JSON_BODY_BYTES) {
|
|
257
|
+
throw new HttpError(413, `Request body too large (max ${MAX_JSON_BODY_BYTES} bytes).`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
chunks.push(part);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
264
|
+
if (text.length === 0) {
|
|
265
|
+
throw new HttpError(400, "Missing JSON request body.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(text);
|
|
270
|
+
} catch {
|
|
271
|
+
throw new HttpError(400, "Invalid JSON body.");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {unknown} value
|
|
277
|
+
*/
|
|
278
|
+
function normalizeOptionalString(value) {
|
|
279
|
+
if (typeof value !== "string") return undefined;
|
|
280
|
+
|
|
281
|
+
const trimmed = value.trim();
|
|
282
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {unknown} value
|
|
287
|
+
* @param {{ name: string; min: number; max: number; defaultValue: number }} options
|
|
288
|
+
*/
|
|
289
|
+
function parseBoundedInteger(value, options) {
|
|
290
|
+
if (value === undefined) return options.defaultValue;
|
|
291
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
292
|
+
throw new HttpError(400, `${options.name} must be an integer.`);
|
|
293
|
+
}
|
|
294
|
+
if (value < options.min || value > options.max) {
|
|
295
|
+
throw new HttpError(400, `${options.name} must be between ${options.min} and ${options.max}.`);
|
|
296
|
+
}
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {string} value
|
|
302
|
+
*/
|
|
303
|
+
function isAbsolutePath(value) {
|
|
304
|
+
if (value.startsWith("/")) return true;
|
|
305
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) return true;
|
|
306
|
+
if (value.startsWith("\\\\")) return true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {unknown} value
|
|
312
|
+
* @param {string} field
|
|
313
|
+
*/
|
|
314
|
+
function normalizeAbsolutePath(value, field) {
|
|
315
|
+
const pathValue = normalizeOptionalString(value);
|
|
316
|
+
if (!pathValue) {
|
|
317
|
+
throw new HttpError(400, `${field} is required.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!isAbsolutePath(pathValue)) {
|
|
321
|
+
throw new HttpError(400, `${field} must be an absolute path.`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return pathValue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string | undefined} output
|
|
329
|
+
*/
|
|
330
|
+
function normalizeOutput(output) {
|
|
331
|
+
if (typeof output !== "string") return undefined;
|
|
332
|
+
const normalized = output.replace(/\r/g, "").trim();
|
|
333
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {unknown} payload
|
|
338
|
+
*/
|
|
339
|
+
function parsePythonRunRequest(payload) {
|
|
340
|
+
if (!isRecord(payload)) {
|
|
341
|
+
throw new HttpError(400, "Request body must be a JSON object.");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const code = typeof payload.code === "string" ? payload.code : "";
|
|
345
|
+
if (code.trim().length === 0) {
|
|
346
|
+
throw new HttpError(400, "code is required.");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (code.length > MAX_CODE_LENGTH) {
|
|
350
|
+
throw new HttpError(400, `code is too long (max ${MAX_CODE_LENGTH} characters).`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const inputJson = normalizeOptionalString(payload.input_json);
|
|
354
|
+
if (inputJson && inputJson.length > MAX_INPUT_JSON_LENGTH) {
|
|
355
|
+
throw new HttpError(400, `input_json is too long (max ${MAX_INPUT_JSON_LENGTH} characters).`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (inputJson) {
|
|
359
|
+
try {
|
|
360
|
+
void JSON.parse(inputJson);
|
|
361
|
+
} catch {
|
|
362
|
+
throw new HttpError(400, "input_json must be valid JSON.");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const timeoutMs = parseBoundedInteger(payload.timeout_ms, {
|
|
367
|
+
name: "timeout_ms",
|
|
368
|
+
min: PYTHON_MIN_TIMEOUT_MS,
|
|
369
|
+
max: PYTHON_MAX_TIMEOUT_MS,
|
|
370
|
+
defaultValue: PYTHON_DEFAULT_TIMEOUT_MS,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
code,
|
|
375
|
+
input_json: inputJson,
|
|
376
|
+
timeout_ms: timeoutMs,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* @param {unknown} payload
|
|
382
|
+
*/
|
|
383
|
+
function parseLibreOfficeRequest(payload) {
|
|
384
|
+
if (!isRecord(payload)) {
|
|
385
|
+
throw new HttpError(400, "Request body must be a JSON object.");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const inputPath = normalizeAbsolutePath(payload.input_path, "input_path");
|
|
389
|
+
|
|
390
|
+
const targetFormat = normalizeOptionalString(payload.target_format)?.toLowerCase();
|
|
391
|
+
if (!targetFormat || !LIBREOFFICE_TARGET_FORMATS.has(targetFormat)) {
|
|
392
|
+
throw new HttpError(400, "target_format must be one of: csv, pdf, xlsx.");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let outputPath;
|
|
396
|
+
if (payload.output_path !== undefined) {
|
|
397
|
+
outputPath = normalizeAbsolutePath(payload.output_path, "output_path");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const overwrite = typeof payload.overwrite === "boolean" ? payload.overwrite : false;
|
|
401
|
+
|
|
402
|
+
const timeoutMs = parseBoundedInteger(payload.timeout_ms, {
|
|
403
|
+
name: "timeout_ms",
|
|
404
|
+
min: LIBREOFFICE_MIN_TIMEOUT_MS,
|
|
405
|
+
max: LIBREOFFICE_MAX_TIMEOUT_MS,
|
|
406
|
+
defaultValue: LIBREOFFICE_DEFAULT_TIMEOUT_MS,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
input_path: inputPath,
|
|
411
|
+
target_format: targetFormat,
|
|
412
|
+
output_path: outputPath,
|
|
413
|
+
overwrite,
|
|
414
|
+
timeout_ms: timeoutMs,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @param {string} command
|
|
420
|
+
* @param {string[]} args
|
|
421
|
+
*/
|
|
422
|
+
function probeBinary(command, args) {
|
|
423
|
+
const probe = spawnSync(command, args, {
|
|
424
|
+
encoding: "utf8",
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (probe.error) {
|
|
428
|
+
console.error(`[pi-for-excel] Failed to probe binary "${command}":`, probe.error);
|
|
429
|
+
return {
|
|
430
|
+
available: false,
|
|
431
|
+
error: "probe_failed",
|
|
432
|
+
command,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (probe.status !== 0) {
|
|
437
|
+
return {
|
|
438
|
+
available: false,
|
|
439
|
+
error: `probe_exit_${String(probe.status)}`,
|
|
440
|
+
command,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const stdout = typeof probe.stdout === "string" ? probe.stdout.trim() : "";
|
|
445
|
+
return {
|
|
446
|
+
available: true,
|
|
447
|
+
version: stdout || command,
|
|
448
|
+
command,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function probeLibreOfficeBinary() {
|
|
453
|
+
for (const candidate of LIBREOFFICE_CANDIDATES) {
|
|
454
|
+
const probe = probeBinary(candidate, ["--version"]);
|
|
455
|
+
if (probe.available) {
|
|
456
|
+
return {
|
|
457
|
+
available: true,
|
|
458
|
+
command: candidate,
|
|
459
|
+
version: probe.version,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
available: false,
|
|
466
|
+
command: LIBREOFFICE_CANDIDATES[0] || "soffice",
|
|
467
|
+
error: `No LibreOffice binary found (tried: ${LIBREOFFICE_CANDIDATES.join(", ")})`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {{ command: string; args: string[]; timeoutMs: number; env?: NodeJS.ProcessEnv }} options
|
|
473
|
+
*/
|
|
474
|
+
async function runCommandCapture(options) {
|
|
475
|
+
let timedOut = false;
|
|
476
|
+
let overflow = false;
|
|
477
|
+
|
|
478
|
+
const result = await new Promise((resolve, reject) => {
|
|
479
|
+
const child = spawn(options.command, options.args, {
|
|
480
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
481
|
+
env: options.env,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
let stdout = "";
|
|
485
|
+
let stderr = "";
|
|
486
|
+
|
|
487
|
+
child.stdout.setEncoding("utf8");
|
|
488
|
+
child.stderr.setEncoding("utf8");
|
|
489
|
+
|
|
490
|
+
child.stdout.on("data", (chunk) => {
|
|
491
|
+
if (overflow) return;
|
|
492
|
+
stdout += chunk;
|
|
493
|
+
if (Buffer.byteLength(stdout, "utf8") > MAX_OUTPUT_BYTES) {
|
|
494
|
+
overflow = true;
|
|
495
|
+
child.kill("SIGKILL");
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
child.stderr.on("data", (chunk) => {
|
|
500
|
+
if (overflow) return;
|
|
501
|
+
stderr += chunk;
|
|
502
|
+
if (Buffer.byteLength(stderr, "utf8") > MAX_OUTPUT_BYTES) {
|
|
503
|
+
overflow = true;
|
|
504
|
+
child.kill("SIGKILL");
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const timeoutId = setTimeout(() => {
|
|
509
|
+
timedOut = true;
|
|
510
|
+
child.kill("SIGKILL");
|
|
511
|
+
}, options.timeoutMs);
|
|
512
|
+
|
|
513
|
+
child.once("error", (error) => {
|
|
514
|
+
clearTimeout(timeoutId);
|
|
515
|
+
reject(error);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
child.once("close", (code, signal) => {
|
|
519
|
+
clearTimeout(timeoutId);
|
|
520
|
+
resolve({
|
|
521
|
+
code: code ?? -1,
|
|
522
|
+
signal: signal ?? null,
|
|
523
|
+
stdout,
|
|
524
|
+
stderr,
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
if (overflow) {
|
|
530
|
+
throw new HttpError(413, `Process output exceeded ${MAX_OUTPUT_BYTES} bytes.`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (timedOut) {
|
|
534
|
+
throw new HttpError(504, `Process timed out after ${options.timeoutMs}ms.`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (result.signal) {
|
|
538
|
+
throw new HttpError(500, `Process exited with signal ${result.signal}.`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* @param {string} stdout
|
|
546
|
+
*/
|
|
547
|
+
function splitPythonResult(stdout) {
|
|
548
|
+
const normalized = stdout.replace(/\r/g, "");
|
|
549
|
+
const marker = `\n${RESULT_JSON_MARKER}\n`;
|
|
550
|
+
|
|
551
|
+
let markerIndex = normalized.lastIndexOf(marker);
|
|
552
|
+
let markerLength = marker.length;
|
|
553
|
+
|
|
554
|
+
if (markerIndex === -1 && normalized.startsWith(`${RESULT_JSON_MARKER}\n`)) {
|
|
555
|
+
markerIndex = 0;
|
|
556
|
+
markerLength = RESULT_JSON_MARKER.length + 1;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (markerIndex === -1) {
|
|
560
|
+
return {
|
|
561
|
+
stdout: normalizeOutput(normalized),
|
|
562
|
+
resultJson: undefined,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const before = normalized.slice(0, markerIndex);
|
|
567
|
+
const after = normalized.slice(markerIndex + markerLength).trim();
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
stdout: normalizeOutput(before),
|
|
571
|
+
resultJson: after.length > 0 ? after : undefined,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @param {{ code: string; input_json?: string; timeout_ms: number }} request
|
|
577
|
+
* @param {{ command: string }} pythonInfo
|
|
578
|
+
*/
|
|
579
|
+
async function runPython(request, pythonInfo) {
|
|
580
|
+
const env = {
|
|
581
|
+
...process.env,
|
|
582
|
+
PI_USER_CODE: request.code,
|
|
583
|
+
PI_INPUT_JSON: request.input_json || "",
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const result = await runCommandCapture({
|
|
587
|
+
command: pythonInfo.command,
|
|
588
|
+
args: ["-I", "-c", PYTHON_WRAPPER_CODE],
|
|
589
|
+
timeoutMs: request.timeout_ms,
|
|
590
|
+
env,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
if (result.code !== 0) {
|
|
594
|
+
const message = [result.stderr, result.stdout]
|
|
595
|
+
.map((value) => value.trim())
|
|
596
|
+
.find((value) => value.length > 0) || `python exited with code ${result.code}`;
|
|
597
|
+
|
|
598
|
+
throw new HttpError(400, message);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const parsed = splitPythonResult(result.stdout);
|
|
602
|
+
|
|
603
|
+
if (parsed.resultJson) {
|
|
604
|
+
try {
|
|
605
|
+
void JSON.parse(parsed.resultJson);
|
|
606
|
+
} catch {
|
|
607
|
+
throw new HttpError(500, "Bridge produced invalid result_json payload.");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
action: "run_python",
|
|
614
|
+
exit_code: result.code,
|
|
615
|
+
stdout: parsed.stdout,
|
|
616
|
+
stderr: normalizeOutput(result.stderr),
|
|
617
|
+
result_json: parsed.resultJson,
|
|
618
|
+
truncated: false,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
|
|
624
|
+
*/
|
|
625
|
+
function resolveOutputPath(request) {
|
|
626
|
+
if (request.output_path) {
|
|
627
|
+
return request.output_path;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const parsed = path.parse(request.input_path);
|
|
631
|
+
return path.join(parsed.dir, `${parsed.name}.${request.target_format}`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* @param {string} filePath
|
|
636
|
+
*/
|
|
637
|
+
function ensureFileExists(filePath) {
|
|
638
|
+
let stats;
|
|
639
|
+
try {
|
|
640
|
+
stats = fs.statSync(filePath);
|
|
641
|
+
} catch {
|
|
642
|
+
throw new HttpError(400, `input_path does not exist: ${filePath}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!stats.isFile()) {
|
|
646
|
+
throw new HttpError(400, `input_path is not a file: ${filePath}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* @param {string} outputPath
|
|
652
|
+
* @param {boolean} overwrite
|
|
653
|
+
*/
|
|
654
|
+
function ensureOutputWritable(outputPath, overwrite) {
|
|
655
|
+
if (fs.existsSync(outputPath) && !overwrite) {
|
|
656
|
+
throw new HttpError(409, `output_path already exists: ${outputPath}`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const outputDir = path.dirname(outputPath);
|
|
660
|
+
if (!fs.existsSync(outputDir)) {
|
|
661
|
+
throw new HttpError(400, `output_path directory does not exist: ${outputDir}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* @param {string} tempDir
|
|
667
|
+
* @param {string} inputPath
|
|
668
|
+
* @param {string} targetFormat
|
|
669
|
+
*/
|
|
670
|
+
function findConvertedFile(tempDir, inputPath, targetFormat) {
|
|
671
|
+
const baseName = path.parse(inputPath).name;
|
|
672
|
+
const expected = path.join(tempDir, `${baseName}.${targetFormat}`);
|
|
673
|
+
if (fs.existsSync(expected)) {
|
|
674
|
+
return expected;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const entries = fs.readdirSync(tempDir, { withFileTypes: true });
|
|
678
|
+
for (const entry of entries) {
|
|
679
|
+
if (!entry.isFile()) continue;
|
|
680
|
+
if (!entry.name.toLowerCase().endsWith(`.${targetFormat}`)) continue;
|
|
681
|
+
return path.join(tempDir, entry.name);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
|
|
689
|
+
* @param {{ command: string }} libreOfficeInfo
|
|
690
|
+
*/
|
|
691
|
+
async function runLibreOfficeConvert(request, libreOfficeInfo) {
|
|
692
|
+
ensureFileExists(request.input_path);
|
|
693
|
+
|
|
694
|
+
const outputPath = resolveOutputPath(request);
|
|
695
|
+
ensureOutputWritable(outputPath, request.overwrite);
|
|
696
|
+
|
|
697
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-libreoffice-"));
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
const result = await runCommandCapture({
|
|
701
|
+
command: libreOfficeInfo.command,
|
|
702
|
+
args: [
|
|
703
|
+
"--headless",
|
|
704
|
+
"--convert-to",
|
|
705
|
+
request.target_format,
|
|
706
|
+
"--outdir",
|
|
707
|
+
tempDir,
|
|
708
|
+
request.input_path,
|
|
709
|
+
],
|
|
710
|
+
timeoutMs: request.timeout_ms,
|
|
711
|
+
env: process.env,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
if (result.code !== 0) {
|
|
715
|
+
const message = [result.stderr, result.stdout]
|
|
716
|
+
.map((value) => value.trim())
|
|
717
|
+
.find((value) => value.length > 0) || `LibreOffice exited with code ${result.code}`;
|
|
718
|
+
throw new HttpError(400, message);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const convertedPath = findConvertedFile(tempDir, request.input_path, request.target_format);
|
|
722
|
+
if (!convertedPath) {
|
|
723
|
+
throw new HttpError(500, "LibreOffice did not produce an output file.");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
fs.copyFileSync(convertedPath, outputPath);
|
|
727
|
+
|
|
728
|
+
const stats = fs.statSync(outputPath);
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
ok: true,
|
|
732
|
+
action: "convert",
|
|
733
|
+
input_path: request.input_path,
|
|
734
|
+
target_format: request.target_format,
|
|
735
|
+
output_path: outputPath,
|
|
736
|
+
bytes: stats.size,
|
|
737
|
+
converter: libreOfficeInfo.command,
|
|
738
|
+
};
|
|
739
|
+
} finally {
|
|
740
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function createStubBackend() {
|
|
745
|
+
return {
|
|
746
|
+
mode: "stub",
|
|
747
|
+
async health() {
|
|
748
|
+
return {
|
|
749
|
+
backend: "stub",
|
|
750
|
+
python: {
|
|
751
|
+
available: true,
|
|
752
|
+
mode: "stub",
|
|
753
|
+
},
|
|
754
|
+
libreoffice: {
|
|
755
|
+
available: true,
|
|
756
|
+
mode: "stub",
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
},
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* @param {{ code: string; input_json?: string; timeout_ms: number }} request
|
|
763
|
+
*/
|
|
764
|
+
async handlePython(request) {
|
|
765
|
+
let resultJson;
|
|
766
|
+
if (request.input_json) {
|
|
767
|
+
resultJson = request.input_json;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
ok: true,
|
|
772
|
+
action: "run_python",
|
|
773
|
+
exit_code: 0,
|
|
774
|
+
stdout: "[stub] Python execution simulated.",
|
|
775
|
+
result_json: resultJson,
|
|
776
|
+
truncated: false,
|
|
777
|
+
};
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
|
|
782
|
+
*/
|
|
783
|
+
async handleLibreOffice(request) {
|
|
784
|
+
const outputPath = resolveOutputPath(request);
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
action: "convert",
|
|
789
|
+
input_path: request.input_path,
|
|
790
|
+
target_format: request.target_format,
|
|
791
|
+
output_path: outputPath,
|
|
792
|
+
bytes: 0,
|
|
793
|
+
converter: "stub",
|
|
794
|
+
};
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function createRealBackend() {
|
|
800
|
+
const pythonInfo = probeBinary(PYTHON_BIN, ["--version"]);
|
|
801
|
+
const libreOfficeInfo = probeLibreOfficeBinary();
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
mode: "real",
|
|
805
|
+
async health() {
|
|
806
|
+
return {
|
|
807
|
+
backend: "real",
|
|
808
|
+
python: pythonInfo.available
|
|
809
|
+
? {
|
|
810
|
+
available: true,
|
|
811
|
+
command: pythonInfo.command,
|
|
812
|
+
version: pythonInfo.version,
|
|
813
|
+
}
|
|
814
|
+
: {
|
|
815
|
+
available: false,
|
|
816
|
+
command: pythonInfo.command,
|
|
817
|
+
error: pythonInfo.error,
|
|
818
|
+
},
|
|
819
|
+
libreoffice: libreOfficeInfo.available
|
|
820
|
+
? {
|
|
821
|
+
available: true,
|
|
822
|
+
command: libreOfficeInfo.command,
|
|
823
|
+
version: libreOfficeInfo.version,
|
|
824
|
+
}
|
|
825
|
+
: {
|
|
826
|
+
available: false,
|
|
827
|
+
command: libreOfficeInfo.command,
|
|
828
|
+
error: libreOfficeInfo.error,
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* @param {{ code: string; input_json?: string; timeout_ms: number }} request
|
|
835
|
+
*/
|
|
836
|
+
async handlePython(request) {
|
|
837
|
+
if (!pythonInfo.available) {
|
|
838
|
+
throw new HttpError(501, "python binary not available");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return runPython(request, { command: pythonInfo.command });
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* @param {{ input_path: string; target_format: string; output_path?: string; overwrite: boolean; timeout_ms: number }} request
|
|
846
|
+
*/
|
|
847
|
+
async handleLibreOffice(request) {
|
|
848
|
+
if (!libreOfficeInfo.available) {
|
|
849
|
+
throw new HttpError(501, "libreoffice binary not available");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return runLibreOfficeConvert(request, { command: libreOfficeInfo.command });
|
|
853
|
+
},
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const backend = MODE === "real" ? createRealBackend() : createStubBackend();
|
|
858
|
+
|
|
859
|
+
const handler = async (req, res) => {
|
|
860
|
+
try {
|
|
861
|
+
const remote = req.socket?.remoteAddress;
|
|
862
|
+
if (!isLoopbackAddress(remote)) {
|
|
863
|
+
respondText(res, 403, "forbidden");
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const origin = req.headers.origin;
|
|
868
|
+
if (!isAllowedOrigin(origin)) {
|
|
869
|
+
respondText(res, 403, "forbidden");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
setCorsHeaders(req, res);
|
|
874
|
+
|
|
875
|
+
if (req.method === "OPTIONS") {
|
|
876
|
+
res.statusCode = 204;
|
|
877
|
+
res.end();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const rawUrl = req.url || "/";
|
|
882
|
+
const url = new URL(rawUrl, `http://${HOST}:${PORT}`);
|
|
883
|
+
|
|
884
|
+
if (url.pathname === "/health") {
|
|
885
|
+
if (req.method !== "GET") {
|
|
886
|
+
throw new HttpError(405, "Method not allowed.");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
respondJson(res, 200, {
|
|
890
|
+
ok: true,
|
|
891
|
+
mode: backend.mode,
|
|
892
|
+
...await backend.health(),
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (url.pathname === "/v1/python-run") {
|
|
898
|
+
if (req.method !== "POST") {
|
|
899
|
+
throw new HttpError(405, "Method not allowed.");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (!isAuthorized(req)) {
|
|
903
|
+
throw new HttpError(401, "Unauthorized.");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const payload = await readJsonBody(req);
|
|
907
|
+
const request = parsePythonRunRequest(payload);
|
|
908
|
+
const result = await backend.handlePython(request);
|
|
909
|
+
|
|
910
|
+
respondJson(res, 200, {
|
|
911
|
+
ok: true,
|
|
912
|
+
...result,
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (url.pathname === "/v1/libreoffice-convert") {
|
|
918
|
+
if (req.method !== "POST") {
|
|
919
|
+
throw new HttpError(405, "Method not allowed.");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!isAuthorized(req)) {
|
|
923
|
+
throw new HttpError(401, "Unauthorized.");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const payload = await readJsonBody(req);
|
|
927
|
+
const request = parseLibreOfficeRequest(payload);
|
|
928
|
+
const result = await backend.handleLibreOffice(request);
|
|
929
|
+
|
|
930
|
+
respondJson(res, 200, {
|
|
931
|
+
ok: true,
|
|
932
|
+
...result,
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
throw new HttpError(404, "Not found.");
|
|
938
|
+
} catch (error) {
|
|
939
|
+
const isHttpError = error instanceof HttpError;
|
|
940
|
+
const status = isHttpError ? error.status : 500;
|
|
941
|
+
|
|
942
|
+
if (!isHttpError) {
|
|
943
|
+
console.error("[pi-for-excel] Unhandled python bridge error:", error);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const message = isHttpError
|
|
947
|
+
? error.message
|
|
948
|
+
: "Internal server error.";
|
|
949
|
+
|
|
950
|
+
respondJson(res, status, {
|
|
951
|
+
ok: false,
|
|
952
|
+
error: message,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const server = (() => {
|
|
958
|
+
if (!useHttps) {
|
|
959
|
+
return http.createServer(handler);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
|
963
|
+
console.error("[pi-for-excel] HTTPS requested but key.pem/cert.pem not found in repo root.");
|
|
964
|
+
console.error("Generate them with mkcert (see README). Example: mkcert localhost");
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return https.createServer(
|
|
969
|
+
{
|
|
970
|
+
key: fs.readFileSync(keyPath),
|
|
971
|
+
cert: fs.readFileSync(certPath),
|
|
972
|
+
},
|
|
973
|
+
handler,
|
|
974
|
+
);
|
|
975
|
+
})();
|
|
976
|
+
|
|
977
|
+
server.listen(PORT, HOST, () => {
|
|
978
|
+
const scheme = useHttps ? "https" : "http";
|
|
979
|
+
console.log(`[pi-for-excel] python bridge listening on ${scheme}://${HOST}:${PORT}`);
|
|
980
|
+
console.log(`[pi-for-excel] mode: ${backend.mode}`);
|
|
981
|
+
console.log(`[pi-for-excel] health: ${scheme}://${HOST}:${PORT}/health`);
|
|
982
|
+
console.log(`[pi-for-excel] endpoint: ${scheme}://${HOST}:${PORT}/v1/python-run`);
|
|
983
|
+
console.log(`[pi-for-excel] endpoint: ${scheme}://${HOST}:${PORT}/v1/libreoffice-convert`);
|
|
984
|
+
console.log(`[pi-for-excel] allowed origins: ${Array.from(allowedOrigins).join(", ")}`);
|
|
985
|
+
|
|
986
|
+
if (authToken) {
|
|
987
|
+
console.log("[pi-for-excel] auth: bearer token required for POST endpoints");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (backend.mode === "stub") {
|
|
991
|
+
console.log("[pi-for-excel] stub mode: python/libreoffice calls are simulated.");
|
|
992
|
+
console.log("[pi-for-excel] use PYTHON_BRIDGE_MODE=real for local command execution.");
|
|
993
|
+
}
|
|
994
|
+
});
|