@wilm-ai/wilma-cli 1.6.2 → 2.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 +21 -0
- package/README.md +57 -67
- package/dist/access.d.ts +12 -0
- package/dist/access.js +33 -0
- package/dist/agent-data.d.ts +496 -0
- package/dist/agent-data.js +578 -0
- package/dist/cli-args.d.ts +33 -0
- package/dist/cli-args.js +256 -0
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.js +141 -0
- package/dist/cli-output.d.ts +6 -0
- package/dist/cli-output.js +184 -0
- package/dist/config.js +63 -24
- package/dist/credentials.d.ts +69 -0
- package/dist/credentials.js +195 -0
- package/dist/downloads.d.ts +23 -0
- package/dist/downloads.js +105 -0
- package/dist/index.js +692 -1657
- package/dist/login-server.d.ts +52 -0
- package/dist/login-server.js +595 -0
- package/dist/mcp-tools.d.ts +23 -0
- package/dist/mcp-tools.js +235 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +153 -0
- package/dist/output-json.d.ts +1 -0
- package/dist/output-json.js +26 -0
- package/dist/session-store.d.ts +7 -0
- package/dist/session-store.js +61 -0
- package/dist/tenant-search.d.ts +11 -0
- package/dist/tenant-search.js +127 -0
- package/dist/totp.d.ts +6 -0
- package/dist/totp.js +49 -0
- package/package.json +24 -8
package/dist/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, rename, stat, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { dirname, resolve } from "node:path";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
const SALT = "wilmai::";
|
|
@@ -13,38 +14,76 @@ export function getConfigPath() {
|
|
|
13
14
|
}
|
|
14
15
|
export async function loadConfig() {
|
|
15
16
|
const path = getConfigPath();
|
|
17
|
+
let raw;
|
|
16
18
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
raw = await readFile(path, "utf-8");
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT")
|
|
20
23
|
return { profiles: [] };
|
|
21
|
-
|
|
22
|
-
// Backward-compat: migrate single-student fields if present
|
|
23
|
-
data.profiles = data.profiles.map((p) => {
|
|
24
|
-
if (!p.students && p.studentNumber) {
|
|
25
|
-
p.students = [{ studentNumber: p.studentNumber, name: p.studentName ?? p.studentNumber }];
|
|
26
|
-
}
|
|
27
|
-
if (!p.lastStudentNumber && p.studentNumber) {
|
|
28
|
-
p.lastStudentNumber = p.studentNumber;
|
|
29
|
-
p.lastStudentName = p.studentName ?? p.studentNumber;
|
|
30
|
-
}
|
|
31
|
-
if (!p.tenantName) {
|
|
32
|
-
p.tenantName = p.tenantUrl;
|
|
33
|
-
}
|
|
34
|
-
delete p.studentNumber;
|
|
35
|
-
delete p.studentName;
|
|
36
|
-
return p;
|
|
37
|
-
});
|
|
38
|
-
return data;
|
|
24
|
+
throw err;
|
|
39
25
|
}
|
|
40
|
-
|
|
26
|
+
let data;
|
|
27
|
+
try {
|
|
28
|
+
data = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
// Treating a damaged file as empty would overwrite every saved login on the next save.
|
|
32
|
+
throw new Error(`The WilmAI config at ${path} isn't valid JSON (${err.message}). ` +
|
|
33
|
+
"Fix the file, or remove it with `wilma config clear` and log in again.");
|
|
34
|
+
}
|
|
35
|
+
await tightenPermissions(path);
|
|
36
|
+
if (!data || !Array.isArray(data.profiles)) {
|
|
41
37
|
return { profiles: [] };
|
|
42
38
|
}
|
|
39
|
+
// Backward-compat: migrate single-student fields if present
|
|
40
|
+
data.profiles = data.profiles.map((p) => {
|
|
41
|
+
if (!p.students && p.studentNumber) {
|
|
42
|
+
p.students = [{ studentNumber: p.studentNumber, name: p.studentName ?? p.studentNumber }];
|
|
43
|
+
}
|
|
44
|
+
if (!p.lastStudentNumber && p.studentNumber) {
|
|
45
|
+
p.lastStudentNumber = p.studentNumber;
|
|
46
|
+
p.lastStudentName = p.studentName ?? p.studentNumber;
|
|
47
|
+
}
|
|
48
|
+
if (!p.tenantName) {
|
|
49
|
+
p.tenantName = p.tenantUrl;
|
|
50
|
+
}
|
|
51
|
+
delete p.studentNumber;
|
|
52
|
+
delete p.studentName;
|
|
53
|
+
return p;
|
|
54
|
+
});
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The config holds Wilma passwords (encoded, not encrypted), so it should be
|
|
59
|
+
* readable by this user only. Older versions could leave the folder or file
|
|
60
|
+
* open to other users of the computer; fix that when found.
|
|
61
|
+
*/
|
|
62
|
+
async function tightenPermissions(path) {
|
|
63
|
+
if (process.platform === "win32")
|
|
64
|
+
return;
|
|
65
|
+
const tighten = async (target, mode) => {
|
|
66
|
+
try {
|
|
67
|
+
if (((await stat(target)).mode & 0o077) !== 0)
|
|
68
|
+
await chmod(target, mode);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Missing, or not ours to fix (owned by another user): leave it.
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
await tighten(path, 0o600);
|
|
75
|
+
// Only the default folder: a custom WILMAI_CONFIG_PATH may live in a shared folder.
|
|
76
|
+
if (!process.env.WILMAI_CONFIG_PATH)
|
|
77
|
+
await tighten(dirname(path), 0o700);
|
|
43
78
|
}
|
|
44
79
|
export async function saveConfig(config) {
|
|
45
80
|
const path = getConfigPath();
|
|
46
81
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
47
|
-
await
|
|
82
|
+
await tightenPermissions(path);
|
|
83
|
+
// Write to a temp file and rename, so a concurrent reader never sees a half-written file.
|
|
84
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
85
|
+
await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
86
|
+
await rename(tmp, path);
|
|
48
87
|
}
|
|
49
88
|
export async function clearConfig() {
|
|
50
89
|
const path = getConfigPath();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { WilmaClient, type MfaCallback, type StudentInfo, type WilmaProfile } from "@wilm-ai/wilma-client";
|
|
2
|
+
import { type CliConfig, type StoredProfile } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Environment variables for agents that keep secrets in their own store
|
|
5
|
+
* (cloud VMs, CI-like sandboxes) instead of a config file.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ENV_VARS: {
|
|
8
|
+
readonly tenant: "WILMA_TENANT";
|
|
9
|
+
readonly username: "WILMA_USERNAME";
|
|
10
|
+
readonly password: "WILMA_PASSWORD";
|
|
11
|
+
readonly totpSecret: "WILMA_TOTP_SECRET";
|
|
12
|
+
};
|
|
13
|
+
export interface ActiveAccount {
|
|
14
|
+
profile: WilmaProfile;
|
|
15
|
+
totpSecret?: string;
|
|
16
|
+
/** Present when the account comes from the saved config. */
|
|
17
|
+
stored?: StoredProfile;
|
|
18
|
+
source: "config" | "env";
|
|
19
|
+
}
|
|
20
|
+
export declare class TotpSecretRequiredError extends Error {
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
export declare class TotpSecretInvalidError extends Error {
|
|
24
|
+
constructor(message?: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Answers Wilma's two-step challenge from the saved authenticator key. If
|
|
28
|
+
* Wilma rejects a code and asks again for the same challenge (a code is
|
|
29
|
+
* usually rejected because it was already used — logins less than 30 seconds
|
|
30
|
+
* apart share one), it waits for the next 30-second window and gives a fresh
|
|
31
|
+
* code. No delay unless Wilma actually refuses.
|
|
32
|
+
*/
|
|
33
|
+
export declare function mfaCallbackFor(totpSecret?: string | null, clock?: {
|
|
34
|
+
now: () => number;
|
|
35
|
+
sleep: (ms: number) => Promise<void>;
|
|
36
|
+
}): MfaCallback | undefined;
|
|
37
|
+
/** Environment variables win over the saved config when all three are set. */
|
|
38
|
+
export declare function resolveAccount(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Every Wilma login the family has. Environment variables give exactly one;
|
|
41
|
+
* otherwise all saved logins, the active one first. Families with children on
|
|
42
|
+
* different Wilmas (e.g. a city school and a private school) save one login each.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveAccounts(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Log in once and return the logged-in client with the account's students.
|
|
47
|
+
* Throws TotpSecretRequiredError for MFA accounts without a key.
|
|
48
|
+
*/
|
|
49
|
+
export declare function verifyLoginSession(input: {
|
|
50
|
+
tenantUrl: string;
|
|
51
|
+
username: string;
|
|
52
|
+
password: string;
|
|
53
|
+
totpSecret?: string | null;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
client: WilmaClient;
|
|
56
|
+
students: StudentInfo[];
|
|
57
|
+
}>;
|
|
58
|
+
/** Save a verified login as the active profile, replacing any profile with the same tenant and username. */
|
|
59
|
+
export declare function saveLogin(config: CliConfig, input: {
|
|
60
|
+
tenantUrl: string;
|
|
61
|
+
tenantName?: string | null;
|
|
62
|
+
username: string;
|
|
63
|
+
password: string;
|
|
64
|
+
totpSecret?: string | null;
|
|
65
|
+
students: StudentInfo[];
|
|
66
|
+
}): Promise<StoredProfile>;
|
|
67
|
+
/** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
|
|
68
|
+
export declare function isMfaFailure(err: unknown): boolean;
|
|
69
|
+
export { AuthenticationError } from "@wilm-ai/wilma-client";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { AuthenticationError, MfaRequiredError, WilmaClient, } from "@wilm-ai/wilma-client";
|
|
2
|
+
import { obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
|
|
3
|
+
import { generateTOTP, parseTotpSecret, TOTP_PERIOD_MS, totpCounter } from "./totp.js";
|
|
4
|
+
import { normalizeTenantUrl, resolveTenant } from "./tenant-search.js";
|
|
5
|
+
/**
|
|
6
|
+
* Environment variables for agents that keep secrets in their own store
|
|
7
|
+
* (cloud VMs, CI-like sandboxes) instead of a config file.
|
|
8
|
+
*/
|
|
9
|
+
export const ENV_VARS = {
|
|
10
|
+
tenant: "WILMA_TENANT",
|
|
11
|
+
username: "WILMA_USERNAME",
|
|
12
|
+
password: "WILMA_PASSWORD",
|
|
13
|
+
totpSecret: "WILMA_TOTP_SECRET",
|
|
14
|
+
};
|
|
15
|
+
export class TotpSecretRequiredError extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super("This Wilma account uses two-step verification. Provide the authenticator setup key (base32 key or otpauth:// link) so logins can run unattended.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class TotpSecretInvalidError extends Error {
|
|
21
|
+
constructor(message = "Wilma didn't accept the two-step verification code. Check the authenticator setup key.") {
|
|
22
|
+
super(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Reject obvious mistakes, like a 6-digit one-time code pasted where the setup key belongs. */
|
|
26
|
+
function checkTotpSecret(raw) {
|
|
27
|
+
const value = raw.trim();
|
|
28
|
+
if (/^\d{6,8}$/.test(value)) {
|
|
29
|
+
throw new TotpSecretInvalidError("That looks like a one-time code. Paste the authenticator setup key instead (a long base32 key or an otpauth:// link).");
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const secret = parseTotpSecret(value);
|
|
33
|
+
if (secret.replace(/=+$/, "").length < 16)
|
|
34
|
+
throw new Error("too short");
|
|
35
|
+
generateTOTP(secret);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new TotpSecretInvalidError("That doesn't look like an authenticator setup key (a base32 key or an otpauth:// link).");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Answers Wilma's two-step challenge from the saved authenticator key. If
|
|
43
|
+
* Wilma rejects a code and asks again for the same challenge (a code is
|
|
44
|
+
* usually rejected because it was already used — logins less than 30 seconds
|
|
45
|
+
* apart share one), it waits for the next 30-second window and gives a fresh
|
|
46
|
+
* code. No delay unless Wilma actually refuses.
|
|
47
|
+
*/
|
|
48
|
+
export function mfaCallbackFor(totpSecret, clock = {
|
|
49
|
+
now: Date.now,
|
|
50
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
51
|
+
}) {
|
|
52
|
+
if (!totpSecret)
|
|
53
|
+
return undefined;
|
|
54
|
+
const secret = parseTotpSecret(totpSecret);
|
|
55
|
+
const usedFor = new Map(); // challenge (formkey) -> time step of the code given
|
|
56
|
+
return async (formkey) => {
|
|
57
|
+
let counter = totpCounter(clock.now());
|
|
58
|
+
const previous = usedFor.get(formkey);
|
|
59
|
+
if (previous !== undefined && previous >= counter) {
|
|
60
|
+
await clock.sleep((previous + 1) * TOTP_PERIOD_MS - clock.now() + 50);
|
|
61
|
+
counter = previous + 1;
|
|
62
|
+
}
|
|
63
|
+
usedFor.set(formkey, counter);
|
|
64
|
+
return generateTOTP(secret, counter * TOTP_PERIOD_MS);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function hasEnvAccount(env = process.env) {
|
|
68
|
+
return Boolean(env[ENV_VARS.tenant] && env[ENV_VARS.username] && env[ENV_VARS.password]);
|
|
69
|
+
}
|
|
70
|
+
/** Environment variables win over the saved config when all three are set. */
|
|
71
|
+
export async function resolveAccount(config, env = process.env) {
|
|
72
|
+
if (hasEnvAccount(env)) {
|
|
73
|
+
const tenant = await resolveTenant(env[ENV_VARS.tenant]);
|
|
74
|
+
return {
|
|
75
|
+
profile: {
|
|
76
|
+
baseUrl: tenant.url,
|
|
77
|
+
username: env[ENV_VARS.username],
|
|
78
|
+
password: env[ENV_VARS.password],
|
|
79
|
+
},
|
|
80
|
+
totpSecret: env[ENV_VARS.totpSecret] || undefined,
|
|
81
|
+
source: "env",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const stored = config.profiles.find((p) => p.id === config.lastProfileId);
|
|
85
|
+
if (!stored)
|
|
86
|
+
return null;
|
|
87
|
+
const password = revealSecret(stored.passwordObfuscated);
|
|
88
|
+
if (!password) {
|
|
89
|
+
throw new Error("Stored password could not be decoded. Run `wilma login` again.");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
profile: {
|
|
93
|
+
baseUrl: stored.tenantUrl,
|
|
94
|
+
username: stored.username,
|
|
95
|
+
password,
|
|
96
|
+
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
97
|
+
},
|
|
98
|
+
totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
|
|
99
|
+
stored,
|
|
100
|
+
source: "config",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every Wilma login the family has. Environment variables give exactly one;
|
|
105
|
+
* otherwise all saved logins, the active one first. Families with children on
|
|
106
|
+
* different Wilmas (e.g. a city school and a private school) save one login each.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveAccounts(config, env = process.env) {
|
|
109
|
+
if (hasEnvAccount(env)) {
|
|
110
|
+
const account = await resolveAccount(config, env);
|
|
111
|
+
return account ? [account] : [];
|
|
112
|
+
}
|
|
113
|
+
const ordered = [...config.profiles].sort((a, b) => Number(b.id === config.lastProfileId) - Number(a.id === config.lastProfileId));
|
|
114
|
+
const accounts = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const stored of ordered) {
|
|
117
|
+
// One session per Wilma account, even if an older config saved it twice in different case.
|
|
118
|
+
const key = `${normalizeTenantUrl(stored.tenantUrl)}|${stored.username.toLowerCase()}`;
|
|
119
|
+
if (seen.has(key))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
const password = revealSecret(stored.passwordObfuscated);
|
|
123
|
+
if (!password)
|
|
124
|
+
continue;
|
|
125
|
+
accounts.push({
|
|
126
|
+
profile: {
|
|
127
|
+
baseUrl: stored.tenantUrl,
|
|
128
|
+
username: stored.username,
|
|
129
|
+
password,
|
|
130
|
+
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
131
|
+
},
|
|
132
|
+
totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
|
|
133
|
+
stored,
|
|
134
|
+
source: "config",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return accounts;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Log in once and return the logged-in client with the account's students.
|
|
141
|
+
* Throws TotpSecretRequiredError for MFA accounts without a key.
|
|
142
|
+
*/
|
|
143
|
+
export async function verifyLoginSession(input) {
|
|
144
|
+
if (input.totpSecret)
|
|
145
|
+
checkTotpSecret(input.totpSecret);
|
|
146
|
+
try {
|
|
147
|
+
const client = await WilmaClient.login({ baseUrl: normalizeTenantUrl(input.tenantUrl), username: input.username, password: input.password, studentNumber: null }, mfaCallbackFor(input.totpSecret));
|
|
148
|
+
return { client, students: await client.students() };
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof MfaRequiredError)
|
|
152
|
+
throw new TotpSecretRequiredError();
|
|
153
|
+
if (isMfaFailure(err))
|
|
154
|
+
throw new TotpSecretInvalidError();
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Save a verified login as the active profile, replacing any profile with the same tenant and username. */
|
|
159
|
+
export async function saveLogin(config, input) {
|
|
160
|
+
const tenantUrl = normalizeTenantUrl(input.tenantUrl);
|
|
161
|
+
// Wilma usernames are case-insensitive: "Matti.M" and "matti.m" are one account
|
|
162
|
+
// (saving it twice would make two sessions that keep cancelling each other).
|
|
163
|
+
const sameAccount = (p) => normalizeTenantUrl(p.tenantUrl) === tenantUrl && p.username.toLowerCase() === input.username.toLowerCase();
|
|
164
|
+
const previous = config.profiles.find(sameAccount);
|
|
165
|
+
const id = previous?.id ?? `${tenantUrl}|${input.username}`;
|
|
166
|
+
const keepLast = previous?.lastStudentNumber
|
|
167
|
+
? input.students.find((s) => s.studentNumber === previous.lastStudentNumber)
|
|
168
|
+
: undefined;
|
|
169
|
+
const lastStudent = keepLast ?? input.students[0];
|
|
170
|
+
const stored = {
|
|
171
|
+
id,
|
|
172
|
+
tenantUrl,
|
|
173
|
+
tenantName: input.tenantName ?? previous?.tenantName ?? tenantUrl,
|
|
174
|
+
username: input.username,
|
|
175
|
+
passwordObfuscated: obfuscateSecret(input.password),
|
|
176
|
+
totpSecretObfuscated: input.totpSecret ? obfuscateSecret(input.totpSecret.trim()) : previous?.totpSecretObfuscated ?? null,
|
|
177
|
+
students: input.students.map((s) => ({ studentNumber: s.studentNumber, name: s.name })),
|
|
178
|
+
lastStudentNumber: lastStudent?.studentNumber ?? null,
|
|
179
|
+
lastStudentName: lastStudent?.name ?? null,
|
|
180
|
+
lastUsedAt: new Date().toISOString(),
|
|
181
|
+
};
|
|
182
|
+
// Logging in again keeps the login's place in the list (`wilma accounts` numbers stay put).
|
|
183
|
+
const at = config.profiles.findIndex(sameAccount);
|
|
184
|
+
const others = config.profiles.filter((p) => !sameAccount(p));
|
|
185
|
+
config.profiles = at === -1 ? [...others, stored] : [...others.slice(0, at), stored, ...others.slice(at)];
|
|
186
|
+
config.lastProfileId = id;
|
|
187
|
+
await saveConfig(config);
|
|
188
|
+
return stored;
|
|
189
|
+
}
|
|
190
|
+
/** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
|
|
191
|
+
export function isMfaFailure(err) {
|
|
192
|
+
return err instanceof AuthenticationError && /^MFA verification failed/.test(err.message);
|
|
193
|
+
}
|
|
194
|
+
// Re-exported so other hosts use the same client classes the CLI does.
|
|
195
|
+
export { AuthenticationError } from "@wilm-ai/wilma-client";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NewsResource } from "@wilm-ai/wilma-client";
|
|
2
|
+
export declare function normalizeResourceId(raw: string | undefined): string | undefined;
|
|
3
|
+
export declare function fileNameFromResponse(resource: NewsResource, contentDisposition: string | null, contentType: string | null): string;
|
|
4
|
+
/**
|
|
5
|
+
* A file name that is safe to create in the download folder on any OS: no
|
|
6
|
+
* path separators or reserved characters, no control or text-direction
|
|
7
|
+
* characters (which can disguise an extension), no leading dot (hidden files,
|
|
8
|
+
* ".npmrc"-style config), and none of Windows' reserved device names.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sanitizeFileName(value: string): string;
|
|
11
|
+
export declare function createUniqueDownloadFile(directory: string, preferredName: string): Promise<{
|
|
12
|
+
path: string;
|
|
13
|
+
handle: import("fs/promises").FileHandle;
|
|
14
|
+
}>;
|
|
15
|
+
/** Read a fetched resource into memory, enforcing the same 50 MB cap as CLI downloads. */
|
|
16
|
+
export declare function readResponseCapped(response: {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
status: number;
|
|
19
|
+
headers: {
|
|
20
|
+
get(name: string): string | null;
|
|
21
|
+
};
|
|
22
|
+
body: ReadableStream<Uint8Array> | null;
|
|
23
|
+
}): Promise<Buffer>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
const MAX_NEWS_RESOURCE_BYTES = 50 * 1024 * 1024;
|
|
4
|
+
export function normalizeResourceId(raw) {
|
|
5
|
+
if (!raw)
|
|
6
|
+
return undefined;
|
|
7
|
+
return /^\d+$/.test(raw) ? `resource-${raw}` : raw;
|
|
8
|
+
}
|
|
9
|
+
export function fileNameFromResponse(resource, contentDisposition, contentType) {
|
|
10
|
+
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition ?? "")?.[1];
|
|
11
|
+
const quoted = /filename="([^"]+)"/i.exec(contentDisposition ?? "")?.[1];
|
|
12
|
+
const plain = /filename=([^;]+)/i.exec(contentDisposition ?? "")?.[1]?.trim();
|
|
13
|
+
let name = encoded ? decodeURIComponentSafely(encoded) : quoted ?? plain ?? resource.fileName ?? resource.label;
|
|
14
|
+
name = sanitizeFileName(name) || "wilma-resource";
|
|
15
|
+
if (!/\.[A-Za-z0-9]{1,8}$/.test(name)) {
|
|
16
|
+
const extension = extensionForContentType(contentType);
|
|
17
|
+
if (extension)
|
|
18
|
+
name += extension;
|
|
19
|
+
}
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A file name that is safe to create in the download folder on any OS: no
|
|
24
|
+
* path separators or reserved characters, no control or text-direction
|
|
25
|
+
* characters (which can disguise an extension), no leading dot (hidden files,
|
|
26
|
+
* ".npmrc"-style config), and none of Windows' reserved device names.
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeFileName(value) {
|
|
29
|
+
let name = value
|
|
30
|
+
.replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "")
|
|
31
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f-\u009f]/g, "_");
|
|
32
|
+
// Trim spaces and dots together until stable: " .npmrc" -> "npmrc".
|
|
33
|
+
name = name.replace(/^[\s.]+|[\s.]+$/g, "");
|
|
34
|
+
if (/^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(name))
|
|
35
|
+
name = `_${name}`;
|
|
36
|
+
return name.slice(0, 180).replace(/[\s.]+$/g, "");
|
|
37
|
+
}
|
|
38
|
+
function extensionForContentType(contentType) {
|
|
39
|
+
const extensions = {
|
|
40
|
+
"application/pdf": ".pdf",
|
|
41
|
+
"application/zip": ".zip",
|
|
42
|
+
"image/jpeg": ".jpg",
|
|
43
|
+
"image/png": ".png",
|
|
44
|
+
"text/plain": ".txt",
|
|
45
|
+
"text/csv": ".csv",
|
|
46
|
+
};
|
|
47
|
+
return contentType ? extensions[contentType] ?? "" : "";
|
|
48
|
+
}
|
|
49
|
+
function decodeURIComponentSafely(value) {
|
|
50
|
+
try {
|
|
51
|
+
return decodeURIComponent(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function createUniqueDownloadFile(directory, preferredName) {
|
|
58
|
+
const dot = preferredName.lastIndexOf(".");
|
|
59
|
+
const hasExtension = dot > 0;
|
|
60
|
+
const stem = hasExtension ? preferredName.slice(0, dot) : preferredName;
|
|
61
|
+
const extension = hasExtension ? preferredName.slice(dot) : "";
|
|
62
|
+
for (let index = 0; index < 1000; index += 1) {
|
|
63
|
+
const candidate = index === 0 ? preferredName : `${stem}-${index}${extension}`;
|
|
64
|
+
const path = resolve(directory, candidate);
|
|
65
|
+
try {
|
|
66
|
+
const handle = await open(path, "wx", 0o600);
|
|
67
|
+
return { path, handle };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST") {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error("Could not choose a unique output filename");
|
|
76
|
+
}
|
|
77
|
+
/** Read a fetched resource into memory, enforcing the same 50 MB cap as CLI downloads. */
|
|
78
|
+
export async function readResponseCapped(response) {
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
await response.body?.cancel();
|
|
81
|
+
throw new Error(`News resource download failed with HTTP ${response.status}`);
|
|
82
|
+
}
|
|
83
|
+
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
84
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_NEWS_RESOURCE_BYTES) {
|
|
85
|
+
await response.body?.cancel();
|
|
86
|
+
throw new Error("News resource exceeds the 50 MB download limit");
|
|
87
|
+
}
|
|
88
|
+
if (!response.body)
|
|
89
|
+
throw new Error("News resource response had no body");
|
|
90
|
+
const reader = response.body.getReader();
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
while (true) {
|
|
94
|
+
const { done, value } = await reader.read();
|
|
95
|
+
if (done)
|
|
96
|
+
break;
|
|
97
|
+
size += value.byteLength;
|
|
98
|
+
if (size > MAX_NEWS_RESOURCE_BYTES) {
|
|
99
|
+
await reader.cancel();
|
|
100
|
+
throw new Error("News resource exceeds the 50 MB download limit");
|
|
101
|
+
}
|
|
102
|
+
chunks.push(value);
|
|
103
|
+
}
|
|
104
|
+
return Buffer.concat(chunks);
|
|
105
|
+
}
|