strom-research 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
// Connectors: plugins that know one archive portal — how to find its books,
|
|
2
|
+
// list their images and fetch them. strom does not ship any; the user copies
|
|
3
|
+
// one into the plugins folder (<shared>/plugins/connectors/<name>/ — the
|
|
4
|
+
// folder's name is the connector's name) or builds one there with their agent
|
|
5
|
+
// (strom connector new). The core keeps them honest: a connector is a program
|
|
6
|
+
// speaking JSON lines on stdin/stdout, every request it makes goes through
|
|
7
|
+
// strom's polite limiter (core/net.ts) to the hosts it names, strom writes the
|
|
8
|
+
// files and checks the images. The user may ask to be asked first (setting
|
|
9
|
+
// connectors.consent on): then nothing runs without their consent — for the
|
|
10
|
+
// program, and for automated access to each host. Code that goes round strom's
|
|
11
|
+
// limiter needs their yes either way.
|
|
12
|
+
//
|
|
13
|
+
// The contract — interface 1, meant never to change — is
|
|
14
|
+
// assets/plugins/connectors/README.md, which strom also puts next to the
|
|
15
|
+
// plugins. This file implements it.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import readline from "node:readline";
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import { StromError, UsageError } from "./errors.js";
|
|
22
|
+
import { which } from "./which.js";
|
|
23
|
+
import { configDir } from "./paths.js";
|
|
24
|
+
import { loadUserConfig, Settings } from "./config.js";
|
|
25
|
+
import { readJsonIfExists, writeJson } from "./json.js";
|
|
26
|
+
import { readAsset } from "./assets.js";
|
|
27
|
+
import { foldText } from "./text.js";
|
|
28
|
+
import { CookieJar, hostAllowed, NetError, politeRequest } from "./net.js";
|
|
29
|
+
import { loginOf, redactor } from "./logins.js";
|
|
30
|
+
import { decodeImage, encodeImage, imageSize, imageSizeOfFile } from "../image/index.js";
|
|
31
|
+
import { blank, paste, toRgb } from "../image/image.js";
|
|
32
|
+
import { botCheck } from "./browser.js";
|
|
33
|
+
export const INTERFACE = 1;
|
|
34
|
+
export const MANIFEST = "connector.json";
|
|
35
|
+
export const CAPABILITIES = ["find", "list", "fetch", "part", "locate"];
|
|
36
|
+
/** How its images come: through strom's limiter (direct), or through the user's own browser. */
|
|
37
|
+
export const ROUTES = ["direct", "browser"];
|
|
38
|
+
/** A connector's name: its folder's name — lowercase letters, digits and dashes. */
|
|
39
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
40
|
+
// ── the plugins folder ───────────────────────────────────────────────────────
|
|
41
|
+
export function pluginsDir(shared) {
|
|
42
|
+
return path.join(shared, "plugins");
|
|
43
|
+
}
|
|
44
|
+
export function connectorsDir(shared) {
|
|
45
|
+
return path.join(pluginsDir(shared), "connectors");
|
|
46
|
+
}
|
|
47
|
+
/** The plugins folder, with strom's own files: what goes where, the interface, the .gitignore. */
|
|
48
|
+
export function ensurePluginsDir(shared) {
|
|
49
|
+
const dir = connectorsDir(shared);
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (!readOnly(err))
|
|
55
|
+
throw err;
|
|
56
|
+
return dir; // a folder strom may not write here (an agent's sandbox): reading still works
|
|
57
|
+
}
|
|
58
|
+
const own = [
|
|
59
|
+
[path.join(pluginsDir(shared), ".gitignore"), readAsset("plugins", "gitignore")],
|
|
60
|
+
[path.join(pluginsDir(shared), "README.md"), readAsset("plugins", "README.md")],
|
|
61
|
+
[path.join(dir, "README.md"), readAsset("plugins", "connectors", "README.md")],
|
|
62
|
+
// the newest SDK, for a connector written with an older one to copy over its own
|
|
63
|
+
[path.join(dir, "sdk.ts"), readAsset("plugins", "connectors", "sdk.ts")],
|
|
64
|
+
];
|
|
65
|
+
for (const [file, text] of own) {
|
|
66
|
+
if (text === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
let old;
|
|
69
|
+
try {
|
|
70
|
+
old = fs.readFileSync(file, "utf8");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
old = undefined;
|
|
74
|
+
}
|
|
75
|
+
if (old === text)
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(file, text);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// strom's own copies are refreshed when it may write here; a read-only sandbox keeps the old ones
|
|
82
|
+
if (!readOnly(err))
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return dir;
|
|
87
|
+
}
|
|
88
|
+
/** A write refused by the system (permissions, a read-only sandbox or disk). */
|
|
89
|
+
function readOnly(err) {
|
|
90
|
+
return ["EACCES", "EPERM", "EROFS"].includes(err?.code ?? "");
|
|
91
|
+
}
|
|
92
|
+
/** A name for a folder that has none usable: "Archiv Čížkov" → "archiv-cizkov". */
|
|
93
|
+
export function suggestName(folder) {
|
|
94
|
+
const s = foldText(folder)
|
|
95
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
96
|
+
.replace(/^-+|-+$/g, "");
|
|
97
|
+
return s && NAME_RE.test(s) ? s : undefined;
|
|
98
|
+
}
|
|
99
|
+
// ── reading and checking ─────────────────────────────────────────────────────
|
|
100
|
+
export function readManifest(dir) {
|
|
101
|
+
const file = path.join(dir, MANIFEST);
|
|
102
|
+
if (!fs.existsSync(file))
|
|
103
|
+
throw new UsageError(`no ${MANIFEST} in ${dir}`, { hint: "a connector is a folder with connector.json — strom connector new <name> --url <portal> makes one" });
|
|
104
|
+
let m;
|
|
105
|
+
try {
|
|
106
|
+
m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
throw new UsageError(`${MANIFEST} is not valid JSON: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
const problems = [];
|
|
112
|
+
if (m.interface !== INTERFACE)
|
|
113
|
+
problems.push(`interface: ${INTERFACE} — the version of the contract it is written for`);
|
|
114
|
+
if (!m.title || typeof m.title !== "string")
|
|
115
|
+
problems.push("title: the archive or portal");
|
|
116
|
+
if (!Array.isArray(m.run) || !m.run.length || m.run.some((x) => typeof x !== "string"))
|
|
117
|
+
problems.push('run: the program, e.g. ["node", "connector.ts"]');
|
|
118
|
+
if (!Array.isArray(m.hosts) || !m.hosts.length || m.hosts.some((h) => typeof h !== "string" || !/^(\*\.)?[a-z0-9.-]+$/i.test(h)))
|
|
119
|
+
problems.push('hosts: the host names it contacts, e.g. ["digi.example.org"]');
|
|
120
|
+
if (!Array.isArray(m.can) || !m.can.length || m.can.some((c) => !CAPABILITIES.includes(c)))
|
|
121
|
+
problems.push(`can: some of ${CAPABILITIES.join(", ")}`);
|
|
122
|
+
if (!m.policy || !["allowed", "manual", "unknown"].includes(m.policy.automation))
|
|
123
|
+
problems.push("policy.automation: allowed, manual or unknown — what the portal's terms say");
|
|
124
|
+
if (m.routes !== undefined) {
|
|
125
|
+
if (!Array.isArray(m.routes) || !m.routes.length || m.routes.some((r) => !ROUTES.includes(r)) || new Set(m.routes).size !== m.routes.length)
|
|
126
|
+
problems.push(`routes: some of ${ROUTES.join(", ")}, the usual one first`);
|
|
127
|
+
else if (m.routes.includes("browser") && Array.isArray(m.can) && !m.can.includes("locate"))
|
|
128
|
+
problems.push('routes "browser": the connector says where the images are — add "locate" to can (see the contract)');
|
|
129
|
+
}
|
|
130
|
+
if (m.browser !== undefined) {
|
|
131
|
+
const open = m.browser && typeof m.browser === "object" ? m.browser.open : undefined;
|
|
132
|
+
const u = typeof open === "string" && URL.canParse(open) ? new URL(open) : undefined;
|
|
133
|
+
if (!m.browser || typeof m.browser !== "object" || (open !== undefined && (!u || !/^https?:$/.test(u.protocol) || !Array.isArray(m.hosts) || !hostAllowed(u.hostname, m.hosts))))
|
|
134
|
+
problems.push("browser.open: a page on one of its hosts, e.g. the portal's start page");
|
|
135
|
+
if (m.browser && typeof m.browser === "object" && m.browser.pages !== undefined) {
|
|
136
|
+
if (typeof m.browser.pages !== "boolean")
|
|
137
|
+
problems.push("browser.pages: true or false");
|
|
138
|
+
else if (m.browser.pages && JSON.stringify(m.routes) !== JSON.stringify(["browser"]))
|
|
139
|
+
problems.push('browser.pages: every request goes through the browser — routes must be ["browser"] alone');
|
|
140
|
+
else if (m.browser.pages && m.login !== undefined)
|
|
141
|
+
problems.push("browser.pages: the user logs in in their own browser — no login in connector.json");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (m.login !== undefined) {
|
|
145
|
+
const l = m.login;
|
|
146
|
+
const fields = l && typeof l.fields === "object" && !Array.isArray(l.fields) ? Object.entries(l.fields) : [];
|
|
147
|
+
if (!l || typeof l.about !== "string" || !l.about.trim())
|
|
148
|
+
problems.push("login.about: what an account of the portal gives");
|
|
149
|
+
if (!fields.length || fields.length > 5 || fields.some(([k, v]) => !/^[a-z][a-z0-9_]*$/.test(k) || typeof v !== "string" || !v.trim()))
|
|
150
|
+
problems.push('login.fields: what the user types in, e.g. {"user": "User name", "password": "Password"}');
|
|
151
|
+
if (l && l.url !== undefined && typeof l.url !== "string")
|
|
152
|
+
problems.push("login.url: where to get an account");
|
|
153
|
+
if (l && l.required !== undefined && typeof l.required !== "boolean")
|
|
154
|
+
problems.push("login.required: true or false");
|
|
155
|
+
}
|
|
156
|
+
if (problems.length)
|
|
157
|
+
throw new UsageError(`${MANIFEST}: ${problems.join("; ")}`);
|
|
158
|
+
return m;
|
|
159
|
+
}
|
|
160
|
+
/** Every folder in the plugins folder: the connectors that work, and those that do not (and why). */
|
|
161
|
+
export function scanConnectors(shared) {
|
|
162
|
+
const connectors = [];
|
|
163
|
+
const broken = [];
|
|
164
|
+
if (!shared)
|
|
165
|
+
return { connectors, broken };
|
|
166
|
+
const base = ensurePluginsDir(shared);
|
|
167
|
+
for (const e of fs.readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
168
|
+
if (!e.isDirectory() || e.name.startsWith(".") || e.name.startsWith("_"))
|
|
169
|
+
continue;
|
|
170
|
+
const dir = path.join(base, e.name);
|
|
171
|
+
if (!NAME_RE.test(e.name)) {
|
|
172
|
+
const s = suggestName(e.name);
|
|
173
|
+
broken.push({ name: e.name, dir, problem: `the folder's name is the connector's name: lowercase letters a–z, digits and dashes — rename it${s ? ` (e.g. ${s})` : ""}` });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
connectors.push({ name: e.name, dir, manifest: readManifest(dir) });
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
broken.push({ name: e.name, dir, problem: err.message });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { connectors, broken };
|
|
184
|
+
}
|
|
185
|
+
export function listConnectors(shared) {
|
|
186
|
+
return scanConnectors(shared).connectors;
|
|
187
|
+
}
|
|
188
|
+
/** The ways a connector's images may come, the usual one first. */
|
|
189
|
+
export function routesOf(c) {
|
|
190
|
+
return c.manifest.routes?.length ? c.manifest.routes : ["direct"];
|
|
191
|
+
}
|
|
192
|
+
/** The route the user chose for a connector (strom connector use), or its usual one. */
|
|
193
|
+
export function routeOf(env, c) {
|
|
194
|
+
const chosen = loadUserConfig(env).connectorRoutes?.[c.name];
|
|
195
|
+
const routes = routesOf(c);
|
|
196
|
+
return chosen && routes.includes(chosen.via) ? { via: chosen.via, chosen } : { via: routes[0] };
|
|
197
|
+
}
|
|
198
|
+
/** Connectors whose images come through the user's own browser on this computer: the agent gets browser tools for their hosts. */
|
|
199
|
+
export function browserConnectors(env, shared) {
|
|
200
|
+
return listConnectors(shared).filter((c) => c.manifest.policy.automation !== "manual" && routeOf(env, c).via === "browser");
|
|
201
|
+
}
|
|
202
|
+
export function findConnector(shared, name) {
|
|
203
|
+
if (!shared)
|
|
204
|
+
throw new StromError("Strom is not set up yet", { hint: "strom setup" });
|
|
205
|
+
const { connectors, broken } = scanConnectors(shared);
|
|
206
|
+
const hit = connectors.find((c) => c.name === name);
|
|
207
|
+
if (hit)
|
|
208
|
+
return hit;
|
|
209
|
+
const bad = broken.find((b) => b.name === name);
|
|
210
|
+
if (bad)
|
|
211
|
+
throw new UsageError(`connector ${name} cannot run: ${bad.problem}`, { hint: `fix it in ${bad.dir} — the contract is ${path.join(connectorsDir(shared), "README.md")}` });
|
|
212
|
+
const base = connectorsDir(shared);
|
|
213
|
+
if (/[\\/]/.test(name) || fs.existsSync(path.join(name, MANIFEST)))
|
|
214
|
+
throw new UsageError(`a connector runs from the plugins folder, by its name — not from ${name}`, { hint: `copy the folder into ${base} (or: strom connector add ${name}), then use its folder's name` });
|
|
215
|
+
throw new UsageError(`no connector "${name}"`, {
|
|
216
|
+
hint: connectors.length ? `known: ${connectors.map((c) => c.name).join(", ")}` : `none yet: copy a connector's folder into ${base}, or build one: strom connector new <name> --url <portal>`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Not part of the code: git, libraries (reported by directNetwork), the output of tests. */
|
|
220
|
+
const SKIP = new Set([".git", "node_modules", "__pycache__", ".venv", "venv", ".DS_Store", ".test"]);
|
|
221
|
+
function codeFiles(dir) {
|
|
222
|
+
const out = [];
|
|
223
|
+
const walk = (d) => {
|
|
224
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
225
|
+
if (SKIP.has(e.name))
|
|
226
|
+
continue;
|
|
227
|
+
const p = path.join(d, e.name);
|
|
228
|
+
if (e.isDirectory())
|
|
229
|
+
walk(p);
|
|
230
|
+
else if (e.isFile())
|
|
231
|
+
out.push(p);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
walk(dir);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** The connector as it is now: the manifest and every file of it. */
|
|
238
|
+
export function connectorHash(dir) {
|
|
239
|
+
const h = crypto.createHash("sha256");
|
|
240
|
+
for (const f of codeFiles(dir)) {
|
|
241
|
+
h.update(path.relative(dir, f).split(path.sep).join("/") + "\0");
|
|
242
|
+
h.update(fs.readFileSync(f));
|
|
243
|
+
h.update("\0");
|
|
244
|
+
}
|
|
245
|
+
return h.digest("hex");
|
|
246
|
+
}
|
|
247
|
+
const LANG = {
|
|
248
|
+
".ts": "js", ".mts": "js", ".cts": "js", ".js": "js", ".mjs": "js", ".cjs": "js",
|
|
249
|
+
".py": "py", ".sh": "sh", ".bash": "sh",
|
|
250
|
+
".rb": "other", ".pl": "other", ".ps1": "other", ".bat": "other", ".cmd": "other",
|
|
251
|
+
};
|
|
252
|
+
/** Code that reaches the network or other programs directly — past strom's limiter — or that strom cannot read.
|
|
253
|
+
* `in`: the languages a check applies to; the manifest's `run` gets them all. */
|
|
254
|
+
const DIRECT = [
|
|
255
|
+
{ re: /\bfetch\s*\(/, what: "fetch()", in: ["js", "other"] },
|
|
256
|
+
{ re: /(from|require\(|import\()\s*["'](node:)?(https?|http2|net|tls|dgram|dns|undici)["']/, what: "a network module", in: ["js", "other"] },
|
|
257
|
+
{ re: /(from|require\(|import\()\s*["'](node:)?(child_process|vm|worker_threads)["']/, what: "child_process, vm or workers (other programs, code strom cannot check)", in: ["js", "other"] },
|
|
258
|
+
{ re: /\bnew\s+(XMLHttpRequest|WebSocket|EventSource)\b/, what: "XMLHttpRequest/WebSocket", in: ["js", "other"] },
|
|
259
|
+
{ re: /(?<![.\w$])eval\s*\(|\bnew\s+Function\s*\(|\bprocess\.(binding|dlopen)\b/, what: "code built at run time", in: ["js", "other"] },
|
|
260
|
+
{ re: /\b(import|require)\s*\(\s*[^"'`\s)]/, what: "a module named at run time (strom cannot check it)", in: ["js", "other"] },
|
|
261
|
+
{ re: /^\s*(import|from)\s+(requests|urllib3?|http|socket|httpx|aiohttp|subprocess|ctypes|ftplib|pycurl|importlib)\b/, what: "a Python module for the network or other programs", in: ["py", "other"] },
|
|
262
|
+
{ re: /\bos\.(system|popen|exec\w*|spawn\w*|fork)\s*\(|\b__import__\s*\(|(?<![.\w])(exec|eval)\s*\(/, what: "Python: other programs or code built at run time", in: ["py", "other"] },
|
|
263
|
+
{ re: /\b(curl|wget|ncat|socat|telnet|Invoke-WebRequest|Invoke-RestMethod)\b/, what: "a downloader program", in: ["sh", "other"] },
|
|
264
|
+
];
|
|
265
|
+
/** Libraries inside the folder: strom cannot check what they do. */
|
|
266
|
+
const LIBRARIES = ["node_modules", ".venv", "venv", "site-packages"];
|
|
267
|
+
export function directNetwork(c) {
|
|
268
|
+
const found = [];
|
|
269
|
+
for (const lib of LIBRARIES)
|
|
270
|
+
if (fs.existsSync(path.join(c.dir, lib)))
|
|
271
|
+
found.push(`${lib}/ — libraries strom cannot check`);
|
|
272
|
+
const run = c.manifest?.run?.join(" ") ?? "";
|
|
273
|
+
for (const d of DIRECT)
|
|
274
|
+
if (d.re.test(run))
|
|
275
|
+
found.push(`${MANIFEST} run: ${d.what}`);
|
|
276
|
+
for (const f of codeFiles(c.dir)) {
|
|
277
|
+
const lang = LANG[path.extname(f).toLowerCase()];
|
|
278
|
+
if (!lang)
|
|
279
|
+
continue;
|
|
280
|
+
fs.readFileSync(f, "utf8")
|
|
281
|
+
.split("\n")
|
|
282
|
+
.forEach((line, i) => {
|
|
283
|
+
if (/^\s*(\/\/|#|\*|\/\*)/.test(line))
|
|
284
|
+
return;
|
|
285
|
+
for (const d of DIRECT)
|
|
286
|
+
if (d.in.includes(lang) && d.re.test(line))
|
|
287
|
+
found.push(`${path.relative(c.dir, f)}:${i + 1} ${d.what}`);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
export function consentsFile(env) {
|
|
293
|
+
return path.join(configDir(env), "consents.json");
|
|
294
|
+
}
|
|
295
|
+
export function loadConsents(env) {
|
|
296
|
+
const c = readJsonIfExists(consentsFile(env)) ?? {};
|
|
297
|
+
return { connectors: c.connectors ?? {}, hosts: c.hosts ?? {} };
|
|
298
|
+
}
|
|
299
|
+
export function saveConsents(env, c) {
|
|
300
|
+
writeJson(consentsFile(env), c);
|
|
301
|
+
}
|
|
302
|
+
export const bareHost = (h) => h.replace(/^\*\./, "").toLowerCase();
|
|
303
|
+
/** Did the user ask to be asked before a connector runs? (strom config set connectors.consent on) */
|
|
304
|
+
export function consentRequired(env) {
|
|
305
|
+
return new Settings(env, {}).connectorsConsent();
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* What is still missing before this connector may run. With consents on: the
|
|
309
|
+
* user's yes to its folder, and to each of its hosts. The folder is allowed as
|
|
310
|
+
* a whole — the user's agent may go on improving it — as long as its code
|
|
311
|
+
* reaches the network only through strom. Code that goes round strom needs a
|
|
312
|
+
* yes exactly as it is, consents on or off, so every change of it needs a new one.
|
|
313
|
+
*/
|
|
314
|
+
export function missingConsents(env, c) {
|
|
315
|
+
const all = loadConsents(env);
|
|
316
|
+
const given = all.connectors[c.name];
|
|
317
|
+
const known = !!given && path.resolve(given.dir) === path.resolve(c.dir);
|
|
318
|
+
const direct = directNetwork(c).length > 0;
|
|
319
|
+
if (!consentRequired(env)) {
|
|
320
|
+
const code = !direct ? undefined : !known ? "new" : given.hash !== connectorHash(c.dir) ? "changed" : undefined;
|
|
321
|
+
return { ...(code ? { code } : {}), hosts: [] };
|
|
322
|
+
}
|
|
323
|
+
const code = !known ? "new" : direct && given.hash !== connectorHash(c.dir) ? "changed" : undefined;
|
|
324
|
+
return { ...(code ? { code } : {}), hosts: c.manifest.hosts.map(bareHost).filter((h) => !all.hosts[h]) };
|
|
325
|
+
}
|
|
326
|
+
/** Has the code changed since the user allowed it? (Only a note while it keeps to strom's network.) */
|
|
327
|
+
export function changedSinceConsent(env, c) {
|
|
328
|
+
const given = loadConsents(env).connectors[c.name];
|
|
329
|
+
return !!given && given.hash !== connectorHash(c.dir);
|
|
330
|
+
}
|
|
331
|
+
/** Connectors that may fetch now — nothing left to allow, not manual — for the host of a URL. */
|
|
332
|
+
export function readyConnectors(env, shared, url) {
|
|
333
|
+
let host;
|
|
334
|
+
try {
|
|
335
|
+
host = new URL(url).hostname;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
return listConnectors(shared).filter((c) => {
|
|
341
|
+
if (!c.manifest.can.includes("fetch") || c.manifest.policy.automation === "manual" || !hostAllowed(host, c.manifest.hosts))
|
|
342
|
+
return false;
|
|
343
|
+
const miss = missingConsents(env, c);
|
|
344
|
+
return !miss.code && !miss.hosts.length;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** The warning the user reads before allowing automated access to an archive. */
|
|
348
|
+
export function hostWarning(host, c) {
|
|
349
|
+
const p = c?.manifest.policy;
|
|
350
|
+
return [
|
|
351
|
+
`Automated access to ${host}${c ? ` (${c.manifest.title}, connector ${c.name})` : ""}`,
|
|
352
|
+
" · Archives run small servers: going too fast slows the archive for everyone and gets your IP blocked.",
|
|
353
|
+
` · Terms of use: ${p?.terms || "not found yet — read them on the portal before you allow this"}${p?.termsSummary ? `\n ${p.termsSummary}` : ""}`,
|
|
354
|
+
p ? ` · The connector's reading of them: automation ${p.automation}${p.officialExport ? `; official export: ${p.officialExport}` : ""}` : undefined,
|
|
355
|
+
" · strom sends one request at a time, at least 2 s apart, at most 400 an hour; it stops at the first",
|
|
356
|
+
" refusal (401/403) and leaves the archive alone for a day, and for an hour when it asks twice to",
|
|
357
|
+
" slow down or stops answering.",
|
|
358
|
+
" · You are responsible for how you use the archive. Prefer its official export where there is one;",
|
|
359
|
+
" when in doubt, ask the archive.",
|
|
360
|
+
]
|
|
361
|
+
.filter(Boolean)
|
|
362
|
+
.join("\n");
|
|
363
|
+
}
|
|
364
|
+
export function codeWarning(c, source, direct) {
|
|
365
|
+
const m = c.manifest;
|
|
366
|
+
return [
|
|
367
|
+
`Connector ${c.name}${m.version ? ` ${m.version}` : ""} — ${m.title}`,
|
|
368
|
+
` from: ${source}`,
|
|
369
|
+
` runs: ${m.run.join(" ")} (a program on this computer, in ${c.dir})`,
|
|
370
|
+
` contacts: ${m.hosts.join(", ")} — only through strom's limiter`,
|
|
371
|
+
` can: ${m.can.join(", ")}`,
|
|
372
|
+
direct.length
|
|
373
|
+
? ` ⚠ its code reaches the network or other programs directly — strom cannot pace or stop that:\n${direct.slice(0, 8).map((d) => ` ${d}`).join("\n")}\n you will be asked again after every change of it`
|
|
374
|
+
: " its code uses no network of its own (checked); your agent may go on improving it without asking you again",
|
|
375
|
+
m.policy.automation === "manual" ? " ⚠ its own reading of the portal's terms: automation is not allowed — it only helps you download by hand" : undefined,
|
|
376
|
+
]
|
|
377
|
+
.filter(Boolean)
|
|
378
|
+
.join("\n");
|
|
379
|
+
}
|
|
380
|
+
// ── images: an error page or a broken download is not a scan ─────────────────
|
|
381
|
+
const MAGIC = [
|
|
382
|
+
{ kind: "jpeg", at: 0, bytes: [0xff, 0xd8, 0xff] },
|
|
383
|
+
{ kind: "png", at: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
384
|
+
{ kind: "gif", at: 0, bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
385
|
+
{ kind: "tiff", at: 0, bytes: [0x49, 0x49, 0x2a, 0x00] },
|
|
386
|
+
{ kind: "tiff", at: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] },
|
|
387
|
+
{ kind: "jp2", at: 0, bytes: [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20] },
|
|
388
|
+
{ kind: "jp2", at: 0, bytes: [0xff, 0x4f, 0xff, 0x51] },
|
|
389
|
+
{ kind: "webp", at: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
390
|
+
{ kind: "bmp", at: 0, bytes: [0x42, 0x4d] },
|
|
391
|
+
{ kind: "heic", at: 4, bytes: [0x66, 0x74, 0x79, 0x70] },
|
|
392
|
+
];
|
|
393
|
+
/** Width and height from the file's header, when strom can read them (JPEG, PNG). */
|
|
394
|
+
export function sizeOf(bytes) {
|
|
395
|
+
try {
|
|
396
|
+
const s = imageSize(bytes);
|
|
397
|
+
return s && s.width > 0 && s.height > 0 ? s : undefined;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return undefined; // too short to hold a size
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** The longer side a whole image has at least — anything smaller is a placeholder or a thumbnail (a part: a sliver). */
|
|
404
|
+
export const MIN_SIDE = { whole: 400, part: 64 };
|
|
405
|
+
/**
|
|
406
|
+
* Why a downloaded file is not a whole image, or undefined when it is one. A
|
|
407
|
+
* portal may answer 200 with a stand-in: an error page, a file cut short, a
|
|
408
|
+
* picture of 1×1 or 120×97 px in place of an image it will not give.
|
|
409
|
+
* A tile is only checked for being a whole file (the last of a row may be a few pixels).
|
|
410
|
+
*/
|
|
411
|
+
export function imageProblem(file, want = "whole") {
|
|
412
|
+
const size = fs.statSync(file).size;
|
|
413
|
+
if (size < 64)
|
|
414
|
+
return `${size} bytes — too small for an image`;
|
|
415
|
+
const fd = fs.openSync(file, "r");
|
|
416
|
+
try {
|
|
417
|
+
const head = Buffer.alloc(16);
|
|
418
|
+
fs.readSync(fd, head, 0, 16, 0);
|
|
419
|
+
const tail = Buffer.alloc(Math.min(1024, size));
|
|
420
|
+
fs.readSync(fd, tail, 0, tail.length, size - tail.length);
|
|
421
|
+
const kind = MAGIC.find((m) => m.bytes.every((b, i) => head[m.at + i] === b))?.kind;
|
|
422
|
+
if (!kind) {
|
|
423
|
+
const start = head.toString("latin1").replace(/[^\x20-\x7e]+/g, " ").trim().slice(0, 16);
|
|
424
|
+
return `not an image (it begins "${start}") — an error page?`;
|
|
425
|
+
}
|
|
426
|
+
// FF D9 ends a JPEG; inside the compressed data an FF is never followed by D9
|
|
427
|
+
if (kind === "jpeg" && !tail.includes(Buffer.from([0xff, 0xd9])))
|
|
428
|
+
return "a JPEG cut short (no end marker) — the download broke off";
|
|
429
|
+
if (kind === "png" && !tail.includes(Buffer.from("IEND")))
|
|
430
|
+
return "a PNG cut short (no end) — the download broke off";
|
|
431
|
+
if (want === "tile")
|
|
432
|
+
return undefined;
|
|
433
|
+
const dims = imageSizeOfFile(file);
|
|
434
|
+
if (dims && Math.max(dims.width, dims.height) < MIN_SIDE[want])
|
|
435
|
+
return `${dims.width}×${dims.height} px — a placeholder or a thumbnail, not ${want === "part" ? "a part of" : ""} a scan`.replace(" ", " ");
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
fs.closeSync(fd);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const TILES_MAX = 4096;
|
|
443
|
+
const PIXELS_MAX = 40_000_000; // about 600 MB of memory to put together; a whole page at full size is rarely wanted (a part is)
|
|
444
|
+
/**
|
|
445
|
+
* An image a portal gives only in tiles (Zoomify, DeepZoom …), put together
|
|
446
|
+
* into one: each tile at its x, y (top left corner, in pixels of the image) of
|
|
447
|
+
* a width × height picture, written to `out` (JPEG, or PNG for a .png name);
|
|
448
|
+
* the tiles are removed. Overlaps are fine; a gap — a tile left out — is not.
|
|
449
|
+
* Why it could not, or undefined.
|
|
450
|
+
*/
|
|
451
|
+
export function assembleTiles(workDir, tiles, width, height, out) {
|
|
452
|
+
const W = Number(width);
|
|
453
|
+
const H = Number(height);
|
|
454
|
+
if (!Number.isInteger(W) || !Number.isInteger(H) || W < 1 || H < 1 || W > 65_535 || H > 65_535 || W * H > PIXELS_MAX)
|
|
455
|
+
return `tiles need the image's width and height: whole pixels, at most ${PIXELS_MAX / 1e6} million together (got ${String(width)} × ${String(height)})`;
|
|
456
|
+
if (!Array.isArray(tiles) || !tiles.length || tiles.length > TILES_MAX)
|
|
457
|
+
return `tiles: a list of 1–${TILES_MAX} tiles, each {file, x, y}`;
|
|
458
|
+
const CELL = 8; // a grid of 8 px cells: each must be covered by a tile
|
|
459
|
+
const cols = Math.ceil(W / CELL);
|
|
460
|
+
const covered = new Uint8Array(cols * Math.ceil(H / CELL));
|
|
461
|
+
let canvas;
|
|
462
|
+
const files = [];
|
|
463
|
+
for (const t of tiles) {
|
|
464
|
+
const { file, x, y } = (t && typeof t === "object" ? t : {});
|
|
465
|
+
if (typeof file !== "string" || !file || !Number.isInteger(x) || !Number.isInteger(y))
|
|
466
|
+
return `a tile needs file, x and y (whole pixels): ${JSON.stringify(t)?.slice(0, 120)}`;
|
|
467
|
+
const f = path.join(workDir, safeName(file));
|
|
468
|
+
if (!fs.existsSync(f))
|
|
469
|
+
return `tile ${file}: no such file was saved`;
|
|
470
|
+
const problem = imageProblem(f, "tile");
|
|
471
|
+
if (problem)
|
|
472
|
+
return `tile ${file}: ${problem}`;
|
|
473
|
+
let img;
|
|
474
|
+
try {
|
|
475
|
+
img = decodeImage(fs.readFileSync(f));
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
return `tile ${file}: ${err.message}`;
|
|
479
|
+
}
|
|
480
|
+
files.push(f);
|
|
481
|
+
const [tx, ty] = [x, y];
|
|
482
|
+
canvas ??= blank(W, H, img.channels);
|
|
483
|
+
if (img.channels > canvas.channels)
|
|
484
|
+
canvas = toRgb(canvas);
|
|
485
|
+
paste(canvas, img.channels < canvas.channels ? toRgb(img) : img, tx, ty);
|
|
486
|
+
for (let cy = Math.max(0, Math.floor(ty / CELL)); cy < Math.min(Math.ceil(H / CELL), Math.ceil((ty + img.height) / CELL)); cy++) {
|
|
487
|
+
const my = Math.min(cy * CELL + CELL / 2, H - 1);
|
|
488
|
+
if (my < ty || my >= ty + img.height)
|
|
489
|
+
continue;
|
|
490
|
+
for (let cx = Math.max(0, Math.floor(tx / CELL)); cx < Math.min(cols, Math.ceil((tx + img.width) / CELL)); cx++) {
|
|
491
|
+
const mx = Math.min(cx * CELL + CELL / 2, W - 1);
|
|
492
|
+
if (mx >= tx && mx < tx + img.width)
|
|
493
|
+
covered[cy * cols + cx] = 1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const gap = covered.indexOf(0);
|
|
498
|
+
if (gap >= 0)
|
|
499
|
+
return `the tiles leave a gap near ${Math.min((gap % cols) * CELL + CELL / 2, W - 1)},${Math.min(Math.floor(gap / cols) * CELL + CELL / 2, H - 1)} px of ${W}×${H} — a tile is missing, or its x, y is wrong`;
|
|
500
|
+
fs.writeFileSync(out, encodeImage(canvas, /\.png$/i.test(out) ? "png" : "jpeg", 90));
|
|
501
|
+
for (const f of files)
|
|
502
|
+
if (f !== out)
|
|
503
|
+
fs.rmSync(f, { force: true });
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
/** A minimal environment: the connector gets no secrets of the shell that runs strom. */
|
|
507
|
+
function connectorEnv(env, workDir) {
|
|
508
|
+
const keep = ["PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "APPDATA", "LOCALAPPDATA"];
|
|
509
|
+
const out = {};
|
|
510
|
+
for (const k of keep)
|
|
511
|
+
if (env[k])
|
|
512
|
+
out[k] = env[k];
|
|
513
|
+
out.STROM_CONNECTOR_WORKDIR = workDir;
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The command line. A Node connector runs in the Node that runs strom (new
|
|
518
|
+
* enough for TypeScript), under Node's permission model: it reads its own
|
|
519
|
+
* folder and the work folder, writes only into the work folder, starts no
|
|
520
|
+
* other programs — and from Node 25 on, reaches no network at all. It runs on
|
|
521
|
+
* the Node strom runs on.
|
|
522
|
+
*/
|
|
523
|
+
function commandLine(c, dir, workDir, env) {
|
|
524
|
+
const [cmd, ...args] = c.manifest.run;
|
|
525
|
+
if (cmd !== "node")
|
|
526
|
+
return [cmd, args];
|
|
527
|
+
return [nodeForPlugins(env), ["--permission", `--allow-fs-read=${dir}`, `--allow-fs-read=${workDir}`, `--allow-fs-write=${workDir}`, ...args]];
|
|
528
|
+
}
|
|
529
|
+
/** The Node a connector written in TypeScript runs in: strom's own (22.18 and newer run TypeScript as it is). */
|
|
530
|
+
function nodeForPlugins(_env) {
|
|
531
|
+
return process.execPath;
|
|
532
|
+
}
|
|
533
|
+
const safeName = (s) => path.basename(s).replace(/[^\p{L}\p{M}\p{N}._-]+/gu, "_").replace(/^\.+/, "_");
|
|
534
|
+
/** A part of an image as fractions of it (0–1), or undefined when it is not one. */
|
|
535
|
+
export function regionOf(v) {
|
|
536
|
+
if (!v || typeof v !== "object")
|
|
537
|
+
return undefined;
|
|
538
|
+
const r = v;
|
|
539
|
+
const [x, y, w, h] = [r.x, r.y, r.w, r.h].map(Number);
|
|
540
|
+
if (![x, y, w, h].every((n) => Number.isFinite(n) && n >= 0 && n <= 1) || !(w > 0) || !(h > 0) || x + w > 1.0001 || y + h > 1.0001)
|
|
541
|
+
return undefined;
|
|
542
|
+
return { x: x, y: y, w: w, h: h };
|
|
543
|
+
}
|
|
544
|
+
/** Hosts of this computer: a login may go there over plain http (a portal the user runs, a test). */
|
|
545
|
+
const LOCAL = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
546
|
+
const TEXT_MAX = 5 * 1024 * 1024;
|
|
547
|
+
const METHODS = new Set(["GET", "POST", "HEAD"]);
|
|
548
|
+
export async function runConnector(c, request, opts) {
|
|
549
|
+
if (!c.manifest.can.includes(request.cmd))
|
|
550
|
+
throw new UsageError(`connector ${c.name} cannot ${request.cmd}`, { hint: `it can: ${c.manifest.can.join(", ")}` });
|
|
551
|
+
fs.mkdirSync(opts.workDir, { recursive: true });
|
|
552
|
+
// real paths: the permission model compares the paths the program uses with these
|
|
553
|
+
const dir = fs.realpathSync(c.dir);
|
|
554
|
+
const workDir = fs.realpathSync(opts.workDir);
|
|
555
|
+
const report = { books: [], images: [], located: [], requests: 0, logs: [] };
|
|
556
|
+
const cookies = new CookieJar(); // the cookies of this run, and of no other
|
|
557
|
+
const seen = new Map(); // the images of this run by their content
|
|
558
|
+
// the user's login, put into requests by strom and taken out of every answer
|
|
559
|
+
const login = c.manifest.login ? loginOf(opts.env, c) : undefined;
|
|
560
|
+
const clean = redactor(login);
|
|
561
|
+
const [cmd, args] = commandLine(c, dir, workDir, opts.env);
|
|
562
|
+
const child = spawn(cmd, args, { cwd: dir, env: connectorEnv(opts.env, workDir), stdio: ["pipe", "pipe", "pipe"] });
|
|
563
|
+
const send = (o) => {
|
|
564
|
+
if (!child.stdin.destroyed && child.stdin.writable)
|
|
565
|
+
child.stdin.write(JSON.stringify(o) + "\n");
|
|
566
|
+
};
|
|
567
|
+
const log = (s) => {
|
|
568
|
+
report.logs.push(s);
|
|
569
|
+
opts.onLog?.(s);
|
|
570
|
+
};
|
|
571
|
+
child.stdin.on("error", () => undefined); // a connector that ended does not read its answers
|
|
572
|
+
let stderr = "";
|
|
573
|
+
child.stderr.on("data", (d) => (stderr = (stderr + d).slice(-4000)));
|
|
574
|
+
const exited = new Promise((resolve) => {
|
|
575
|
+
child.on("error", (err) => {
|
|
576
|
+
report.stopped ??= `the connector could not start: ${err.message}`;
|
|
577
|
+
resolve(null);
|
|
578
|
+
});
|
|
579
|
+
child.on("close", (code) => resolve(code));
|
|
580
|
+
});
|
|
581
|
+
const stop = (why) => {
|
|
582
|
+
report.stopped ??= why;
|
|
583
|
+
child.kill();
|
|
584
|
+
};
|
|
585
|
+
send({ interface: INTERFACE, ...request, ...(login ? { login: true } : {}) });
|
|
586
|
+
const lines = readline.createInterface({ input: child.stdout });
|
|
587
|
+
let queue = Promise.resolve();
|
|
588
|
+
lines.on("line", (line) => {
|
|
589
|
+
// one message at a time, in order: the limiter paces the requests anyway
|
|
590
|
+
queue = queue.then(() => (report.stopped ? undefined : handle(line))).catch((err) => stop(`strom failed: ${err.message}`));
|
|
591
|
+
});
|
|
592
|
+
const handle = async (line) => {
|
|
593
|
+
if (!line.trim())
|
|
594
|
+
return;
|
|
595
|
+
let msg;
|
|
596
|
+
try {
|
|
597
|
+
msg = JSON.parse(line);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
log(line.slice(0, 300)); // plain output is a log line
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (typeof msg.log === "string")
|
|
604
|
+
return log(msg.log);
|
|
605
|
+
if (typeof msg.error === "string")
|
|
606
|
+
return stop(`the connector: ${msg.error}`);
|
|
607
|
+
if (msg.done) {
|
|
608
|
+
child.stdin.end(); // nothing more to answer: its input closes, it can exit
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (msg.book && typeof msg.book === "object") {
|
|
612
|
+
const b = msg.book;
|
|
613
|
+
if (typeof b.title === "string" && b.title.trim())
|
|
614
|
+
report.books.push({ ...b, title: b.title.normalize("NFC") });
|
|
615
|
+
else
|
|
616
|
+
log(`a book without a title: ${line.slice(0, 200)}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (msg.located && typeof msg.located === "object") {
|
|
620
|
+
const l = msg.located;
|
|
621
|
+
if (request.cmd !== "locate")
|
|
622
|
+
return log(`"located" answers locate only: ${line.slice(0, 200)}`);
|
|
623
|
+
const n = Number(l.n);
|
|
624
|
+
const src = typeof l.src === "string" && URL.canParse(l.src) ? new URL(l.src) : undefined;
|
|
625
|
+
if (!Number.isInteger(n) || !request.images.includes(n))
|
|
626
|
+
return stop(`located image ${String(l.n ?? "?")}: not one of the images asked for (${request.images.join(", ")})`);
|
|
627
|
+
if (!src || !/^https?:$/.test(src.protocol))
|
|
628
|
+
return stop(`located image ${n}: src must be the image's own http(s) address`);
|
|
629
|
+
if (!hostAllowed(src.hostname, c.manifest.hosts))
|
|
630
|
+
return stop(`located image ${n}: ${src.hostname} is not one of its hosts (${c.manifest.hosts.join(", ")})`);
|
|
631
|
+
report.located.push({
|
|
632
|
+
n,
|
|
633
|
+
src: src.toString(),
|
|
634
|
+
...(typeof l.url === "string" && l.url ? { url: l.url } : {}),
|
|
635
|
+
...(typeof l.page === "string" && l.page ? { page: l.page } : {}),
|
|
636
|
+
...(request.region ? { region: regionOf(l.region) ?? request.region } : {}),
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (msg.image && typeof msg.image === "object") {
|
|
641
|
+
const { region: got, tiles, width, height, ...im } = msg.image;
|
|
642
|
+
const file = path.join(workDir, safeName(String(im.file ?? "")));
|
|
643
|
+
if (tiles !== undefined) {
|
|
644
|
+
// a portal that gives the image in tiles only: strom puts them together into the file
|
|
645
|
+
if (!im.file)
|
|
646
|
+
return stop(`image ${im.n ?? "?"}: tiles are put together into its file — give the file a name`);
|
|
647
|
+
const why = assembleTiles(workDir, tiles, width, height, file);
|
|
648
|
+
if (why)
|
|
649
|
+
return stop(`image ${im.n ?? "?"}: ${why}`);
|
|
650
|
+
}
|
|
651
|
+
if (!im.file || !fs.existsSync(file))
|
|
652
|
+
return stop(`image ${im.n ?? "?"}: no file ${im.file ?? ""} was saved`);
|
|
653
|
+
const problem = imageProblem(file, request.cmd === "part" ? "part" : "whole");
|
|
654
|
+
if (problem)
|
|
655
|
+
return stop(`image ${im.n ?? "?"} (${path.basename(file)}): ${problem}`);
|
|
656
|
+
if (request.cmd !== "part") {
|
|
657
|
+
// the same file as another image: what a portal gives in place of the images it will not give
|
|
658
|
+
const hash = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
659
|
+
if (seen.has(hash) && seen.get(hash) !== im.n) {
|
|
660
|
+
// two scans are never the same to the byte: neither is the image asked for
|
|
661
|
+
const same = seen.get(hash);
|
|
662
|
+
report.images = report.images.filter((i) => i.n !== same);
|
|
663
|
+
return stop(`image ${im.n ?? "?"} (${path.basename(file)}): the same file as image ${same ?? "?"} — a placeholder in place of both, neither is kept`);
|
|
664
|
+
}
|
|
665
|
+
seen.set(hash, im.n);
|
|
666
|
+
}
|
|
667
|
+
if (request.cmd === "part") {
|
|
668
|
+
if (im.n !== request.image)
|
|
669
|
+
return stop(`asked for a part of image ${request.image}, it gave image ${im.n ?? "?"}`);
|
|
670
|
+
// the part it got, when the portal cut it a little differently; else the part asked for
|
|
671
|
+
report.images.push({ ...im, file, region: regionOf(got) ?? request.region });
|
|
672
|
+
}
|
|
673
|
+
else
|
|
674
|
+
report.images.push({ ...im, file });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (msg.http && typeof msg.http === "object") {
|
|
678
|
+
const id = msg.id;
|
|
679
|
+
const h = msg.http;
|
|
680
|
+
const refuse = (error, message) => {
|
|
681
|
+
send({ id, error, message });
|
|
682
|
+
stop(message);
|
|
683
|
+
};
|
|
684
|
+
const method = String(h.method ?? (h.form !== undefined ? "POST" : "GET")).toUpperCase();
|
|
685
|
+
if (typeof h.url !== "string")
|
|
686
|
+
return refuse("http", "a request without a url");
|
|
687
|
+
if (!METHODS.has(method))
|
|
688
|
+
return refuse("http", `method ${method}: GET, POST or HEAD`);
|
|
689
|
+
if (h.body !== undefined && typeof h.body !== "string")
|
|
690
|
+
return refuse("http", "body: a string");
|
|
691
|
+
// a value of a form or a header may be the user's login: {"login":"password"} — strom puts it in
|
|
692
|
+
const missing = [];
|
|
693
|
+
const secret = [];
|
|
694
|
+
const value = (v, where) => {
|
|
695
|
+
if (v === null || typeof v !== "object" || typeof v.login !== "string")
|
|
696
|
+
return String(v);
|
|
697
|
+
const field = v.login;
|
|
698
|
+
secret.push(where);
|
|
699
|
+
if (login?.values[field] === undefined)
|
|
700
|
+
missing.push(field);
|
|
701
|
+
return login?.values[field] ?? "";
|
|
702
|
+
};
|
|
703
|
+
const headers = {};
|
|
704
|
+
if (h.headers && typeof h.headers === "object")
|
|
705
|
+
for (const [k, v] of Object.entries(h.headers))
|
|
706
|
+
headers[k] = value(v, k);
|
|
707
|
+
const privateHeaders = [...secret];
|
|
708
|
+
let body = typeof h.body === "string" ? h.body : undefined;
|
|
709
|
+
if (h.form !== undefined) {
|
|
710
|
+
if (!h.form || typeof h.form !== "object" || Array.isArray(h.form))
|
|
711
|
+
return refuse("http", "form: an object of fields");
|
|
712
|
+
if (body !== undefined)
|
|
713
|
+
return refuse("http", "a request has a body or a form, not both");
|
|
714
|
+
if (method !== "POST")
|
|
715
|
+
return refuse("http", "a form is sent with POST");
|
|
716
|
+
body = new URLSearchParams(Object.entries(h.form).map(([k, v]) => [k, value(v, "form")])).toString();
|
|
717
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type"))
|
|
718
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
719
|
+
}
|
|
720
|
+
if (secret.length) {
|
|
721
|
+
const u = URL.canParse(h.url) ? new URL(h.url) : undefined;
|
|
722
|
+
if (!login)
|
|
723
|
+
return refuse("login", `no login of the user for ${c.name} — the user saves one in their terminal: strom login ${c.name}`);
|
|
724
|
+
if (missing.length)
|
|
725
|
+
return refuse("login", `the login saved for ${c.name} has no ${[...new Set(missing)].join(", ")} — the user saves it again: strom login ${c.name}`);
|
|
726
|
+
if (!u || (u.protocol !== "https:" && !LOCAL.has(u.hostname)))
|
|
727
|
+
return refuse("login", `a login goes only over https, not to ${u?.origin ?? h.url}`);
|
|
728
|
+
if (!hostAllowed(u.hostname, login.hosts))
|
|
729
|
+
return refuse("login", `the login was saved for ${login.hosts.join(", ")}, not for ${u.hostname} — the user saves it again for the connector as it is now: strom login ${c.name}`);
|
|
730
|
+
const unfit = privateHeaders.find((k) => !/^[\t\x20-\x7e\x80-\xff]*$/.test(headers[k] ?? ""));
|
|
731
|
+
if (unfit)
|
|
732
|
+
return refuse("login", `the login cannot go in the header ${unfit}: it has letters a header cannot carry — send it in a form`);
|
|
733
|
+
}
|
|
734
|
+
if (opts.pages) {
|
|
735
|
+
// a portal that answers a real browser only: the page comes from the user's browser, or the run waits for it
|
|
736
|
+
if (secret.length)
|
|
737
|
+
return refuse("login", `through the browser the user logs in in their own tab — ${c.name} sends no login there`);
|
|
738
|
+
const u = URL.canParse(h.url) ? new URL(h.url) : undefined;
|
|
739
|
+
if (!u || !/^https?:$/.test(u.protocol) || !hostAllowed(u.hostname, c.manifest.hosts))
|
|
740
|
+
return refuse("host", `${u?.hostname ?? h.url} is not one of its hosts (${c.manifest.hosts.join(", ")})`);
|
|
741
|
+
const want = { method: method, url: u.toString(), headers, ...(body !== undefined ? { body } : {}) };
|
|
742
|
+
const got = opts.pages(want);
|
|
743
|
+
if (!got) {
|
|
744
|
+
report.needs = want;
|
|
745
|
+
send({ id, error: "browser", message: "this page comes from the user's browser: strom plans it" });
|
|
746
|
+
return stop(`it needs a page from your browser: ${method} ${u.toString()}`);
|
|
747
|
+
}
|
|
748
|
+
report.pages = (report.pages ?? 0) + 1;
|
|
749
|
+
if (got.status === 401 || got.status === 403)
|
|
750
|
+
return refuse("refused", `${u.hostname} answered ${got.status} in your browser — strom stops and leaves it alone for a day`);
|
|
751
|
+
const answer = { id, status: got.status, type: got.contentType, headers: got.headers, url: got.url };
|
|
752
|
+
if (typeof h.save === "string" && h.save) {
|
|
753
|
+
const file = path.join(workDir, safeName(h.save));
|
|
754
|
+
fs.writeFileSync(file, got.body);
|
|
755
|
+
send({ ...answer, file, bytes: got.body.length, ...sizeOf(got.body) });
|
|
756
|
+
}
|
|
757
|
+
else if (got.body.length > TEXT_MAX)
|
|
758
|
+
refuse("too-big", `an answer of ${got.body.length} bytes is too big as text — ask with "save"`);
|
|
759
|
+
else
|
|
760
|
+
send({ ...answer, text: got.body.toString("utf8") });
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (opts.maxRequests !== undefined && report.requests >= opts.maxRequests)
|
|
764
|
+
return refuse("cap", `the run's limit of ${opts.maxRequests} requests (a test: --max <n>, at most 50)`);
|
|
765
|
+
report.requests++;
|
|
766
|
+
try {
|
|
767
|
+
const res = await politeRequest(h.url, {
|
|
768
|
+
stateDir: opts.netDir,
|
|
769
|
+
hosts: c.manifest.hosts,
|
|
770
|
+
pace: c.manifest.policy.pace ?? {},
|
|
771
|
+
method: method,
|
|
772
|
+
headers,
|
|
773
|
+
...(body !== undefined ? { body } : {}),
|
|
774
|
+
...(secret.length ? { private: { headers: privateHeaders, body: secret.includes("form") } } : {}),
|
|
775
|
+
cookies,
|
|
776
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
777
|
+
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
778
|
+
});
|
|
779
|
+
const check = botCheck(res.body, res.headers);
|
|
780
|
+
if (check)
|
|
781
|
+
log(`${new URL(h.url).hostname} answered with ${check}'s check whether a person is there, not the page — it gives its pages to a real browser only: "browser": {"pages": true} (the contract, section 5)`);
|
|
782
|
+
const answer = { id, status: res.status, type: res.contentType, headers: Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, clean(v)])), url: clean(res.url) };
|
|
783
|
+
if (typeof h.save === "string" && h.save) {
|
|
784
|
+
const file = path.join(workDir, safeName(h.save));
|
|
785
|
+
fs.writeFileSync(file, res.body);
|
|
786
|
+
// an image's size from its header: a connector sees a thumbnail or a placeholder before it gives it as the image
|
|
787
|
+
send({ ...answer, file, bytes: res.body.length, ...sizeOf(res.body) });
|
|
788
|
+
}
|
|
789
|
+
else if (res.body.length > TEXT_MAX) {
|
|
790
|
+
refuse("too-big", `an answer of ${res.body.length} bytes is too big as text — ask with "save"`);
|
|
791
|
+
}
|
|
792
|
+
else
|
|
793
|
+
send({ ...answer, text: clean(res.body.toString("utf8")) });
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
if (!(err instanceof NetError))
|
|
797
|
+
throw err;
|
|
798
|
+
// every refusal ends the run: nothing more goes to an archive that said no, or went silent
|
|
799
|
+
refuse(err.failure, clean(err.message));
|
|
800
|
+
}
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
log(`unknown message: ${line.slice(0, 200)}`);
|
|
804
|
+
};
|
|
805
|
+
const code = await exited;
|
|
806
|
+
await queue;
|
|
807
|
+
lines.close();
|
|
808
|
+
if (code && !report.stopped)
|
|
809
|
+
report.stopped = `the connector ended with code ${code}${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" | ")}` : ""}`;
|
|
810
|
+
return report;
|
|
811
|
+
}
|