pi-unsloth-webtools 0.1.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/web-access.ts ADDED
@@ -0,0 +1,375 @@
1
+ import { domainToASCII } from "node:url";
2
+
3
+ const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
4
+ const MAX_DOMAINS_PER_LIST = 100;
5
+ const MAX_CACHEABLE_DOMAIN_LEN = 253;
6
+ const SITE_FILTER_LIMIT = 8;
7
+ const DOTTED_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
8
+ const PORT_RE = /^[0-9]{1,5}$/;
9
+
10
+ export interface WebsitePolicy {
11
+ allowedDomains: string[];
12
+ blockedDomains: string[];
13
+ }
14
+
15
+ export function normalizeDomain(value: unknown): string {
16
+ const domain = String(value ?? "").trim().toLowerCase();
17
+ if (!domain) throw new Error("Website domains cannot be empty");
18
+ if (
19
+ [...domain].some((char) => char.charCodeAt(0) < 32) ||
20
+ ["\\", "/", "@", "?", "#"].some((char) => domain.includes(char))
21
+ ) {
22
+ throw new Error(`Invalid website domain: ${String(value)}`);
23
+ }
24
+ const bracketed = domain.startsWith("[") && domain.endsWith("]");
25
+ if (domain.startsWith("[") !== domain.endsWith("]")) {
26
+ throw new Error(`Invalid website domain: ${String(value)}`);
27
+ }
28
+ const stripped = (bracketed ? domain.slice(1, -1) : domain).replace(/\.+$/, "");
29
+ if (/^[0-9a-fA-F:.]+$/.test(stripped) && stripped.includes(":")) {
30
+ try {
31
+ return compressIpv6(stripped);
32
+ } catch {
33
+ throw new Error(`Invalid website domain: ${String(value)}`);
34
+ }
35
+ }
36
+ if (stripped.includes(":")) {
37
+ throw new Error("Website limits must contain domains without schemes or ports");
38
+ }
39
+ const numericParts = stripped.split(".");
40
+ if (
41
+ numericParts.length <= 4 &&
42
+ numericParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/.test(part))
43
+ ) {
44
+ throw new Error("Non-canonical numeric IP hostnames are not allowed");
45
+ }
46
+ let asciiDomain: string;
47
+ try {
48
+ asciiDomain = domainToASCII(stripped).toLowerCase();
49
+ } catch {
50
+ throw new Error(`Invalid website domain: ${String(value)}`);
51
+ }
52
+ if (
53
+ asciiDomain.length > 253 ||
54
+ !asciiDomain.split(".").every((label) => DOMAIN_LABEL_RE.test(label))
55
+ ) {
56
+ throw new Error(`Invalid website domain: ${String(value)}`);
57
+ }
58
+ return asciiDomain;
59
+ }
60
+
61
+ function compressIpv6(ip: string): string {
62
+ const segments = ip.toLowerCase().split("::");
63
+ if (segments.length > 2) throw new Error("invalid ipv6");
64
+ const left = segments[0] ? segments[0].split(":") : [];
65
+ const right = segments.length === 2 && segments[1] ? segments[1].split(":") : [];
66
+ for (const group of [...left, ...right]) {
67
+ if (!/^[0-9a-f]{1,4}$/.test(group)) throw new Error("invalid ipv6");
68
+ }
69
+ if (segments.length === 2) {
70
+ const fill = 8 - left.length - right.length;
71
+ if (fill < 1) throw new Error("invalid ipv6");
72
+ const full = [...left, ...Array(fill).fill("0"), ...right];
73
+ return compressGroups(full);
74
+ }
75
+ if (left.length !== 8) throw new Error("invalid ipv6");
76
+ return compressGroups(left);
77
+ }
78
+
79
+ function compressGroups(groups: string[]): string {
80
+ let bestStart = -1;
81
+ let bestLen = 0;
82
+ let runStart = -1;
83
+ for (let i = 0; i <= groups.length; i++) {
84
+ if (i < groups.length && groups[i] === "0") {
85
+ if (runStart === -1) runStart = i;
86
+ } else if (runStart !== -1) {
87
+ const runLen = i - runStart;
88
+ if (runLen > bestLen) {
89
+ bestStart = runStart;
90
+ bestLen = runLen;
91
+ }
92
+ runStart = -1;
93
+ }
94
+ }
95
+ if (bestLen < 2) return groups.map((g) => g.replace(/^0+(?=[0-9a-f])/, "")).join(":");
96
+ const head = groups.slice(0, bestStart).map((g) => g.replace(/^0+(?=[0-9a-f])/, ""));
97
+ const tail = groups.slice(bestStart + bestLen).map((g) => g.replace(/^0+(?=[0-9a-f])/, ""));
98
+ return [...head, "", ...tail].join(":");
99
+ }
100
+
101
+ export function normalizeWebsitePolicy(value: unknown): WebsitePolicy {
102
+ if (value === null || value === undefined) {
103
+ return { allowedDomains: [], blockedDomains: [] };
104
+ }
105
+ if (typeof value !== "object" || Array.isArray(value)) {
106
+ throw new Error("websitePolicy must be an object");
107
+ }
108
+ const raw = value as Record<string, unknown>;
109
+ const unknown = Object.keys(raw).filter(
110
+ (key) => key !== "allowedDomains" && key !== "blockedDomains",
111
+ );
112
+ if (unknown.length) {
113
+ throw new Error(`Unsupported websitePolicy fields: ${unknown.sort().join(", ")}`);
114
+ }
115
+ const normalized: WebsitePolicy = { allowedDomains: [], blockedDomains: [] };
116
+ for (const key of ["allowedDomains", "blockedDomains"] as const) {
117
+ const rawDomains = raw[key];
118
+ if (!Array.isArray(rawDomains)) throw new Error(`${key} must be a list`);
119
+ if (rawDomains.length > MAX_DOMAINS_PER_LIST) {
120
+ throw new Error(`${key} supports at most ${MAX_DOMAINS_PER_LIST} domains`);
121
+ }
122
+ const domains: string[] = [];
123
+ for (const rawDomain of rawDomains) {
124
+ if (typeof rawDomain !== "string" || rawDomain.length > MAX_CACHEABLE_DOMAIN_LEN) {
125
+ throw new Error(`${key} must contain only strings`);
126
+ }
127
+ const domain = normalizeDomain(rawDomain);
128
+ if (!domains.includes(domain)) domains.push(domain);
129
+ }
130
+ normalized[key] = domains;
131
+ }
132
+ return normalized;
133
+ }
134
+
135
+ function matchesDomain(hostname: string, domain: string): boolean {
136
+ return hostname === domain || hostname.endsWith(`.${domain}`);
137
+ }
138
+
139
+ export function hostnameAllowed(hostname: string, policy: WebsitePolicy | null): boolean {
140
+ let host: string;
141
+ let normalized: WebsitePolicy;
142
+ try {
143
+ host = normalizeDomain(hostname);
144
+ normalized = normalizePolicyMaybe(policy);
145
+ } catch {
146
+ return false;
147
+ }
148
+ if (normalized.blockedDomains.some((domain) => matchesDomain(host, domain))) return false;
149
+ const allowed = normalized.allowedDomains;
150
+ return allowed.length === 0 || allowed.some((domain) => matchesDomain(host, domain));
151
+ }
152
+
153
+ function normalizePolicyObject(policy: WebsitePolicy): WebsitePolicy {
154
+ const normalized: WebsitePolicy = { allowedDomains: [], blockedDomains: [] };
155
+ for (const key of ["allowedDomains", "blockedDomains"] as const) {
156
+ const domains: string[] = [];
157
+ for (const rawDomain of policy[key]) {
158
+ const domain = normalizeDomain(rawDomain);
159
+ if (!domains.includes(domain)) domains.push(domain);
160
+ }
161
+ normalized[key] = domains;
162
+ }
163
+ return normalized;
164
+ }
165
+
166
+ function normalizePolicyMaybe(policy: WebsitePolicy | null | undefined): WebsitePolicy {
167
+ if (!policy) return { allowedDomains: [], blockedDomains: [] };
168
+ return normalizePolicyObject(policy);
169
+ }
170
+
171
+ export function checkUrlAccess(
172
+ url: string,
173
+ policy: WebsitePolicy | null,
174
+ ): [boolean, string, string] {
175
+ if (typeof url !== "string" || !url.trim()) {
176
+ return [false, "Blocked: URL is empty.", ""];
177
+ }
178
+ const candidate = url.trim();
179
+ if (
180
+ Array.from(candidate).some((char) => /\s/.test(char) || char.charCodeAt(0) < 32) ||
181
+ candidate.includes("\\")
182
+ ) {
183
+ return [false, "Blocked: URL contains invalid characters.", ""];
184
+ }
185
+ let parsed: URL;
186
+ try {
187
+ parsed = new URL(candidate);
188
+ } catch {
189
+ return [false, "Blocked: URL has an invalid hostname or port.", ""];
190
+ }
191
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
192
+ if (scheme !== "http" && scheme !== "https") {
193
+ return [false, "Blocked: only http/https URLs are allowed.", ""];
194
+ }
195
+ if (parsed.username || parsed.password || parsed.hostname.includes("%")) {
196
+ return [false, "Blocked: URL credentials or encoded hostnames are not allowed.", ""];
197
+ }
198
+ if (!parsed.hostname) {
199
+ return [false, "Blocked: URL has an invalid hostname or port.", ""];
200
+ }
201
+ try {
202
+ if (parsed.port && !(PORT_RE.test(parsed.port) && Number(parsed.port) >= 1 && Number(parsed.port) <= 65535)) {
203
+ return [false, "Blocked: URL has an invalid hostname or port.", ""];
204
+ }
205
+ } catch {
206
+ return [false, "Blocked: URL has an invalid hostname or port.", ""];
207
+ }
208
+ let hostname: string;
209
+ try {
210
+ hostname = normalizeDomain(parsed.hostname);
211
+ } catch {
212
+ return [false, "Blocked: URL has an invalid hostname or port.", ""];
213
+ }
214
+ if (!hostnameAllowed(hostname, policy)) {
215
+ return [false, `Blocked: website access policy disallows ${hostname}.`, hostname];
216
+ }
217
+ return [true, "", hostname];
218
+ }
219
+
220
+ export function websitePolicyPrompt(policy: WebsitePolicy | null): string {
221
+ const normalized = normalizeWebsitePolicy(policy);
222
+ const allowed = normalized.allowedDomains;
223
+ const blocked = normalized.blockedDomains;
224
+ if (!allowed.length && !blocked.length) return "";
225
+ const lines = ["Website access limits are enforced by the application."];
226
+ if (allowed.length) {
227
+ lines.push(
228
+ "Only search or fetch these domains and their subdomains: " +
229
+ allowed.join(", ") +
230
+ ". Do not propose, cite, or attempt any other website.",
231
+ );
232
+ }
233
+ if (blocked.length) {
234
+ lines.push(
235
+ "Never search or fetch these domains or their subdomains: " + blocked.join(", ") + ".",
236
+ );
237
+ }
238
+ lines.push("Blocked search results are unavailable; do not try to work around these limits.");
239
+ return lines.join("\n");
240
+ }
241
+
242
+ export function scopeSearchQuery(query: string, policy: WebsitePolicy | null): string {
243
+ const allowed = normalizeWebsitePolicy(policy).allowedDomains;
244
+ if (!allowed.length) return query;
245
+ let window = allowed;
246
+ if (allowed.length > SITE_FILTER_LIMIT) {
247
+ let hash = 0;
248
+ for (const char of query) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
249
+ const offset = hash % allowed.length;
250
+ window = [...allowed, ...allowed].slice(offset, offset + SITE_FILTER_LIMIT);
251
+ }
252
+ const siteFilter = window.map((domain) => `site:${domain}`).join(" OR ");
253
+ return `${query} (${siteFilter})`;
254
+ }
255
+
256
+ export function normalizeUrlScheme(url: string): string {
257
+ url = url.trim();
258
+ const schemeMatch = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url);
259
+ if (schemeMatch) {
260
+ const scheme = schemeMatch[1];
261
+ const afterScheme = url.slice(schemeMatch[0].length);
262
+ const hasNetloc = afterScheme.startsWith("//");
263
+ if (hasNetloc || !DOTTED_HOST_RE.test(scheme)) return url;
264
+ return rewriteBareHost(url, url.split(/[/?#]/, 1)[0]);
265
+ }
266
+ if (url.startsWith("//")) {
267
+ const rest = url.slice(2);
268
+ return rewriteBareHost("//" + rest, rest.split(/[/?#]/, 1)[0]);
269
+ }
270
+ if (url.startsWith("/")) return url;
271
+ return rewriteBareHost(url, url.split(/[/?#]/, 1)[0]);
272
+ }
273
+
274
+ function rewriteBareHost(url: string, authority: string): string {
275
+ const host = authority.split(":", 1)[0];
276
+ if (!DOTTED_HOST_RE.test(host)) return url;
277
+ const colon = authority.indexOf(":");
278
+ const port = colon === -1 ? "" : authority.slice(colon + 1);
279
+ if (port && !(PORT_RE.test(port) && Number(port) >= 1 && Number(port) <= 65535)) return url;
280
+ return "https://" + url.replace(/^\/\//, "");
281
+ }
282
+
283
+ const GITHUB_NON_OWNER_SEGMENTS = new Set([
284
+ "about",
285
+ "apps",
286
+ "codespaces",
287
+ "collections",
288
+ "contact",
289
+ "customer-stories",
290
+ "dashboard",
291
+ "discussions",
292
+ "enterprise",
293
+ "explore",
294
+ "features",
295
+ "issues",
296
+ "join",
297
+ "login",
298
+ "marketplace",
299
+ "new",
300
+ "notifications",
301
+ "organizations",
302
+ "orgs",
303
+ "pricing",
304
+ "pulls",
305
+ "search",
306
+ "security",
307
+ "settings",
308
+ "signup",
309
+ "site",
310
+ "sponsors",
311
+ "team",
312
+ "topics",
313
+ "trending",
314
+ ]);
315
+
316
+ const GITHUB_NAME_RE = /^[A-Za-z0-9_.\-]{1,100}$/;
317
+
318
+ export function githubRepoReadmeApiUrl(url: string): string | null {
319
+ let parsed: URL;
320
+ try {
321
+ parsed = new URL(url);
322
+ } catch {
323
+ return null;
324
+ }
325
+ const host = (parsed.hostname ?? "").toLowerCase();
326
+ if (host !== "github.com" && host !== "www.github.com") return null;
327
+ const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
328
+ if (parts.length !== 2) return null;
329
+ const [owner, repo] = parts;
330
+ if (GITHUB_NON_OWNER_SEGMENTS.has(owner.toLowerCase())) return null;
331
+ const cleanRepo = repo.endsWith(".git") ? repo.slice(0, -4) : repo;
332
+ if (!GITHUB_NAME_RE.test(owner) || !GITHUB_NAME_RE.test(cleanRepo)) return null;
333
+ return `https://api.github.com/repos/${owner}/${cleanRepo}/readme`;
334
+ }
335
+
336
+ function ipv4Octets(ip: string): number[] | null {
337
+ const parts = ip.split(".");
338
+ if (parts.length !== 4) return null;
339
+ const octets = parts.map((part) => Number(part));
340
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
341
+ return octets;
342
+ }
343
+
344
+ export function isPublicIp(ip: string): boolean {
345
+ if (ip.includes(".")) {
346
+ const o = ipv4Octets(ip);
347
+ if (!o) return false;
348
+ if (o[0] === 0) return false;
349
+ if (o[0] === 10) return false;
350
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return false;
351
+ if (o[0] === 127) return false;
352
+ if (o[0] === 169 && o[1] === 254) return false;
353
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return false;
354
+ if (o[0] === 192 && o[1] === 0 && o[2] === 0) return false;
355
+ if (o[0] === 192 && o[1] === 0 && o[2] === 2) return false;
356
+ if (o[0] === 192 && o[1] === 168) return false;
357
+ if (o[0] === 198 && (o[1] === 18 || o[1] === 19)) return false;
358
+ if (o[0] === 198 && o[1] === 51 && o[2] === 100) return false;
359
+ if (o[0] === 203 && o[1] === 0 && o[2] === 113) return false;
360
+ if (o[0] >= 224) return false;
361
+ return true;
362
+ }
363
+ const lower = ip.toLowerCase();
364
+ if (lower === "::" || lower === "::1") return false;
365
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return false;
366
+ if (/^fe[89ab][0-9a-f]:/.test(lower)) return false;
367
+ if (lower.startsWith("ff")) return false;
368
+ if (lower.startsWith("2001:db8")) return false;
369
+ if (lower.startsWith("64:ff9b:")) return false;
370
+ if (lower.startsWith("2001:10:")) return false;
371
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
372
+ if (mapped) return isPublicIp(mapped[1]);
373
+ if (lower.startsWith("::ffff:")) return false;
374
+ return true;
375
+ }