@nascencestudio/ipguard 0.0.0-stage → 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/dist/abuseipdb.d.ts +42 -0
  4. package/dist/abuseipdb.d.ts.map +1 -0
  5. package/dist/abuseipdb.js +84 -0
  6. package/dist/abuseipdb.js.map +1 -0
  7. package/dist/admin.d.ts +41 -0
  8. package/dist/admin.d.ts.map +1 -0
  9. package/dist/admin.js +87 -0
  10. package/dist/admin.js.map +1 -0
  11. package/dist/index.d.ts +33 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +102 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/ip.d.ts +54 -0
  16. package/dist/ip.d.ts.map +1 -0
  17. package/dist/ip.js +218 -0
  18. package/dist/ip.js.map +1 -0
  19. package/dist/model.d.ts +94 -0
  20. package/dist/model.d.ts.map +1 -0
  21. package/dist/model.js +148 -0
  22. package/dist/model.js.map +1 -0
  23. package/dist/runtime/IpGuardNav.astro +63 -0
  24. package/dist/runtime/IpGuardPage.astro +408 -0
  25. package/dist/runtime/admin-endpoint.d.ts +10 -0
  26. package/dist/runtime/admin-endpoint.d.ts.map +1 -0
  27. package/dist/runtime/admin-endpoint.js +168 -0
  28. package/dist/runtime/admin-endpoint.js.map +1 -0
  29. package/dist/runtime/guard.d.ts +44 -0
  30. package/dist/runtime/guard.d.ts.map +1 -0
  31. package/dist/runtime/guard.js +136 -0
  32. package/dist/runtime/guard.js.map +1 -0
  33. package/dist/runtime/middleware.d.ts +8 -0
  34. package/dist/runtime/middleware.d.ts.map +1 -0
  35. package/dist/runtime/middleware.js +27 -0
  36. package/dist/runtime/middleware.js.map +1 -0
  37. package/dist/runtime/store.d.ts +22 -0
  38. package/dist/runtime/store.d.ts.map +1 -0
  39. package/dist/runtime/store.js +194 -0
  40. package/dist/runtime/store.js.map +1 -0
  41. package/dist/runtime/viewer.d.ts +9 -0
  42. package/dist/runtime/viewer.d.ts.map +1 -0
  43. package/dist/runtime/viewer.js +29 -0
  44. package/dist/runtime/viewer.js.map +1 -0
  45. package/dist/virtual.d.ts +12 -0
  46. package/package.json +65 -3
