distopia 0.3.0 → 0.5.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/dist/index.cjs CHANGED
@@ -2,8 +2,10 @@
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  let oxfmt = require("oxfmt");
4
4
  let oxlint = require("oxlint");
5
+ let node_dns = require("node:dns");
6
+ let zod = require("zod");
5
7
  //#region package.json
6
- var version = "0.3.0";
8
+ var version = "0.5.0";
7
9
  //#endregion
8
10
  //#region ../template/src/oxfmt.ts
9
11
  const config = (0, oxfmt.defineConfig)({
@@ -30,9 +32,207 @@ const config$1 = (0, oxlint.defineConfig)({
30
32
  ignorePatterns: ["*.auto.ts", "dist/**"]
31
33
  });
32
34
  //#endregion
35
+ //#region ../../src/infrastructure/http/src/Error/BodySizeError.ts
36
+ var BodySizeError = class extends Error {};
37
+ //#endregion
38
+ //#region ../../src/infrastructure/http/src/Error/HeaderError.ts
39
+ var HeaderError = class extends Error {};
40
+ //#endregion
41
+ //#region ../../src/infrastructure/http/src/Error/InvalidDomainError.ts
42
+ var InvalidDomainError = class extends Error {};
43
+ //#endregion
44
+ //#region ../../src/infrastructure/http/src/Error/LocalAddressError.ts
45
+ var LocalAddressError = class extends Error {};
46
+ //#endregion
47
+ //#region ../../src/infrastructure/http/src/Error/RedirectError.ts
48
+ var RedirectError = class extends Error {};
49
+ //#endregion
50
+ //#region ../../src/infrastructure/http/src/url.ts
51
+ function isLocalIPv4(host) {
52
+ const parts = host.split(".");
53
+ if (parts.length !== 4) return false;
54
+ const [aStr, bStr, cStr, dStr] = parts;
55
+ const a = Number(aStr);
56
+ const b = Number(bStr);
57
+ const c = Number(cStr);
58
+ const d = Number(dStr);
59
+ for (const v of [
60
+ a,
61
+ b,
62
+ c,
63
+ d
64
+ ]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
65
+ return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
66
+ }
67
+ function isLocalIPv6(addr) {
68
+ const h = addr.toLowerCase();
69
+ if (h === "::1" || h === "::") return true;
70
+ if (/^fe[89ab]/.test(h)) return true;
71
+ if (/^f[cd]/.test(h)) return true;
72
+ const rest = h.match(/^::ffff:(.+)$/)?.[1];
73
+ if (rest !== void 0) {
74
+ if (rest.includes(".")) return isLocalIPv4(rest);
75
+ const segs = rest.split(":");
76
+ if (segs.length === 2) {
77
+ const hi = parseInt(segs[0] ?? "", 16);
78
+ const lo = parseInt(segs[1] ?? "", 16);
79
+ if (Number.isInteger(hi) && Number.isInteger(lo)) return isLocalIPv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+ async function isLocalUrl(url) {
85
+ let s = url.replace(/\\/g, "/");
86
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "http://" + s;
87
+ const hostname = URL.parse(s)?.hostname.toLowerCase();
88
+ if (hostname === void 0) return false;
89
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
90
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return isLocalIPv6(hostname.slice(1, -1));
91
+ if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
92
+ return await isLocalHostname(hostname);
93
+ }
94
+ async function isHttpProtocol(url) {
95
+ const protocol = URL.parse(url.toString())?.protocol;
96
+ return protocol === "http:" || protocol === "https:";
97
+ }
98
+ //#endregion
99
+ //#region ../../src/infrastructure/http/src/dns.ts
100
+ async function isLocalHostname(hostname) {
101
+ const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
102
+ return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
103
+ }
104
+ async function resolveHostnameToSafeIp(hostname) {
105
+ const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
106
+ if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
107
+ if (v4[0] !== void 0) return v4[0];
108
+ if (v6[0] !== void 0) return v6[0];
109
+ return null;
110
+ }
111
+ //#endregion
112
+ //#region ../../src/infrastructure/http/src/redirect.ts
113
+ const DEFAULT_MAX_REDIRECT = 10;
114
+ //#endregion
115
+ //#region ../../src/infrastructure/http/src/size.ts
116
+ const MAX_BYTES = 1024 * 1024;
117
+ async function isValidSize(response) {
118
+ const body = response.body;
119
+ if (body === null) return true;
120
+ const reader = body.getReader();
121
+ let received = 0;
122
+ let isOk = true;
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+ received += value.byteLength;
127
+ if (received > 1048576) {
128
+ await reader.cancel();
129
+ isOk = false;
130
+ }
131
+ }
132
+ return isOk;
133
+ }
134
+ //#endregion
135
+ //#region ../../src/infrastructure/http/src/timeout.ts
136
+ const DEFAULT_TIMEOUT = 10 * 1e3;
137
+ const DISCORD_TIMEOUT = 30 * 1e3;
138
+ //#endregion
33
139
  //#region ../../src/infrastructure/http/src/safefetch.ts
34
- async function safeFetch(input, init) {
35
- return await fetch(input, init);
140
+ const ALLOW_DISCORD_DOMAINS = [
141
+ "discord.com",
142
+ "discordapp.com",
143
+ "discord.gg"
144
+ ];
145
+ async function resolveToPinnedUrl(url, init) {
146
+ const { hostname, host, protocol } = new URL(url);
147
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
148
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
149
+ if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
150
+ return {
151
+ url,
152
+ init
153
+ };
154
+ }
155
+ if (/^\d/.test(hostname)) {
156
+ if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
157
+ return {
158
+ url,
159
+ init
160
+ };
161
+ }
162
+ const resolvedIp = await resolveHostnameToSafeIp(hostname);
163
+ if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
164
+ const pinnedUrlObj = new URL(url);
165
+ pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
166
+ const headers = new Headers(init.headers);
167
+ headers.set("Host", host);
168
+ const pinnedInit = {
169
+ ...init,
170
+ headers,
171
+ ...protocol === "https:" ? { tls: { serverName: hostname } } : {}
172
+ };
173
+ return {
174
+ url: pinnedUrlObj.href,
175
+ init: pinnedInit
176
+ };
177
+ }
178
+ async function safeFetchForDiscord(input, init) {
179
+ const hostname = new URL(input).hostname;
180
+ if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
181
+ const pinned = await resolveToPinnedUrl(input, init ?? {});
182
+ if (pinned instanceof LocalAddressError) return pinned;
183
+ return await fetch(pinned.url, {
184
+ ...pinned.init,
185
+ signal: AbortSignal.timeout(DISCORD_TIMEOUT),
186
+ redirect: "manual"
187
+ });
188
+ }
189
+ async function safeFetch(input, init, options) {
190
+ let reqUrl = input;
191
+ let currentInit = init ?? {};
192
+ let redirectCount = 0;
193
+ let response;
194
+ while (true) {
195
+ const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
196
+ if (pinned instanceof LocalAddressError) return pinned;
197
+ response = await fetch(pinned.url, {
198
+ ...pinned.init,
199
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
200
+ redirect: "manual"
201
+ });
202
+ if (!await isValidSize(response.clone())) return new BodySizeError("Body size Error");
203
+ const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
204
+ const location = response.headers.get("location");
205
+ if (isRedirect) {
206
+ if (location === null) return new HeaderError("location is not found.");
207
+ let url;
208
+ try {
209
+ url = new URL(location, reqUrl);
210
+ } catch {
211
+ return new HeaderError(`${location} is invalid.`);
212
+ }
213
+ if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
214
+ if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
215
+ if (new URL(reqUrl).origin !== url.origin) {
216
+ const headers = new Headers(currentInit.headers);
217
+ headers.delete("authorization");
218
+ headers.delete("cookie");
219
+ currentInit = {
220
+ ...currentInit,
221
+ headers
222
+ };
223
+ }
224
+ reqUrl = url.href;
225
+ redirectCount += 1;
226
+ } else {
227
+ const finalUrl = reqUrl;
228
+ return new Proxy(response, { get(target, prop) {
229
+ if (prop === "url") return finalUrl;
230
+ const value = Reflect.get(target, prop, target);
231
+ return typeof value === "function" ? value.bind(target) : value;
232
+ } });
233
+ }
234
+ if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
235
+ }
36
236
  }
37
237
  //#endregion
38
238
  //#region ../../src/infrastructure/http/src/safeurl.ts
@@ -45,12 +245,78 @@ function safeUrl(strings, ...values) {
45
245
  }
46
246
  return result;
47
247
  }
248
+ const safeUrlSchema = zod.z.string().refine((url) => {
249
+ const parsed = URL.parse(url);
250
+ return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
251
+ }, { message: "URL must be a valid http or https URL" }).transform((url) => url);
252
+ function validateSafeUrl(url) {
253
+ const result = safeUrlSchema.safeParse(url);
254
+ return result.success ? result.data : null;
255
+ }
256
+ //#endregion
257
+ //#region ../../src/infrastructure/http/src/invite.ts
258
+ const DISCORD_DOMAINS = [
259
+ "discord.com",
260
+ "ptb.discord.com",
261
+ "canary.discord.com"
262
+ ];
263
+ const INVITE_PROTOCOL = [
264
+ "discord:",
265
+ "http:",
266
+ "https:"
267
+ ];
268
+ async function isDiscordInviteLink(url) {
269
+ const parsedUrl = URL.parse(url.toString());
270
+ if (parsedUrl === null) return false;
271
+ return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
272
+ }
273
+ function isUsedCf(res) {
274
+ return res.headers.get("cf-mitigated") === "challenge";
275
+ }
276
+ async function isInviteLink(url) {
277
+ const safeUrl = validateSafeUrl(url);
278
+ if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
279
+ const response = await safeFetch(safeUrl, {
280
+ method: "GET",
281
+ headers: { "User-Agent": "Mozilla/5.0" }
282
+ }, { detectDiscordProtocol: true });
283
+ if (response instanceof Error) return response;
284
+ const resUrl = response.url;
285
+ const location = response.headers.get("location");
286
+ return {
287
+ content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
288
+ isUsedCf: isUsedCf(response)
289
+ };
290
+ }
48
291
  //#endregion
49
292
  //#region src/index.ts
50
293
  const DISTOPIA_VERSION = version;
51
294
  //#endregion
295
+ exports.ALLOW_DISCORD_DOMAINS = ALLOW_DISCORD_DOMAINS;
296
+ exports.BodySizeError = BodySizeError;
297
+ exports.DEFAULT_MAX_REDIRECT = DEFAULT_MAX_REDIRECT;
298
+ exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
299
+ exports.DISCORD_DOMAINS = DISCORD_DOMAINS;
300
+ exports.DISCORD_TIMEOUT = DISCORD_TIMEOUT;
52
301
  exports.DISTOPIA_VERSION = DISTOPIA_VERSION;
302
+ exports.HeaderError = HeaderError;
303
+ exports.INVITE_PROTOCOL = INVITE_PROTOCOL;
304
+ exports.InvalidDomainError = InvalidDomainError;
305
+ exports.LocalAddressError = LocalAddressError;
306
+ exports.MAX_BYTES = MAX_BYTES;
307
+ exports.RedirectError = RedirectError;
308
+ exports.isHttpProtocol = isHttpProtocol;
309
+ exports.isInviteLink = isInviteLink;
310
+ exports.isLocalHostname = isLocalHostname;
311
+ exports.isLocalIPv4 = isLocalIPv4;
312
+ exports.isLocalIPv6 = isLocalIPv6;
313
+ exports.isLocalUrl = isLocalUrl;
314
+ exports.isUsedCf = isUsedCf;
315
+ exports.isValidSize = isValidSize;
53
316
  exports.oxfmtCfg = config;
54
317
  exports.oxlintCfg = config$1;
318
+ exports.resolveHostnameToSafeIp = resolveHostnameToSafeIp;
55
319
  exports.safeFetch = safeFetch;
320
+ exports.safeFetchForDiscord = safeFetchForDiscord;
56
321
  exports.safeUrl = safeUrl;
322
+ exports.validateSafeUrl = validateSafeUrl;
package/dist/index.d.cts CHANGED
@@ -24,17 +24,69 @@ declare const config$1: {
24
24
  ignorePatterns: string[];
25
25
  };
26
26
  //#endregion
27
+ //#region ../../src/infrastructure/http/src/Error/BodySizeError.d.ts
28
+ declare class BodySizeError extends Error {}
29
+ //#endregion
30
+ //#region ../../src/infrastructure/http/src/Error/HeaderError.d.ts
31
+ declare class HeaderError extends Error {}
32
+ //#endregion
33
+ //#region ../../src/infrastructure/http/src/Error/InvalidDomainError.d.ts
34
+ declare class InvalidDomainError extends Error {}
35
+ //#endregion
36
+ //#region ../../src/infrastructure/http/src/Error/LocalAddressError.d.ts
37
+ declare class LocalAddressError extends Error {}
38
+ //#endregion
39
+ //#region ../../src/infrastructure/http/src/Error/RedirectError.d.ts
40
+ declare class RedirectError extends Error {}
41
+ //#endregion
42
+ //#region ../../src/infrastructure/http/src/dns.d.ts
43
+ declare function isLocalHostname(hostname: string): Promise<boolean>;
44
+ declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
45
+ //#endregion
46
+ //#region ../../src/infrastructure/http/src/invite.d.ts
47
+ type IsInviteLink = {
48
+ content: boolean;
49
+ isUsedCf: boolean;
50
+ };
51
+ declare const DISCORD_DOMAINS: string[];
52
+ declare const INVITE_PROTOCOL: string[];
53
+ declare function isUsedCf(res: Response): boolean;
54
+ declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
55
+ //#endregion
56
+ //#region ../../src/infrastructure/http/src/redirect.d.ts
57
+ declare const DEFAULT_MAX_REDIRECT = 10;
58
+ //#endregion
27
59
  //#region ../../src/infrastructure/http/src/safeurl.d.ts
28
60
  type Branded<T, Brand> = T & {
29
61
  readonly __brand: Brand;
30
62
  };
31
63
  type SafeUrl = Branded<string, "distopiaSafeUrl">;
32
64
  declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
65
+ declare function validateSafeUrl(url: string): SafeUrl | null;
33
66
  //#endregion
34
67
  //#region ../../src/infrastructure/http/src/safefetch.d.ts
35
- declare function safeFetch(input: SafeUrl, init?: RequestInit): Promise<Response>;
68
+ type SafeFetchOptions = {
69
+ detectDiscordProtocol?: boolean;
70
+ };
71
+ declare const ALLOW_DISCORD_DOMAINS: string[];
72
+ declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
73
+ declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
74
+ //#endregion
75
+ //#region ../../src/infrastructure/http/src/size.d.ts
76
+ declare const MAX_BYTES: number;
77
+ declare function isValidSize(response: Response): Promise<boolean>;
78
+ //#endregion
79
+ //#region ../../src/infrastructure/http/src/timeout.d.ts
80
+ declare const DEFAULT_TIMEOUT: number;
81
+ declare const DISCORD_TIMEOUT: number;
82
+ //#endregion
83
+ //#region ../../src/infrastructure/http/src/url.d.ts
84
+ declare function isLocalIPv4(host: string): boolean;
85
+ declare function isLocalIPv6(addr: string): boolean;
86
+ declare function isLocalUrl(url: string): Promise<boolean>;
87
+ declare function isHttpProtocol(url: string | URL): Promise<boolean>;
36
88
  //#endregion
37
89
  //#region src/index.d.ts
38
90
  declare const DISTOPIA_VERSION: string;
39
91
  //#endregion
40
- export { type Branded, DISTOPIA_VERSION, type SafeUrl, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeUrl };
92
+ export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
package/dist/index.d.mts CHANGED
@@ -24,17 +24,69 @@ declare const config$1: {
24
24
  ignorePatterns: string[];
25
25
  };
26
26
  //#endregion
27
+ //#region ../../src/infrastructure/http/src/Error/BodySizeError.d.ts
28
+ declare class BodySizeError extends Error {}
29
+ //#endregion
30
+ //#region ../../src/infrastructure/http/src/Error/HeaderError.d.ts
31
+ declare class HeaderError extends Error {}
32
+ //#endregion
33
+ //#region ../../src/infrastructure/http/src/Error/InvalidDomainError.d.ts
34
+ declare class InvalidDomainError extends Error {}
35
+ //#endregion
36
+ //#region ../../src/infrastructure/http/src/Error/LocalAddressError.d.ts
37
+ declare class LocalAddressError extends Error {}
38
+ //#endregion
39
+ //#region ../../src/infrastructure/http/src/Error/RedirectError.d.ts
40
+ declare class RedirectError extends Error {}
41
+ //#endregion
42
+ //#region ../../src/infrastructure/http/src/dns.d.ts
43
+ declare function isLocalHostname(hostname: string): Promise<boolean>;
44
+ declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
45
+ //#endregion
46
+ //#region ../../src/infrastructure/http/src/invite.d.ts
47
+ type IsInviteLink = {
48
+ content: boolean;
49
+ isUsedCf: boolean;
50
+ };
51
+ declare const DISCORD_DOMAINS: string[];
52
+ declare const INVITE_PROTOCOL: string[];
53
+ declare function isUsedCf(res: Response): boolean;
54
+ declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
55
+ //#endregion
56
+ //#region ../../src/infrastructure/http/src/redirect.d.ts
57
+ declare const DEFAULT_MAX_REDIRECT = 10;
58
+ //#endregion
27
59
  //#region ../../src/infrastructure/http/src/safeurl.d.ts
28
60
  type Branded<T, Brand> = T & {
29
61
  readonly __brand: Brand;
30
62
  };
31
63
  type SafeUrl = Branded<string, "distopiaSafeUrl">;
32
64
  declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
65
+ declare function validateSafeUrl(url: string): SafeUrl | null;
33
66
  //#endregion
34
67
  //#region ../../src/infrastructure/http/src/safefetch.d.ts
35
- declare function safeFetch(input: SafeUrl, init?: RequestInit): Promise<Response>;
68
+ type SafeFetchOptions = {
69
+ detectDiscordProtocol?: boolean;
70
+ };
71
+ declare const ALLOW_DISCORD_DOMAINS: string[];
72
+ declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
73
+ declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
74
+ //#endregion
75
+ //#region ../../src/infrastructure/http/src/size.d.ts
76
+ declare const MAX_BYTES: number;
77
+ declare function isValidSize(response: Response): Promise<boolean>;
78
+ //#endregion
79
+ //#region ../../src/infrastructure/http/src/timeout.d.ts
80
+ declare const DEFAULT_TIMEOUT: number;
81
+ declare const DISCORD_TIMEOUT: number;
82
+ //#endregion
83
+ //#region ../../src/infrastructure/http/src/url.d.ts
84
+ declare function isLocalIPv4(host: string): boolean;
85
+ declare function isLocalIPv6(addr: string): boolean;
86
+ declare function isLocalUrl(url: string): Promise<boolean>;
87
+ declare function isHttpProtocol(url: string | URL): Promise<boolean>;
36
88
  //#endregion
37
89
  //#region src/index.d.ts
38
90
  declare const DISTOPIA_VERSION: string;
39
91
  //#endregion
40
- export { type Branded, DISTOPIA_VERSION, type SafeUrl, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeUrl };
92
+ export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  /* @ts-self-types="./index.d.mts" */
2
2
  import { defineConfig } from "oxfmt";
3
3
  import { defineConfig as defineConfig$1 } from "oxlint";
4
+ import { promises } from "node:dns";
5
+ import { z } from "zod";
4
6
  //#region package.json
5
- var version = "0.3.0";
7
+ var version = "0.5.0";
6
8
  //#endregion
7
9
  //#region ../template/src/oxfmt.ts
8
10
  const config = defineConfig({
@@ -29,9 +31,207 @@ const config$1 = defineConfig$1({
29
31
  ignorePatterns: ["*.auto.ts", "dist/**"]
30
32
  });
31
33
  //#endregion
34
+ //#region ../../src/infrastructure/http/src/Error/BodySizeError.ts
35
+ var BodySizeError = class extends Error {};
36
+ //#endregion
37
+ //#region ../../src/infrastructure/http/src/Error/HeaderError.ts
38
+ var HeaderError = class extends Error {};
39
+ //#endregion
40
+ //#region ../../src/infrastructure/http/src/Error/InvalidDomainError.ts
41
+ var InvalidDomainError = class extends Error {};
42
+ //#endregion
43
+ //#region ../../src/infrastructure/http/src/Error/LocalAddressError.ts
44
+ var LocalAddressError = class extends Error {};
45
+ //#endregion
46
+ //#region ../../src/infrastructure/http/src/Error/RedirectError.ts
47
+ var RedirectError = class extends Error {};
48
+ //#endregion
49
+ //#region ../../src/infrastructure/http/src/url.ts
50
+ function isLocalIPv4(host) {
51
+ const parts = host.split(".");
52
+ if (parts.length !== 4) return false;
53
+ const [aStr, bStr, cStr, dStr] = parts;
54
+ const a = Number(aStr);
55
+ const b = Number(bStr);
56
+ const c = Number(cStr);
57
+ const d = Number(dStr);
58
+ for (const v of [
59
+ a,
60
+ b,
61
+ c,
62
+ d
63
+ ]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
64
+ return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
65
+ }
66
+ function isLocalIPv6(addr) {
67
+ const h = addr.toLowerCase();
68
+ if (h === "::1" || h === "::") return true;
69
+ if (/^fe[89ab]/.test(h)) return true;
70
+ if (/^f[cd]/.test(h)) return true;
71
+ const rest = h.match(/^::ffff:(.+)$/)?.[1];
72
+ if (rest !== void 0) {
73
+ if (rest.includes(".")) return isLocalIPv4(rest);
74
+ const segs = rest.split(":");
75
+ if (segs.length === 2) {
76
+ const hi = parseInt(segs[0] ?? "", 16);
77
+ const lo = parseInt(segs[1] ?? "", 16);
78
+ if (Number.isInteger(hi) && Number.isInteger(lo)) return isLocalIPv4(`${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`);
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ async function isLocalUrl(url) {
84
+ let s = url.replace(/\\/g, "/");
85
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = "http://" + s;
86
+ const hostname = URL.parse(s)?.hostname.toLowerCase();
87
+ if (hostname === void 0) return false;
88
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
89
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return isLocalIPv6(hostname.slice(1, -1));
90
+ if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
91
+ return await isLocalHostname(hostname);
92
+ }
93
+ async function isHttpProtocol(url) {
94
+ const protocol = URL.parse(url.toString())?.protocol;
95
+ return protocol === "http:" || protocol === "https:";
96
+ }
97
+ //#endregion
98
+ //#region ../../src/infrastructure/http/src/dns.ts
99
+ async function isLocalHostname(hostname) {
100
+ const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
101
+ return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
102
+ }
103
+ async function resolveHostnameToSafeIp(hostname) {
104
+ const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
105
+ if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
106
+ if (v4[0] !== void 0) return v4[0];
107
+ if (v6[0] !== void 0) return v6[0];
108
+ return null;
109
+ }
110
+ //#endregion
111
+ //#region ../../src/infrastructure/http/src/redirect.ts
112
+ const DEFAULT_MAX_REDIRECT = 10;
113
+ //#endregion
114
+ //#region ../../src/infrastructure/http/src/size.ts
115
+ const MAX_BYTES = 1024 * 1024;
116
+ async function isValidSize(response) {
117
+ const body = response.body;
118
+ if (body === null) return true;
119
+ const reader = body.getReader();
120
+ let received = 0;
121
+ let isOk = true;
122
+ while (true) {
123
+ const { done, value } = await reader.read();
124
+ if (done) break;
125
+ received += value.byteLength;
126
+ if (received > 1048576) {
127
+ await reader.cancel();
128
+ isOk = false;
129
+ }
130
+ }
131
+ return isOk;
132
+ }
133
+ //#endregion
134
+ //#region ../../src/infrastructure/http/src/timeout.ts
135
+ const DEFAULT_TIMEOUT = 10 * 1e3;
136
+ const DISCORD_TIMEOUT = 30 * 1e3;
137
+ //#endregion
32
138
  //#region ../../src/infrastructure/http/src/safefetch.ts
33
- async function safeFetch(input, init) {
34
- return await fetch(input, init);
139
+ const ALLOW_DISCORD_DOMAINS = [
140
+ "discord.com",
141
+ "discordapp.com",
142
+ "discord.gg"
143
+ ];
144
+ async function resolveToPinnedUrl(url, init) {
145
+ const { hostname, host, protocol } = new URL(url);
146
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
147
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
148
+ if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
149
+ return {
150
+ url,
151
+ init
152
+ };
153
+ }
154
+ if (/^\d/.test(hostname)) {
155
+ if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
156
+ return {
157
+ url,
158
+ init
159
+ };
160
+ }
161
+ const resolvedIp = await resolveHostnameToSafeIp(hostname);
162
+ if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
163
+ const pinnedUrlObj = new URL(url);
164
+ pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
165
+ const headers = new Headers(init.headers);
166
+ headers.set("Host", host);
167
+ const pinnedInit = {
168
+ ...init,
169
+ headers,
170
+ ...protocol === "https:" ? { tls: { serverName: hostname } } : {}
171
+ };
172
+ return {
173
+ url: pinnedUrlObj.href,
174
+ init: pinnedInit
175
+ };
176
+ }
177
+ async function safeFetchForDiscord(input, init) {
178
+ const hostname = new URL(input).hostname;
179
+ if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
180
+ const pinned = await resolveToPinnedUrl(input, init ?? {});
181
+ if (pinned instanceof LocalAddressError) return pinned;
182
+ return await fetch(pinned.url, {
183
+ ...pinned.init,
184
+ signal: AbortSignal.timeout(DISCORD_TIMEOUT),
185
+ redirect: "manual"
186
+ });
187
+ }
188
+ async function safeFetch(input, init, options) {
189
+ let reqUrl = input;
190
+ let currentInit = init ?? {};
191
+ let redirectCount = 0;
192
+ let response;
193
+ while (true) {
194
+ const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
195
+ if (pinned instanceof LocalAddressError) return pinned;
196
+ response = await fetch(pinned.url, {
197
+ ...pinned.init,
198
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
199
+ redirect: "manual"
200
+ });
201
+ if (!await isValidSize(response.clone())) return new BodySizeError("Body size Error");
202
+ const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
203
+ const location = response.headers.get("location");
204
+ if (isRedirect) {
205
+ if (location === null) return new HeaderError("location is not found.");
206
+ let url;
207
+ try {
208
+ url = new URL(location, reqUrl);
209
+ } catch {
210
+ return new HeaderError(`${location} is invalid.`);
211
+ }
212
+ if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
213
+ if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
214
+ if (new URL(reqUrl).origin !== url.origin) {
215
+ const headers = new Headers(currentInit.headers);
216
+ headers.delete("authorization");
217
+ headers.delete("cookie");
218
+ currentInit = {
219
+ ...currentInit,
220
+ headers
221
+ };
222
+ }
223
+ reqUrl = url.href;
224
+ redirectCount += 1;
225
+ } else {
226
+ const finalUrl = reqUrl;
227
+ return new Proxy(response, { get(target, prop) {
228
+ if (prop === "url") return finalUrl;
229
+ const value = Reflect.get(target, prop, target);
230
+ return typeof value === "function" ? value.bind(target) : value;
231
+ } });
232
+ }
233
+ if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
234
+ }
35
235
  }
36
236
  //#endregion
37
237
  //#region ../../src/infrastructure/http/src/safeurl.ts
@@ -44,8 +244,51 @@ function safeUrl(strings, ...values) {
44
244
  }
45
245
  return result;
46
246
  }
247
+ const safeUrlSchema = z.string().refine((url) => {
248
+ const parsed = URL.parse(url);
249
+ return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
250
+ }, { message: "URL must be a valid http or https URL" }).transform((url) => url);
251
+ function validateSafeUrl(url) {
252
+ const result = safeUrlSchema.safeParse(url);
253
+ return result.success ? result.data : null;
254
+ }
255
+ //#endregion
256
+ //#region ../../src/infrastructure/http/src/invite.ts
257
+ const DISCORD_DOMAINS = [
258
+ "discord.com",
259
+ "ptb.discord.com",
260
+ "canary.discord.com"
261
+ ];
262
+ const INVITE_PROTOCOL = [
263
+ "discord:",
264
+ "http:",
265
+ "https:"
266
+ ];
267
+ async function isDiscordInviteLink(url) {
268
+ const parsedUrl = URL.parse(url.toString());
269
+ if (parsedUrl === null) return false;
270
+ return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
271
+ }
272
+ function isUsedCf(res) {
273
+ return res.headers.get("cf-mitigated") === "challenge";
274
+ }
275
+ async function isInviteLink(url) {
276
+ const safeUrl = validateSafeUrl(url);
277
+ if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
278
+ const response = await safeFetch(safeUrl, {
279
+ method: "GET",
280
+ headers: { "User-Agent": "Mozilla/5.0" }
281
+ }, { detectDiscordProtocol: true });
282
+ if (response instanceof Error) return response;
283
+ const resUrl = response.url;
284
+ const location = response.headers.get("location");
285
+ return {
286
+ content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
287
+ isUsedCf: isUsedCf(response)
288
+ };
289
+ }
47
290
  //#endregion
48
291
  //#region src/index.ts
49
292
  const DISTOPIA_VERSION = version;
50
293
  //#endregion
51
- export { DISTOPIA_VERSION, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeUrl };
294
+ export { ALLOW_DISCORD_DOMAINS, BodySizeError, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, LocalAddressError, MAX_BYTES, RedirectError, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "distopia",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Library for Distopia.",
5
5
  "keywords": [
6
6
  "distopia",
@@ -40,9 +40,10 @@
40
40
  "dependencies": {
41
41
  "@types/node": "24.13.2",
42
42
  "jsr": "0.14.3",
43
- "oxfmt": "0.54.0",
44
- "oxlint": "1.60.0",
45
- "tsdown": "0.22.2"
43
+ "oxfmt": "0.56.0",
44
+ "oxlint": "1.71.0",
45
+ "tsdown": "0.22.2",
46
+ "zod": "4.4.3"
46
47
  },
47
48
  "devDependencies": {
48
49
  "bun": "1.3.13"