package/dist/ip.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * IP addresses and ranges (ADR 0001): strict parsing of IPv4 and IPv6 (no zone ids, no leading zeros in
3
+ * IPv4 octets, which some parsers read as octal), canonical formatting (RFC 5952 for IPv6), CIDR ranges,
4
+ * the "subject" an automatic action applies to (an IPv4 address, or an IPv6 /64, since one IPv6 visitor
5
+ * can rotate through a whole /64), whether an address is public, and the visitor's real address behind
6
+ * trusted proxies. Pure.
7
+ */
8
+ const BITS = { 4: 32, 6: 128 };
9
+ const MAX_INPUT = 64;
10
+ function parseV4(text) {
11
+ const parts = text.split('.');
12
+ if (parts.length !== 4)
13
+ return null;
14
+ let value = 0n;
15
+ for (const part of parts) {
16
+ if (!/^(0|[1-9][0-9]{0,2})$/.test(part))
17
+ return null;
18
+ const n = Number(part);
19
+ if (n > 255)
20
+ return null;
21
+ value = (value << 8n) | BigInt(n);
22
+ }
23
+ return value;
24
+ }
25
+ function parseV6(text) {
26
+ if (!/^[0-9a-fA-F:.]+$/.test(text))
27
+ return null;
28
+ // An embedded IPv4 address in the last 32 bits (e.g. ::ffff:192.0.2.1).
29
+ let tail = null;
30
+ let body = text;
31
+ const lastColon = text.lastIndexOf(':');
32
+ if (text.includes('.')) {
33
+ tail = parseV4(text.slice(lastColon + 1));
34
+ if (tail === null)
35
+ return null;
36
+ body = `${text.slice(0, lastColon + 1)}0:0`;
37
+ }
38
+ const halves = body.split('::');
39
+ if (halves.length > 2)
40
+ return null;
41
+ const groups = (half) => (half === '' ? [] : half.split(':'));
42
+ const head = groups(halves[0] ?? '');
43
+ const rest = halves.length === 2 ? groups(halves[1] ?? '') : [];
44
+ const total = head.length + rest.length;
45
+ if (halves.length === 1 ? total !== 8 : total > 7)
46
+ return null;
47
+ const all = [...head, ...Array(8 - total).fill('0'), ...rest];
48
+ let value = 0n;
49
+ for (const group of all) {
50
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group))
51
+ return null;
52
+ value = (value << 16n) | BigInt(Number.parseInt(group, 16));
53
+ }
54
+ if (tail !== null)
55
+ value = (value & ~0xffffffffn) | tail;
56
+ return value;
57
+ }
58
+ const V4_MAPPED = 0xffff00000000n;
59
+ /** An address, or null when it isn't a valid one. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is read as IPv4. */
60
+ export function parseIp(input) {
61
+ if (typeof input !== 'string')
62
+ return null;
63
+ const text = input.trim();
64
+ if (text === '' || text.length > MAX_INPUT)
65
+ return null;
66
+ if (!text.includes(':')) {
67
+ const v4 = parseV4(text);
68
+ return v4 === null ? null : { version: 4, value: v4 };
69
+ }
70
+ const v6 = parseV6(text);
71
+ if (v6 === null)
72
+ return null;
73
+ if (v6 >> 32n === V4_MAPPED >> 32n)
74
+ return { version: 4, value: v6 & 0xffffffffn };
75
+ return { version: 6, value: v6 };
76
+ }
77
+ /** The canonical text of an address (IPv6 per RFC 5952: lowercase, zeros compressed once). */
78
+ export function formatIp(ip) {
79
+ if (ip.version === 4) {
80
+ return [24n, 16n, 8n, 0n].map((shift) => String((ip.value >> shift) & 0xffn)).join('.');
81
+ }
82
+ const groups = Array.from({ length: 8 }, (_, i) => Number((ip.value >> BigInt((7 - i) * 16)) & 0xffffn));
83
+ // The longest run of two or more zero groups (the first one on a tie) becomes "::".
84
+ let bestStart = -1;
85
+ let bestLength = 1;
86
+ for (let i = 0; i < 8;) {
87
+ if (groups[i] !== 0) {
88
+ i++;
89
+ continue;
90
+ }
91
+ let j = i;
92
+ while (j < 8 && groups[j] === 0)
93
+ j++;
94
+ if (j - i > bestLength) {
95
+ bestStart = i;
96
+ bestLength = j - i;
97
+ }
98
+ i = j;
99
+ }
100
+ const hex = groups.map((g) => g.toString(16));
101
+ if (bestStart < 0)
102
+ return hex.join(':');
103
+ const left = hex.slice(0, bestStart).join(':');
104
+ const right = hex.slice(bestStart + bestLength).join(':');
105
+ return `${left}::${right}`;
106
+ }
107
+ const mask = (version, prefix) => {
108
+ const bits = BITS[version];
109
+ return prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix);
110
+ };
111
+ /**
112
+ * A range from `a.b.c.d/n`, `x::/n`, or a single address (a /32 or /128). Host bits are cleared
113
+ * (`10.1.2.3/8` is `10.0.0.0/8`). Null when invalid.
114
+ */
115
+ export function parseCidr(input) {
116
+ if (typeof input !== 'string')
117
+ return null;
118
+ const text = input.trim();
119
+ const slash = text.indexOf('/');
120
+ const ip = parseIp(slash < 0 ? text : text.slice(0, slash));
121
+ if (!ip)
122
+ return null;
123
+ let prefix = BITS[ip.version];
124
+ if (slash >= 0) {
125
+ const raw = text.slice(slash + 1);
126
+ if (!/^(0|[1-9][0-9]{0,2})$/.test(raw))
127
+ return null;
128
+ prefix = Number(raw);
129
+ // An IPv4-mapped address written with an IPv6 prefix: take the IPv4 part of the prefix.
130
+ if (ip.version === 4 && text.slice(0, slash).includes(':'))
131
+ prefix -= 96;
132
+ if (prefix < 0 || prefix > BITS[ip.version])
133
+ return null;
134
+ }
135
+ return { version: ip.version, network: ip.value & mask(ip.version, prefix), prefix };
136
+ }
137
+ export function formatCidr(cidr) {
138
+ const address = formatIp({ version: cidr.version, value: cidr.network });
139
+ return cidr.prefix === BITS[cidr.version] ? address : `${address}/${cidr.prefix}`;
140
+ }
141
+ export function cidrContains(cidr, ip) {
142
+ return cidr.version === ip.version && (ip.value & mask(ip.version, cidr.prefix)) === cidr.network;
143
+ }
144
+ /** How many addresses a range covers, as a readable size (`1`, `256`, `/64`). */
145
+ export const isSingleAddress = (cidr) => cidr.prefix === BITS[cidr.version];
146
+ /**
147
+ * What automatic actions (rate limit, flag, blacklist, ban) apply to: the IPv4 address itself, or the
148
+ * IPv6 address's /64.
149
+ */
150
+ export function subjectOf(ip) {
151
+ const prefix = ip.version === 4 ? 32 : 64;
152
+ return { version: ip.version, network: ip.value & mask(ip.version, prefix), prefix };
153
+ }
154
+ /** Ranges that are never "public" (private, loopback, link-local, documentation, multicast, reserved). */
155
+ const NOT_PUBLIC = [
156
+ '0.0.0.0/8',
157
+ '10.0.0.0/8',
158
+ '100.64.0.0/10',
159
+ '127.0.0.0/8',
160
+ '169.254.0.0/16',
161
+ '172.16.0.0/12',
162
+ '192.0.0.0/24',
163
+ '192.0.2.0/24',
164
+ '192.168.0.0/16',
165
+ '198.18.0.0/15',
166
+ '198.51.100.0/24',
167
+ '203.0.113.0/24',
168
+ '224.0.0.0/3',
169
+ '2001::/23',
170
+ '2001:db8::/32',
171
+ ]
172
+ .map(parseCidr)
173
+ .filter((c) => c !== null);
174
+ const GLOBAL_V6 = parseCidr('2000::/3');
175
+ /** Whether an address is on the public internet (AbuseIPDB only knows about those). */
176
+ export function isPublic(ip) {
177
+ if (ip.version === 6 && !cidrContains(GLOBAL_V6, ip))
178
+ return false;
179
+ return !NOT_PUBLIC.some((range) => cidrContains(range, ip));
180
+ }
181
+ /** Ranges trusted as proxies by default: loopback and private networks (where a reverse proxy runs). */
182
+ export const DEFAULT_TRUSTED_PROXIES = [
183
+ '127.0.0.0/8',
184
+ '::1/128',
185
+ '10.0.0.0/8',
186
+ '172.16.0.0/12',
187
+ '192.168.0.0/16',
188
+ 'fc00::/7',
189
+ ];
190
+ /**
191
+ * Whether an address is one of the trusted proxies. Such an address is never judged: a request that
192
+ * reaches us without a forwarded address looks like it comes from the proxy itself (or from loopback in
193
+ * development), and flagging or banning that would block every visitor at once.
194
+ */
195
+ export const isTrustedProxy = (ip, trusted) => trusted.some((range) => cidrContains(range, ip));
196
+ /**
197
+ * The visitor's address: the connecting address, unless it's a trusted proxy; then the rightmost
198
+ * address in `X-Forwarded-For` that isn't a trusted proxy (addresses further left can be forged by the
199
+ * visitor). Null when nothing valid is found.
200
+ */
201
+ export function resolveClientIp(remote, forwardedFor, trusted) {
202
+ const connecting = parseIp(remote ?? '');
203
+ const isTrusted = (ip) => trusted.some((range) => cidrContains(range, ip));
204
+ if (!connecting || !isTrusted(connecting))
205
+ return connecting;
206
+ const chain = (forwardedFor ?? '')
207
+ .split(',')
208
+ .slice(-20)
209
+ .map((part) => parseIp(part))
210
+ .filter((ip) => ip !== null);
211
+ for (let i = chain.length - 1; i >= 0; i--) {
212
+ const hop = chain[i];
213
+ if (!isTrusted(hop))
214
+ return hop;
215
+ }
216
+ return chain[0] ?? connecting;
217
+ }
218
+ //# sourceMappingURL=ip.js.map
package/dist/ip.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip.js","sourceRoot":"","sources":["../src/ip.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * IP addresses and ranges (ADR 0001): strict parsing of IPv4 and IPv6 (no zone ids, no leading zeros in\n * IPv4 octets, which some parsers read as octal), canonical formatting (RFC 5952 for IPv6), CIDR ranges,\n * the \"subject\" an automatic action applies to (an IPv4 address, or an IPv6 /64, since one IPv6 visitor\n * can rotate through a whole /64), whether an address is public, and the visitor's real address behind\n * trusted proxies. Pure.\n */\n\nexport type IpVersion = 4 | 6;\n\nexport interface Ip {\n\tversion: IpVersion;\n\t/** The address as a number (32 or 128 bits). */\n\tvalue: bigint;\n}\n\nexport interface Cidr {\n\tversion: IpVersion;\n\t/** The network address (host bits cleared). */\n\tnetwork: bigint;\n\tprefix: number;\n}\n\nconst BITS: Record<IpVersion, number> = { 4: 32, 6: 128 };\nconst MAX_INPUT = 64;\n\nfunction parseV4(text: string): bigint | null {\n\tconst parts = text.split('.');\n\tif (parts.length !== 4) return null;\n\tlet value = 0n;\n\tfor (const part of parts) {\n\t\tif (!/^(0|[1-9][0-9]{0,2})$/.test(part)) return null;\n\t\tconst n = Number(part);\n\t\tif (n > 255) return null;\n\t\tvalue = (value << 8n) | BigInt(n);\n\t}\n\treturn value;\n}\n\nfunction parseV6(text: string): bigint | null {\n\tif (!/^[0-9a-fA-F:.]+$/.test(text)) return null;\n\t// An embedded IPv4 address in the last 32 bits (e.g. ::ffff:192.0.2.1).\n\tlet tail: bigint | null = null;\n\tlet body = text;\n\tconst lastColon = text.lastIndexOf(':');\n\tif (text.includes('.')) {\n\t\ttail = parseV4(text.slice(lastColon + 1));\n\t\tif (tail === null) return null;\n\t\tbody = `${text.slice(0, lastColon + 1)}0:0`;\n\t}\n\tconst halves = body.split('::');\n\tif (halves.length > 2) return null;\n\tconst groups = (half: string) => (half === '' ? [] : half.split(':'));\n\tconst head = groups(halves[0] ?? '');\n\tconst rest = halves.length === 2 ? groups(halves[1] ?? '') : [];\n\tconst total = head.length + rest.length;\n\tif (halves.length === 1 ? total !== 8 : total > 7) return null;\n\tconst all = [...head, ...Array(8 - total).fill('0'), ...rest];\n\tlet value = 0n;\n\tfor (const group of all) {\n\t\tif (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;\n\t\tvalue = (value << 16n) | BigInt(Number.parseInt(group, 16));\n\t}\n\tif (tail !== null) value = (value & ~0xffffffffn) | tail;\n\treturn value;\n}\n\nconst V4_MAPPED = 0xffff00000000n;\n\n/** An address, or null when it isn't a valid one. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is read as IPv4. */\nexport function parseIp(input: unknown): Ip | null {\n\tif (typeof input !== 'string') return null;\n\tconst text = input.trim();\n\tif (text === '' || text.length > MAX_INPUT) return null;\n\tif (!text.includes(':')) {\n\t\tconst v4 = parseV4(text);\n\t\treturn v4 === null ? null : { version: 4, value: v4 };\n\t}\n\tconst v6 = parseV6(text);\n\tif (v6 === null) return null;\n\tif (v6 >> 32n === V4_MAPPED >> 32n) return { version: 4, value: v6 & 0xffffffffn };\n\treturn { version: 6, value: v6 };\n}\n\n/** The canonical text of an address (IPv6 per RFC 5952: lowercase, zeros compressed once). */\nexport function formatIp(ip: Ip): string {\n\tif (ip.version === 4) {\n\t\treturn [24n, 16n, 8n, 0n].map((shift) => String((ip.value >> shift) & 0xffn)).join('.');\n\t}\n\tconst groups = Array.from({ length: 8 }, (_, i) => Number((ip.value >> BigInt((7 - i) * 16)) & 0xffffn));\n\t// The longest run of two or more zero groups (the first one on a tie) becomes \"::\".\n\tlet bestStart = -1;\n\tlet bestLength = 1;\n\tfor (let i = 0; i < 8; ) {\n\t\tif (groups[i] !== 0) {\n\t\t\ti++;\n\t\t\tcontinue;\n\t\t}\n\t\tlet j = i;\n\t\twhile (j < 8 && groups[j] === 0) j++;\n\t\tif (j - i > bestLength) {\n\t\t\tbestStart = i;\n\t\t\tbestLength = j - i;\n\t\t}\n\t\ti = j;\n\t}\n\tconst hex = groups.map((g) => g.toString(16));\n\tif (bestStart < 0) return hex.join(':');\n\tconst left = hex.slice(0, bestStart).join(':');\n\tconst right = hex.slice(bestStart + bestLength).join(':');\n\treturn `${left}::${right}`;\n}\n\nconst mask = (version: IpVersion, prefix: number) => {\n\tconst bits = BITS[version];\n\treturn prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix);\n};\n\n/**\n * A range from `a.b.c.d/n`, `x::/n`, or a single address (a /32 or /128). Host bits are cleared\n * (`10.1.2.3/8` is `10.0.0.0/8`). Null when invalid.\n */\nexport function parseCidr(input: unknown): Cidr | null {\n\tif (typeof input !== 'string') return null;\n\tconst text = input.trim();\n\tconst slash = text.indexOf('/');\n\tconst ip = parseIp(slash < 0 ? text : text.slice(0, slash));\n\tif (!ip) return null;\n\tlet prefix = BITS[ip.version];\n\tif (slash >= 0) {\n\t\tconst raw = text.slice(slash + 1);\n\t\tif (!/^(0|[1-9][0-9]{0,2})$/.test(raw)) return null;\n\t\tprefix = Number(raw);\n\t\t// An IPv4-mapped address written with an IPv6 prefix: take the IPv4 part of the prefix.\n\t\tif (ip.version === 4 && text.slice(0, slash).includes(':')) prefix -= 96;\n\t\tif (prefix < 0 || prefix > BITS[ip.version]) return null;\n\t}\n\treturn { version: ip.version, network: ip.value & mask(ip.version, prefix), prefix };\n}\n\nexport function formatCidr(cidr: Cidr): string {\n\tconst address = formatIp({ version: cidr.version, value: cidr.network });\n\treturn cidr.prefix === BITS[cidr.version] ? address : `${address}/${cidr.prefix}`;\n}\n\nexport function cidrContains(cidr: Cidr, ip: Ip): boolean {\n\treturn cidr.version === ip.version && (ip.value & mask(ip.version, cidr.prefix)) === cidr.network;\n}\n\n/** How many addresses a range covers, as a readable size (`1`, `256`, `/64`). */\nexport const isSingleAddress = (cidr: Cidr) => cidr.prefix === BITS[cidr.version];\n\n/**\n * What automatic actions (rate limit, flag, blacklist, ban) apply to: the IPv4 address itself, or the\n * IPv6 address's /64.\n */\nexport function subjectOf(ip: Ip): Cidr {\n\tconst prefix = ip.version === 4 ? 32 : 64;\n\treturn { version: ip.version, network: ip.value & mask(ip.version, prefix), prefix };\n}\n\n/** Ranges that are never \"public\" (private, loopback, link-local, documentation, multicast, reserved). */\nconst NOT_PUBLIC = [\n\t'0.0.0.0/8',\n\t'10.0.0.0/8',\n\t'100.64.0.0/10',\n\t'127.0.0.0/8',\n\t'169.254.0.0/16',\n\t'172.16.0.0/12',\n\t'192.0.0.0/24',\n\t'192.0.2.0/24',\n\t'192.168.0.0/16',\n\t'198.18.0.0/15',\n\t'198.51.100.0/24',\n\t'203.0.113.0/24',\n\t'224.0.0.0/3',\n\t'2001::/23',\n\t'2001:db8::/32',\n]\n\t.map(parseCidr)\n\t.filter((c): c is Cidr => c !== null);\nconst GLOBAL_V6 = parseCidr('2000::/3') as Cidr;\n\n/** Whether an address is on the public internet (AbuseIPDB only knows about those). */\nexport function isPublic(ip: Ip): boolean {\n\tif (ip.version === 6 && !cidrContains(GLOBAL_V6, ip)) return false;\n\treturn !NOT_PUBLIC.some((range) => cidrContains(range, ip));\n}\n\n/** Ranges trusted as proxies by default: loopback and private networks (where a reverse proxy runs). */\nexport const DEFAULT_TRUSTED_PROXIES = [\n\t'127.0.0.0/8',\n\t'::1/128',\n\t'10.0.0.0/8',\n\t'172.16.0.0/12',\n\t'192.168.0.0/16',\n\t'fc00::/7',\n];\n\n/**\n * Whether an address is one of the trusted proxies. Such an address is never judged: a request that\n * reaches us without a forwarded address looks like it comes from the proxy itself (or from loopback in\n * development), and flagging or banning that would block every visitor at once.\n */\nexport const isTrustedProxy = (ip: Ip, trusted: readonly Cidr[]) => trusted.some((range) => cidrContains(range, ip));\n\n/**\n * The visitor's address: the connecting address, unless it's a trusted proxy; then the rightmost\n * address in `X-Forwarded-For` that isn't a trusted proxy (addresses further left can be forged by the\n * visitor). Null when nothing valid is found.\n */\nexport function resolveClientIp(\n\tremote: string | null | undefined,\n\tforwardedFor: string | null | undefined,\n\ttrusted: readonly Cidr[],\n): Ip | null {\n\tconst connecting = parseIp(remote ?? '');\n\tconst isTrusted = (ip: Ip) => trusted.some((range) => cidrContains(range, ip));\n\tif (!connecting || !isTrusted(connecting)) return connecting;\n\tconst chain = (forwardedFor ?? '')\n\t\t.split(',')\n\t\t.slice(-20)\n\t\t.map((part) => parseIp(part))\n\t\t.filter((ip): ip is Ip => ip !== null);\n\tfor (let i = chain.length - 1; i >= 0; i--) {\n\t\tconst hop = chain[i] as Ip;\n\t\tif (!isTrusted(hop)) return hop;\n\t}\n\treturn chain[0] ?? connecting;\n}\n"]}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * IP Guard's model (ADR 0001): list entries, settings and the decision for an action. Everything read
3
+ * from storage or received from a form is validated again here. Pure.
4
+ */
5
+ import { type Cidr, type Ip } from './ip.js';
6
+ export declare const LISTS: readonly ["whitelist", "flagged", "blacklist", "banned"];
7
+ export type ListName = (typeof LISTS)[number];
8
+ export declare const SOURCES: readonly ["manual", "abuseipdb", "rate_limit"];
9
+ export type Source = (typeof SOURCES)[number];
10
+ export declare const LIMITS: {
11
+ readonly reason: 255;
12
+ readonly note: 500;
13
+ readonly blockMessage: 512;
14
+ };
15
+ export declare const ENTRY_ID: RegExp;
16
+ export interface Entry {
17
+ id: string;
18
+ list: ListName;
19
+ /** A single address or a range, canonical (`192.0.2.7`, `2001:db8:1:2::/64`). */
20
+ range: string;
21
+ reason: string;
22
+ source: Source;
23
+ /** The AbuseIPDB score when the source is AbuseIPDB. */
24
+ score?: number;
25
+ /** Attempts counted when an address was flagged. */
26
+ count?: number;
27
+ /** ISO time. */
28
+ createdAt: string;
29
+ /** Who added it (a user name), or null for automatic entries. */
30
+ createdBy: string | null;
31
+ /** Blacklist only: the block ends at this ISO time; null or absent: never. */
32
+ expiresAt?: string | null;
33
+ }
34
+ export interface Settings {
35
+ enabled: boolean;
36
+ /** Attempts allowed per address within the window before it's flagged. */
37
+ maxAttempts: number;
38
+ windowSeconds: number;
39
+ /** AbuseIPDB confidence score (0–100) at which an address is blacklisted. */
40
+ minScore: number;
41
+ /** What a refused visitor sees; never the reason. */
42
+ blockMessage: string;
43
+ /** The AbuseIPDB key saved in the dashboard (an environment variable wins). Empty: none. */
44
+ apiKey: string;
45
+ }
46
+ export declare const WINDOWS: readonly [300, 900, 1800, 3600, 7200, 21600, 43200, 86400];
47
+ export declare const DEFAULT_SETTINGS: Settings;
48
+ /** One line of text: control characters become spaces, whitespace collapsed, trimmed, capped. */
49
+ export declare function oneLine(value: unknown, max: number): string;
50
+ /** A stored or received entry (any JSON) → a valid entry, or null. */
51
+ export declare function parseEntry(raw: unknown): Entry | null;
52
+ /** Stored settings (any JSON) → valid settings; anything missing or invalid takes the default. */
53
+ export declare function parseSettings(raw: unknown): Settings;
54
+ /** An entry with its range parsed, for matching. */
55
+ export interface IndexedEntry extends Entry {
56
+ cidr: Cidr;
57
+ }
58
+ export declare const indexEntries: (entries: readonly Entry[]) => IndexedEntry[];
59
+ /** The lists an address is on (active entries only; an expired blacklist entry doesn't count). */
60
+ export declare function listsOf(entries: readonly IndexedEntry[], ip: Ip, now: number): Record<ListName, IndexedEntry | null>;
61
+ /** Whether two ranges share any address. */
62
+ export declare const rangesOverlap: (a: Cidr, b: Cidr) => boolean;
63
+ export type Decision = {
64
+ action: 'allow';
65
+ } | {
66
+ action: 'refuse';
67
+ because: ListName;
68
+ } | {
69
+ action: 'ban' | 'blacklist' | 'flag';
70
+ };
71
+ /**
72
+ * The decision for an action (ADR 0001 §3), given the lists the address is on, the attempts already
73
+ * counted in the window, and the AbuseIPDB score (null: unknown, no key, or the API failed: fail open).
74
+ * `ban`, `blacklist` and `flag` refuse this action too.
75
+ */
76
+ export declare function decide(input: {
77
+ on: Record<ListName, IndexedEntry | null>;
78
+ attempts: number;
79
+ score: number | null;
80
+ settings: Pick<Settings, 'maxAttempts' | 'minScore'>;
81
+ }): Decision;
82
+ export interface Refusal {
83
+ code: 'own-address' | 'whitelisted';
84
+ message: string;
85
+ }
86
+ /**
87
+ * Why an admin can't put a range on a list (ADR 0001 §5), or null when it's fine: banning, blacklisting
88
+ * or flagging the admin's own current address, or a whitelisted address.
89
+ */
90
+ export declare function refusalFor(list: ListName, range: Cidr, context: {
91
+ adminIp: Ip | null;
92
+ entries: readonly IndexedEntry[];
93
+ }): Refusal | null;
94
+ //# sourceMappingURL=model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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"}
package/dist/model.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * IP Guard's model (ADR 0001): list entries, settings and the decision for an action. Everything read
3
+ * from storage or received from a form is validated again here. Pure.
4
+ */
5
+ import { cidrContains, formatCidr, parseCidr } from './ip.js';
6
+ export const LISTS = ['whitelist', 'flagged', 'blacklist', 'banned'];
7
+ export const SOURCES = ['manual', 'abuseipdb', 'rate_limit'];
8
+ export const LIMITS = { reason: 255, note: 500, blockMessage: 512 };
9
+ export const ENTRY_ID = /^ig_[a-z0-9]{16}$/;
10
+ export const WINDOWS = [300, 900, 1800, 3600, 7200, 21600, 43200, 86400];
11
+ export const DEFAULT_SETTINGS = {
12
+ enabled: true,
13
+ maxAttempts: 5,
14
+ windowSeconds: 3600,
15
+ minScore: 75,
16
+ blockMessage: 'Your submission could not be processed. Please contact us directly if you believe this is an error.',
17
+ apiKey: '',
18
+ };
19
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
20
+ /** One line of text: control characters become spaces, whitespace collapsed, trimmed, capped. */
21
+ export function oneLine(value, max) {
22
+ if (typeof value !== 'string')
23
+ return '';
24
+ let text = '';
25
+ for (const char of value)
26
+ text += (char.codePointAt(0) ?? 0) < 0x20 || char === '\u007f' ? ' ' : char;
27
+ return text.replace(/\s+/g, ' ').trim().slice(0, max);
28
+ }
29
+ const isoTime = (value) => {
30
+ if (typeof value !== 'string' || value.length > 40)
31
+ return null;
32
+ const time = Date.parse(value);
33
+ return Number.isNaN(time) ? null : new Date(time).toISOString();
34
+ };
35
+ const intIn = (value, min, max) => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : null;
36
+ /** A stored or received entry (any JSON) → a valid entry, or null. */
37
+ export function parseEntry(raw) {
38
+ if (!isObject(raw))
39
+ return null;
40
+ if (typeof raw.id !== 'string' || !ENTRY_ID.test(raw.id))
41
+ return null;
42
+ if (!LISTS.includes(raw.list))
43
+ return null;
44
+ const cidr = parseCidr(raw.range);
45
+ const createdAt = isoTime(raw.createdAt);
46
+ if (!cidr || !createdAt)
47
+ return null;
48
+ const entry = {
49
+ id: raw.id,
50
+ list: raw.list,
51
+ range: formatCidr(cidr),
52
+ reason: oneLine(raw.reason, LIMITS.reason),
53
+ source: SOURCES.includes(raw.source) ? raw.source : 'manual',
54
+ createdAt,
55
+ createdBy: typeof raw.createdBy === 'string' ? oneLine(raw.createdBy, 100) || null : null,
56
+ };
57
+ const score = intIn(raw.score, 0, 100);
58
+ if (score !== null)
59
+ entry.score = score;
60
+ const count = intIn(raw.count, 0, 1_000_000);
61
+ if (count !== null)
62
+ entry.count = count;
63
+ if (entry.list === 'blacklist')
64
+ entry.expiresAt = isoTime(raw.expiresAt);
65
+ return entry;
66
+ }
67
+ /** Stored settings (any JSON) → valid settings; anything missing or invalid takes the default. */
68
+ export function parseSettings(raw) {
69
+ const r = isObject(raw) ? raw : {};
70
+ return {
71
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : DEFAULT_SETTINGS.enabled,
72
+ maxAttempts: intIn(r.maxAttempts, 1, 1000) ?? DEFAULT_SETTINGS.maxAttempts,
73
+ windowSeconds: WINDOWS.includes(r.windowSeconds)
74
+ ? r.windowSeconds
75
+ : DEFAULT_SETTINGS.windowSeconds,
76
+ minScore: intIn(r.minScore, 0, 100) ?? DEFAULT_SETTINGS.minScore,
77
+ blockMessage: oneLine(r.blockMessage, LIMITS.blockMessage) || DEFAULT_SETTINGS.blockMessage,
78
+ apiKey: typeof r.apiKey === 'string' && /^[A-Za-z0-9]{1,200}$/.test(r.apiKey) ? r.apiKey : '',
79
+ };
80
+ }
81
+ export const indexEntries = (entries) => entries.flatMap((entry) => {
82
+ const cidr = parseCidr(entry.range);
83
+ return cidr ? [{ ...entry, cidr }] : [];
84
+ });
85
+ const isActive = (entry, now) => entry.list !== 'blacklist' || !entry.expiresAt || Date.parse(entry.expiresAt) > now;
86
+ /** The lists an address is on (active entries only; an expired blacklist entry doesn't count). */
87
+ export function listsOf(entries, ip, now) {
88
+ const found = {
89
+ whitelist: null,
90
+ flagged: null,
91
+ blacklist: null,
92
+ banned: null,
93
+ };
94
+ for (const entry of entries) {
95
+ if (!found[entry.list] && isActive(entry, now) && cidrContains(entry.cidr, ip))
96
+ found[entry.list] = entry;
97
+ }
98
+ return found;
99
+ }
100
+ /** Whether two ranges share any address. */
101
+ export const rangesOverlap = (a, b) => a.version === b.version &&
102
+ (cidrContains(a, { version: b.version, value: b.network }) ||
103
+ cidrContains(b, { version: a.version, value: a.network }));
104
+ /**
105
+ * The decision for an action (ADR 0001 §3), given the lists the address is on, the attempts already
106
+ * counted in the window, and the AbuseIPDB score (null: unknown, no key, or the API failed: fail open).
107
+ * `ban`, `blacklist` and `flag` refuse this action too.
108
+ */
109
+ export function decide(input) {
110
+ const { on, attempts, score, settings } = input;
111
+ if (on.whitelist)
112
+ return { action: 'allow' };
113
+ if (on.banned)
114
+ return { action: 'refuse', because: 'banned' };
115
+ if (on.blacklist)
116
+ return { action: 'refuse', because: 'blacklist' };
117
+ if (on.flagged)
118
+ return { action: 'refuse', because: 'flagged' };
119
+ const overLimit = attempts >= settings.maxAttempts;
120
+ const abusive = score !== null && score >= settings.minScore;
121
+ if (overLimit && abusive)
122
+ return { action: 'ban' };
123
+ if (abusive)
124
+ return { action: 'blacklist' };
125
+ if (overLimit)
126
+ return { action: 'flag' };
127
+ return { action: 'allow' };
128
+ }
129
+ /**
130
+ * Why an admin can't put a range on a list (ADR 0001 §5), or null when it's fine: banning, blacklisting
131
+ * or flagging the admin's own current address, or a whitelisted address.
132
+ */
133
+ export function refusalFor(list, range, context) {
134
+ if (list === 'whitelist')
135
+ return null;
136
+ if (context.adminIp && cidrContains(range, context.adminIp)) {
137
+ return { code: 'own-address', message: 'That includes your own current address: you would lock yourself out.' };
138
+ }
139
+ const whitelisted = context.entries.find((e) => e.list === 'whitelist' && rangesOverlap(e.cidr, range));
140
+ if (whitelisted) {
141
+ return {
142
+ code: 'whitelisted',
143
+ message: `That includes the whitelisted ${whitelisted.range}. Remove it from the whitelist first.`,
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+ //# sourceMappingURL=model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * IP Guard's model (ADR 0001): list entries, settings and the decision for an action. Everything read\n * from storage or received from a form is validated again here. Pure.\n */\nimport { type Cidr, cidrContains, formatCidr, type Ip, parseCidr } from './ip.js';\n\nexport const LISTS = ['whitelist', 'flagged', 'blacklist', 'banned'] as const;\nexport type ListName = (typeof LISTS)[number];\nexport const SOURCES = ['manual', 'abuseipdb', 'rate_limit'] as const;\nexport type Source = (typeof SOURCES)[number];\n\nexport const LIMITS = { reason: 255, note: 500, blockMessage: 512 } as const;\nexport const ENTRY_ID = /^ig_[a-z0-9]{16}$/;\n\nexport interface Entry {\n\tid: string;\n\tlist: ListName;\n\t/** A single address or a range, canonical (`192.0.2.7`, `2001:db8:1:2::/64`). */\n\trange: string;\n\treason: string;\n\tsource: Source;\n\t/** The AbuseIPDB score when the source is AbuseIPDB. */\n\tscore?: number;\n\t/** Attempts counted when an address was flagged. */\n\tcount?: number;\n\t/** ISO time. */\n\tcreatedAt: string;\n\t/** Who added it (a user name), or null for automatic entries. */\n\tcreatedBy: string | null;\n\t/** Blacklist only: the block ends at this ISO time; null or absent: never. */\n\texpiresAt?: string | null;\n}\n\nexport interface Settings {\n\tenabled: boolean;\n\t/** Attempts allowed per address within the window before it's flagged. */\n\tmaxAttempts: number;\n\twindowSeconds: number;\n\t/** AbuseIPDB confidence score (0–100) at which an address is blacklisted. */\n\tminScore: number;\n\t/** What a refused visitor sees; never the reason. */\n\tblockMessage: string;\n\t/** The AbuseIPDB key saved in the dashboard (an environment variable wins). Empty: none. */\n\tapiKey: string;\n}\n\nexport const WINDOWS = [300, 900, 1800, 3600, 7200, 21600, 43200, 86400] as const;\n\nexport const DEFAULT_SETTINGS: Settings = {\n\tenabled: true,\n\tmaxAttempts: 5,\n\twindowSeconds: 3600,\n\tminScore: 75,\n\tblockMessage: 'Your submission could not be processed. Please contact us directly if you believe this is an error.',\n\tapiKey: '',\n};\n\nconst isObject = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === 'object' && !Array.isArray(v);\n\n/** One line of text: control characters become spaces, whitespace collapsed, trimmed, capped. */\nexport function oneLine(value: unknown, max: number): string {\n\tif (typeof value !== 'string') return '';\n\tlet text = '';\n\tfor (const char of value) text += (char.codePointAt(0) ?? 0) < 0x20 || char === '\\u007f' ? ' ' : char;\n\treturn text.replace(/\\s+/g, ' ').trim().slice(0, max);\n}\n\nconst isoTime = (value: unknown): string | null => {\n\tif (typeof value !== 'string' || value.length > 40) return null;\n\tconst time = Date.parse(value);\n\treturn Number.isNaN(time) ? null : new Date(time).toISOString();\n};\n\nconst intIn = (value: unknown, min: number, max: number): number | null =>\n\ttypeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : null;\n\n/** A stored or received entry (any JSON) → a valid entry, or null. */\nexport function parseEntry(raw: unknown): Entry | null {\n\tif (!isObject(raw)) return null;\n\tif (typeof raw.id !== 'string' || !ENTRY_ID.test(raw.id)) return null;\n\tif (!LISTS.includes(raw.list as ListName)) return null;\n\tconst cidr = parseCidr(raw.range);\n\tconst createdAt = isoTime(raw.createdAt);\n\tif (!cidr || !createdAt) return null;\n\tconst entry: Entry = {\n\t\tid: raw.id,\n\t\tlist: raw.list as ListName,\n\t\trange: formatCidr(cidr),\n\t\treason: oneLine(raw.reason, LIMITS.reason),\n\t\tsource: SOURCES.includes(raw.source as Source) ? (raw.source as Source) : 'manual',\n\t\tcreatedAt,\n\t\tcreatedBy: typeof raw.createdBy === 'string' ? oneLine(raw.createdBy, 100) || null : null,\n\t};\n\tconst score = intIn(raw.score, 0, 100);\n\tif (score !== null) entry.score = score;\n\tconst count = intIn(raw.count, 0, 1_000_000);\n\tif (count !== null) entry.count = count;\n\tif (entry.list === 'blacklist') entry.expiresAt = isoTime(raw.expiresAt);\n\treturn entry;\n}\n\n/** Stored settings (any JSON) → valid settings; anything missing or invalid takes the default. */\nexport function parseSettings(raw: unknown): Settings {\n\tconst r = isObject(raw) ? raw : {};\n\treturn {\n\t\tenabled: typeof r.enabled === 'boolean' ? r.enabled : DEFAULT_SETTINGS.enabled,\n\t\tmaxAttempts: intIn(r.maxAttempts, 1, 1000) ?? DEFAULT_SETTINGS.maxAttempts,\n\t\twindowSeconds: WINDOWS.includes(r.windowSeconds as (typeof WINDOWS)[number])\n\t\t\t? (r.windowSeconds as number)\n\t\t\t: DEFAULT_SETTINGS.windowSeconds,\n\t\tminScore: intIn(r.minScore, 0, 100) ?? DEFAULT_SETTINGS.minScore,\n\t\tblockMessage: oneLine(r.blockMessage, LIMITS.blockMessage) || DEFAULT_SETTINGS.blockMessage,\n\t\tapiKey: typeof r.apiKey === 'string' && /^[A-Za-z0-9]{1,200}$/.test(r.apiKey) ? r.apiKey : '',\n\t};\n}\n\n/** An entry with its range parsed, for matching. */\nexport interface IndexedEntry extends Entry {\n\tcidr: Cidr;\n}\n\nexport const indexEntries = (entries: readonly Entry[]): IndexedEntry[] =>\n\tentries.flatMap((entry) => {\n\t\tconst cidr = parseCidr(entry.range);\n\t\treturn cidr ? [{ ...entry, cidr }] : [];\n\t});\n\nconst isActive = (entry: Entry, now: number) =>\n\tentry.list !== 'blacklist' || !entry.expiresAt || Date.parse(entry.expiresAt) > now;\n\n/** The lists an address is on (active entries only; an expired blacklist entry doesn't count). */\nexport function listsOf(entries: readonly IndexedEntry[], ip: Ip, now: number): Record<ListName, IndexedEntry | null> {\n\tconst found: Record<ListName, IndexedEntry | null> = {\n\t\twhitelist: null,\n\t\tflagged: null,\n\t\tblacklist: null,\n\t\tbanned: null,\n\t};\n\tfor (const entry of entries) {\n\t\tif (!found[entry.list] && isActive(entry, now) && cidrContains(entry.cidr, ip)) found[entry.list] = entry;\n\t}\n\treturn found;\n}\n\n/** Whether two ranges share any address. */\nexport const rangesOverlap = (a: Cidr, b: Cidr) =>\n\ta.version === b.version &&\n\t(cidrContains(a, { version: b.version, value: b.network }) ||\n\t\tcidrContains(b, { version: a.version, value: a.network }));\n\nexport type Decision =\n\t| { action: 'allow' }\n\t| { action: 'refuse'; because: ListName }\n\t| { action: 'ban' | 'blacklist' | 'flag' };\n\n/**\n * The decision for an action (ADR 0001 §3), given the lists the address is on, the attempts already\n * counted in the window, and the AbuseIPDB score (null: unknown, no key, or the API failed: fail open).\n * `ban`, `blacklist` and `flag` refuse this action too.\n */\nexport function decide(input: {\n\ton: Record<ListName, IndexedEntry | null>;\n\tattempts: number;\n\tscore: number | null;\n\tsettings: Pick<Settings, 'maxAttempts' | 'minScore'>;\n}): Decision {\n\tconst { on, attempts, score, settings } = input;\n\tif (on.whitelist) return { action: 'allow' };\n\tif (on.banned) return { action: 'refuse', because: 'banned' };\n\tif (on.blacklist) return { action: 'refuse', because: 'blacklist' };\n\tif (on.flagged) return { action: 'refuse', because: 'flagged' };\n\tconst overLimit = attempts >= settings.maxAttempts;\n\tconst abusive = score !== null && score >= settings.minScore;\n\tif (overLimit && abusive) return { action: 'ban' };\n\tif (abusive) return { action: 'blacklist' };\n\tif (overLimit) return { action: 'flag' };\n\treturn { action: 'allow' };\n}\n\nexport interface Refusal {\n\tcode: 'own-address' | 'whitelisted';\n\tmessage: string;\n}\n\n/**\n * Why an admin can't put a range on a list (ADR 0001 §5), or null when it's fine: banning, blacklisting\n * or flagging the admin's own current address, or a whitelisted address.\n */\nexport function refusalFor(\n\tlist: ListName,\n\trange: Cidr,\n\tcontext: { adminIp: Ip | null; entries: readonly IndexedEntry[] },\n): Refusal | null {\n\tif (list === 'whitelist') return null;\n\tif (context.adminIp && cidrContains(range, context.adminIp)) {\n\t\treturn { code: 'own-address', message: 'That includes your own current address: you would lock yourself out.' };\n\t}\n\tconst whitelisted = context.entries.find((e) => e.list === 'whitelist' && rangesOverlap(e.cidr, range));\n\tif (whitelisted) {\n\t\treturn {\n\t\t\tcode: 'whitelisted',\n\t\t\tmessage: `That includes the whitelisted ${whitelisted.range}. Remove it from the whitelist first.`,\n\t\t};\n\t}\n\treturn null;\n}\n"]}
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * IP Guard's inner sidebar (ADR 0001 §7). Plain links with StudioCMS's inner-sidebar classes, marked
4
+ * active here (StudioCMS's `SidebarLink` decides by path alone, and every view shares one path).
5
+ * StudioCMS's double-sidebar script expects #back-to-outer and #show-page (mobile navigation).
6
+ */
7
+ /// <reference types="studiocms/v/types" />
8
+ import { Button } from 'studiocms:ui/components/button';
9
+ import { Icon } from 'studiocms:ui/components/icon';
10
+ import { loadEntries } from './store.js';
11
+
12
+ const here = Astro.url.pathname;
13
+ const view = Astro.url.searchParams.get('view') ?? 'flagged';
14
+ const flagged = (await loadEntries().catch(() => [])).filter((e) => e.list === 'flagged').length;
15
+ const links = [
16
+ { id: 'flagged', label: flagged ? `Flagged (${flagged})` : 'Flagged', icon: 'heroicons:flag' },
17
+ { id: 'blacklist', label: 'Blacklist', icon: 'heroicons:no-symbol' },
18
+ { id: 'banned', label: 'Banned', icon: 'heroicons:lock-closed' },
19
+ { id: 'whitelist', label: 'Whitelist', icon: 'heroicons:check-badge' },
20
+ { id: 'settings', label: 'Settings', icon: 'heroicons:cog-6-tooth' },
21
+ { id: 'check', label: 'Check an address', icon: 'heroicons:magnifying-glass' },
22
+ ] as const;
23
+ ---
24
+
25
+ <div class="inner-sidebar-header">
26
+ <Button variant="solid" color="primary" size="md" id="back-to-outer" class="mobile-btn mid-size-btn">
27
+ <Icon name="heroicons:arrow-left" width={24} height={24} slot="start-content" />
28
+ </Button>
29
+ <p class="ipguard-nav__title">IP Guard</p>
30
+ <Button variant="solid" color="primary" size="md" id="show-page" class="mobile-btn">
31
+ <Icon name="heroicons:x-mark" width={24} height={24} slot="start-content" />
32
+ </Button>
33
+ </div>
34
+ <nav class="ipguard-nav__links" aria-label="IP Guard" data-ipguard-nav>
35
+ {
36
+ links.map((link) => (
37
+ <a
38
+ class:list={['inner-sidebar-link', { active: link.id === view }]}
39
+ href={link.id === 'flagged' ? here : `${here}?view=${link.id}`}
40
+ aria-current={link.id === view ? 'page' : undefined}
41
+ data-ipguard-nav-link={link.id}
42
+ >
43
+ <Icon name={link.icon} class="sidebar-link-icon" width={24} height={24} />
44
+ <span>{link.label}</span>
45
+ </a>
46
+ ))
47
+ }
48
+ </nav>
49
+
50
+ <style>
51
+ .ipguard-nav__title {
52
+ flex: 1;
53
+ margin: 0;
54
+ font-weight: 600;
55
+ font-size: 1.125rem;
56
+ }
57
+ .ipguard-nav__links {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 0.4rem;
61
+ width: 100%;
62
+ }
63
+ </style>