@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
@@ -0,0 +1,44 @@
1
+ import { type Ip } from '../ip.js';
2
+ import { type Decision, type Settings } from '../model.js';
3
+ export type { Decision } from '../model.js';
4
+ /** What a request looks like to IP Guard (an Astro context or global). */
5
+ export interface RequestLike {
6
+ request: Request;
7
+ clientAddress?: string;
8
+ }
9
+ /** The off switch (environment): no checks, no bans. */
10
+ export declare const disabledByEnvironment: () => boolean;
11
+ /** The visitor's real address, or null (then nothing is checked: fail open). */
12
+ export declare function clientIp(context: RequestLike): Ip | null;
13
+ export declare const abuseIpDb: () => {
14
+ check: (ip: Ip, apiKey: string) => Promise<import("../abuseipdb.js").CheckResult>;
15
+ pausedUntil: () => Promise<number | null>;
16
+ resume: () => Promise<void>;
17
+ };
18
+ /** The AbuseIPDB key in use: the environment variable wins over the dashboard's. */
19
+ export declare const apiKeyOf: (settings: Settings) => string;
20
+ export interface CheckResult {
21
+ /** `allow`: go ahead. `refuse`: show the block message (never the reason). */
22
+ outcome: 'allow' | 'refuse';
23
+ decision: Decision | {
24
+ action: 'off';
25
+ } | {
26
+ action: 'unknown-address';
27
+ };
28
+ /** The message to show a refused visitor. */
29
+ message: string;
30
+ }
31
+ /**
32
+ * Check an action (a form submission) from this request: lists, rate limit, AbuseIPDB (ADR 0001 §3).
33
+ * Acts on the decision (ban, blacklist or flag the subject) and counts allowed attempts.
34
+ * `key` names what's protected (e.g. `form:contact`) for the log.
35
+ */
36
+ export declare function checkAction(context: RequestLike, options: {
37
+ key: string;
38
+ }): Promise<CheckResult>;
39
+ /**
40
+ * Count an attempt that was rejected before `checkAction()` (e.g. a form's honeypot caught it): it counts
41
+ * towards the rate limit like an accepted one (ADR 0001 §3). Whitelisted addresses aren't counted.
42
+ */
43
+ export declare function recordAttempt(context: RequestLike): Promise<void>;
44
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/runtime/guard.ts"],"names":[],"mappings":"AAWA,OAAO,EAIN,KAAK,EAAE,EAMP,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,KAAK,QAAQ,EAMb,KAAK,QAAQ,EAEb,MAAM,aAAa,CAAC;AAYrB,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAID,wDAAwD;AACxD,eAAO,MAAM,qBAAqB,eAA4C,CAAC;AAE/E,gFAAgF;AAChF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,EAAE,GAAG,IAAI,CAQxD;AAGD,eAAO,MAAM,SAAS;;;;CAGrB,CAAC;AAEF,oFAAoF;AACpF,eAAO,MAAM,QAAQ,GAAI,UAAU,QAAQ,WAA6D,CAAC;AA0BzG,MAAM,WAAW,WAAW;IAC3B,8EAA8E;IAC9E,OAAO,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC5B,QAAQ,EAAE,QAAQ,GAAG;QAAE,MAAM,EAAE,KAAK,CAAA;KAAE,GAAG;QAAE,MAAM,EAAE,iBAAiB,CAAA;KAAE,CAAC;IACvE,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAuDtG;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAOvE"}
@@ -0,0 +1,136 @@
1
+ /// <reference types="studiocms/v/types" />
2
+ /// <reference path="../virtual.d.ts" />
3
+ /**
4
+ * `@nascencestudio/ipguard/guard`: IP Guard for other plugins (ADR 0001 §3, §9). Webforms calls
5
+ * `checkAction()` before accepting a submission and `recordAttempt()` for submissions its own spam checks
6
+ * rejected. Server only.
7
+ *
8
+ * `IPGUARD_DISABLE=1` (environment) turns every check and ban off: the way back in after a lockout.
9
+ */
10
+ import config from 'virtual:ipguard/config';
11
+ import { createAbuseIpDb } from '../abuseipdb.js';
12
+ import { formatCidr, formatIp, isSingleAddress, isTrustedProxy, parseCidr, resolveClientIp, subjectOf, } from '../ip.js';
13
+ import { listsOf, rangesOverlap, decide as tableDecision, } from '../model.js';
14
+ import { addAttempt, countAttempts, loadEntries, loadSettings, maybeCleanup, newEntryId, saveEntry, scoreStore, } from './store.js';
15
+ const trusted = config.trustedProxies.map(parseCidr).filter((c) => c !== null);
16
+ /** The off switch (environment): no checks, no bans. */
17
+ export const disabledByEnvironment = () => process.env.IPGUARD_DISABLE === '1';
18
+ /** The visitor's real address, or null (then nothing is checked: fail open). */
19
+ export function clientIp(context) {
20
+ let remote;
21
+ try {
22
+ remote = context.clientAddress;
23
+ }
24
+ catch {
25
+ remote = undefined;
26
+ }
27
+ return resolveClientIp(remote, context.request.headers.get('x-forwarded-for'), trusted);
28
+ }
29
+ let client = null;
30
+ export const abuseIpDb = () => {
31
+ client ??= createAbuseIpDb({ fetch: globalThis.fetch, store: scoreStore, endpoint: config.abuseIpDbEndpoint });
32
+ return client;
33
+ };
34
+ /** The AbuseIPDB key in use: the environment variable wins over the dashboard's. */
35
+ export const apiKeyOf = (settings) => process.env.ABUSEIPDB_API_KEY?.trim() || settings.apiKey;
36
+ /**
37
+ * The range an automatic entry covers: the subject (an IPv4 address or an IPv6 /64), or just the address
38
+ * when the /64 would also cover a whitelisted address (never act on whitelisted addresses).
39
+ */
40
+ function automaticRange(ip, entries) {
41
+ const subject = subjectOf(ip);
42
+ if (isSingleAddress(subject))
43
+ return subject;
44
+ const touchesWhitelist = entries.some((e) => e.list === 'whitelist' && rangesOverlap(e.cidr, subject));
45
+ return touchesWhitelist ? parseCidr(formatIp(ip)) : subject;
46
+ }
47
+ async function addAutomatic(list, range, reason, extra = {}) {
48
+ await saveEntry({
49
+ id: newEntryId(),
50
+ list,
51
+ range: formatCidr(range),
52
+ reason,
53
+ source: 'manual',
54
+ createdAt: new Date().toISOString(),
55
+ createdBy: null,
56
+ ...extra,
57
+ });
58
+ }
59
+ /**
60
+ * Check an action (a form submission) from this request: lists, rate limit, AbuseIPDB (ADR 0001 §3).
61
+ * Acts on the decision (ban, blacklist or flag the subject) and counts allowed attempts.
62
+ * `key` names what's protected (e.g. `form:contact`) for the log.
63
+ */
64
+ export async function checkAction(context, options) {
65
+ const settings = await loadSettings();
66
+ const message = settings.blockMessage;
67
+ if (!settings.enabled || disabledByEnvironment())
68
+ return { outcome: 'allow', decision: { action: 'off' }, message };
69
+ const ip = clientIp(context);
70
+ // No address, or only the proxy's own (no forwarded address): never judged (fail open).
71
+ if (!ip || isTrustedProxy(ip, trusted))
72
+ return { outcome: 'allow', decision: { action: 'unknown-address' }, message };
73
+ const entries = await loadEntries();
74
+ const now = Date.now();
75
+ const on = listsOf(entries, ip, now);
76
+ const subject = formatCidr(subjectOf(ip));
77
+ const listed = on.whitelist || on.banned || on.blacklist || on.flagged;
78
+ const attempts = listed ? 0 : await countAttempts(subject, settings.windowSeconds, now);
79
+ const score = listed ? null : (await abuseIpDb().check(ip, apiKeyOf(settings))).score;
80
+ const decision = tableDecision({ on, attempts, score, settings });
81
+ const range = automaticRange(ip, entries);
82
+ const where = `${formatIp(ip)} (${options.key.slice(0, 80)})`;
83
+ switch (decision.action) {
84
+ case 'ban':
85
+ await addAutomatic('banned', range, `Over the rate limit and AbuseIPDB score ${score}%`, {
86
+ source: 'abuseipdb',
87
+ score: score ?? undefined,
88
+ });
89
+ await addAutomatic('blacklist', range, `Over the rate limit and AbuseIPDB score ${score}%`, {
90
+ source: 'abuseipdb',
91
+ score: score ?? undefined,
92
+ expiresAt: null,
93
+ });
94
+ console.info(`[ipguard] banned ${where}`);
95
+ break;
96
+ case 'blacklist':
97
+ await addAutomatic('blacklist', range, `AbuseIPDB score ${score}%`, {
98
+ source: 'abuseipdb',
99
+ score: score ?? undefined,
100
+ expiresAt: null,
101
+ });
102
+ console.info(`[ipguard] blacklisted ${where}`);
103
+ break;
104
+ case 'flag':
105
+ await addAutomatic('flagged', range, `Over the rate limit (${attempts} attempts)`, {
106
+ source: 'rate_limit',
107
+ count: attempts,
108
+ });
109
+ console.info(`[ipguard] flagged ${where}`);
110
+ break;
111
+ case 'allow':
112
+ if (!on.whitelist)
113
+ await addAttempt(subject, settings.windowSeconds, now);
114
+ break;
115
+ case 'refuse':
116
+ break;
117
+ }
118
+ await maybeCleanup(settings.windowSeconds, now);
119
+ return { outcome: decision.action === 'allow' ? 'allow' : 'refuse', decision, message };
120
+ }
121
+ /**
122
+ * Count an attempt that was rejected before `checkAction()` (e.g. a form's honeypot caught it): it counts
123
+ * towards the rate limit like an accepted one (ADR 0001 §3). Whitelisted addresses aren't counted.
124
+ */
125
+ export async function recordAttempt(context) {
126
+ const settings = await loadSettings();
127
+ if (!settings.enabled || disabledByEnvironment())
128
+ return;
129
+ const ip = clientIp(context);
130
+ if (!ip || isTrustedProxy(ip, trusted))
131
+ return;
132
+ if (listsOf(await loadEntries(), ip, Date.now()).whitelist)
133
+ return;
134
+ await addAttempt(formatCidr(subjectOf(ip)), settings.windowSeconds);
135
+ }
136
+ //# sourceMappingURL=guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.js","sourceRoot":"","sources":["../../src/runtime/guard.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference types=\"studiocms/v/types\" />\n/// <reference path=\"../virtual.d.ts\" />\n/**\n * `@nascencestudio/ipguard/guard`: IP Guard for other plugins (ADR 0001 §3, §9). Webforms calls\n * `checkAction()` before accepting a submission and `recordAttempt()` for submissions its own spam checks\n * rejected. Server only.\n *\n * `IPGUARD_DISABLE=1` (environment) turns every check and ban off: the way back in after a lockout.\n */\nimport config from 'virtual:ipguard/config';\nimport { type AbuseIpDb, createAbuseIpDb } from '../abuseipdb.js';\nimport {\n\ttype Cidr,\n\tformatCidr,\n\tformatIp,\n\ttype Ip,\n\tisSingleAddress,\n\tisTrustedProxy,\n\tparseCidr,\n\tresolveClientIp,\n\tsubjectOf,\n} from '../ip.js';\nimport {\n\ttype Decision,\n\ttype Entry,\n\ttype IndexedEntry,\n\ttype ListName,\n\tlistsOf,\n\trangesOverlap,\n\ttype Settings,\n\tdecide as tableDecision,\n} from '../model.js';\nimport {\n\taddAttempt,\n\tcountAttempts,\n\tloadEntries,\n\tloadSettings,\n\tmaybeCleanup,\n\tnewEntryId,\n\tsaveEntry,\n\tscoreStore,\n} from './store.js';\n\nexport type { Decision } from '../model.js';\n\n/** What a request looks like to IP Guard (an Astro context or global). */\nexport interface RequestLike {\n\trequest: Request;\n\tclientAddress?: string;\n}\n\nconst trusted = config.trustedProxies.map(parseCidr).filter((c): c is Cidr => c !== null);\n\n/** The off switch (environment): no checks, no bans. */\nexport const disabledByEnvironment = () => process.env.IPGUARD_DISABLE === '1';\n\n/** The visitor's real address, or null (then nothing is checked: fail open). */\nexport function clientIp(context: RequestLike): Ip | null {\n\tlet remote: string | undefined;\n\ttry {\n\t\tremote = context.clientAddress;\n\t} catch {\n\t\tremote = undefined;\n\t}\n\treturn resolveClientIp(remote, context.request.headers.get('x-forwarded-for'), trusted);\n}\n\nlet client: AbuseIpDb | null = null;\nexport const abuseIpDb = () => {\n\tclient ??= createAbuseIpDb({ fetch: globalThis.fetch, store: scoreStore, endpoint: config.abuseIpDbEndpoint });\n\treturn client;\n};\n\n/** The AbuseIPDB key in use: the environment variable wins over the dashboard's. */\nexport const apiKeyOf = (settings: Settings) => process.env.ABUSEIPDB_API_KEY?.trim() || settings.apiKey;\n\n/**\n * The range an automatic entry covers: the subject (an IPv4 address or an IPv6 /64), or just the address\n * when the /64 would also cover a whitelisted address (never act on whitelisted addresses).\n */\nfunction automaticRange(ip: Ip, entries: readonly IndexedEntry[]): Cidr {\n\tconst subject = subjectOf(ip);\n\tif (isSingleAddress(subject)) return subject;\n\tconst touchesWhitelist = entries.some((e) => e.list === 'whitelist' && rangesOverlap(e.cidr, subject));\n\treturn touchesWhitelist ? (parseCidr(formatIp(ip)) as Cidr) : subject;\n}\n\nasync function addAutomatic(list: ListName, range: Cidr, reason: string, extra: Partial<Entry> = {}): Promise<void> {\n\tawait saveEntry({\n\t\tid: newEntryId(),\n\t\tlist,\n\t\trange: formatCidr(range),\n\t\treason,\n\t\tsource: 'manual',\n\t\tcreatedAt: new Date().toISOString(),\n\t\tcreatedBy: null,\n\t\t...extra,\n\t});\n}\n\nexport interface CheckResult {\n\t/** `allow`: go ahead. `refuse`: show the block message (never the reason). */\n\toutcome: 'allow' | 'refuse';\n\tdecision: Decision | { action: 'off' } | { action: 'unknown-address' };\n\t/** The message to show a refused visitor. */\n\tmessage: string;\n}\n\n/**\n * Check an action (a form submission) from this request: lists, rate limit, AbuseIPDB (ADR 0001 §3).\n * Acts on the decision (ban, blacklist or flag the subject) and counts allowed attempts.\n * `key` names what's protected (e.g. `form:contact`) for the log.\n */\nexport async function checkAction(context: RequestLike, options: { key: string }): Promise<CheckResult> {\n\tconst settings = await loadSettings();\n\tconst message = settings.blockMessage;\n\tif (!settings.enabled || disabledByEnvironment()) return { outcome: 'allow', decision: { action: 'off' }, message };\n\tconst ip = clientIp(context);\n\t// No address, or only the proxy's own (no forwarded address): never judged (fail open).\n\tif (!ip || isTrustedProxy(ip, trusted)) return { outcome: 'allow', decision: { action: 'unknown-address' }, message };\n\n\tconst entries = await loadEntries();\n\tconst now = Date.now();\n\tconst on = listsOf(entries, ip, now);\n\tconst subject = formatCidr(subjectOf(ip));\n\tconst listed = on.whitelist || on.banned || on.blacklist || on.flagged;\n\tconst attempts = listed ? 0 : await countAttempts(subject, settings.windowSeconds, now);\n\tconst score = listed ? null : (await abuseIpDb().check(ip, apiKeyOf(settings))).score;\n\tconst decision = tableDecision({ on, attempts, score, settings });\n\tconst range = automaticRange(ip, entries);\n\tconst where = `${formatIp(ip)} (${options.key.slice(0, 80)})`;\n\n\tswitch (decision.action) {\n\t\tcase 'ban':\n\t\t\tawait addAutomatic('banned', range, `Over the rate limit and AbuseIPDB score ${score}%`, {\n\t\t\t\tsource: 'abuseipdb',\n\t\t\t\tscore: score ?? undefined,\n\t\t\t});\n\t\t\tawait addAutomatic('blacklist', range, `Over the rate limit and AbuseIPDB score ${score}%`, {\n\t\t\t\tsource: 'abuseipdb',\n\t\t\t\tscore: score ?? undefined,\n\t\t\t\texpiresAt: null,\n\t\t\t});\n\t\t\tconsole.info(`[ipguard] banned ${where}`);\n\t\t\tbreak;\n\t\tcase 'blacklist':\n\t\t\tawait addAutomatic('blacklist', range, `AbuseIPDB score ${score}%`, {\n\t\t\t\tsource: 'abuseipdb',\n\t\t\t\tscore: score ?? undefined,\n\t\t\t\texpiresAt: null,\n\t\t\t});\n\t\t\tconsole.info(`[ipguard] blacklisted ${where}`);\n\t\t\tbreak;\n\t\tcase 'flag':\n\t\t\tawait addAutomatic('flagged', range, `Over the rate limit (${attempts} attempts)`, {\n\t\t\t\tsource: 'rate_limit',\n\t\t\t\tcount: attempts,\n\t\t\t});\n\t\t\tconsole.info(`[ipguard] flagged ${where}`);\n\t\t\tbreak;\n\t\tcase 'allow':\n\t\t\tif (!on.whitelist) await addAttempt(subject, settings.windowSeconds, now);\n\t\t\tbreak;\n\t\tcase 'refuse':\n\t\t\tbreak;\n\t}\n\tawait maybeCleanup(settings.windowSeconds, now);\n\treturn { outcome: decision.action === 'allow' ? 'allow' : 'refuse', decision, message };\n}\n\n/**\n * Count an attempt that was rejected before `checkAction()` (e.g. a form's honeypot caught it): it counts\n * towards the rate limit like an accepted one (ADR 0001 §3). Whitelisted addresses aren't counted.\n */\nexport async function recordAttempt(context: RequestLike): Promise<void> {\n\tconst settings = await loadSettings();\n\tif (!settings.enabled || disabledByEnvironment()) return;\n\tconst ip = clientIp(context);\n\tif (!ip || isTrustedProxy(ip, trusted)) return;\n\tif (listsOf(await loadEntries(), ip, Date.now()).whitelist) return;\n\tawait addAttempt(formatCidr(subjectOf(ip)), settings.windowSeconds);\n}\n"]}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Site-wide bans (ADR 0001 §2): every request from a banned address gets a bare 403 before anything else
3
+ * runs (no page, no session). Whitelisted addresses are never banned. Fails open: if the lists can't be
4
+ * read, the request goes through.
5
+ */
6
+ import type { MiddlewareHandler } from 'astro';
7
+ export declare const onRequest: MiddlewareHandler;
8
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/runtime/middleware.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAC;AAK/C,eAAO,MAAM,SAAS,EAAE,iBAoBvB,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { listsOf } from '../model.js';
2
+ import { clientIp, disabledByEnvironment } from './guard.js';
3
+ import { loadEntries, loadSettings } from './store.js';
4
+ export const onRequest = async (context, next) => {
5
+ if (disabledByEnvironment())
6
+ return next();
7
+ try {
8
+ const ip = clientIp(context);
9
+ if (ip && (await loadSettings()).enabled) {
10
+ const entries = await loadEntries();
11
+ if (entries.some((e) => e.list === 'banned')) {
12
+ const on = listsOf(entries, ip, Date.now());
13
+ if (on.banned && !on.whitelist) {
14
+ return new Response('Access denied.', {
15
+ status: 403,
16
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
17
+ });
18
+ }
19
+ }
20
+ }
21
+ }
22
+ catch (error) {
23
+ console.warn('[ipguard] ban check failed; letting the request through', error);
24
+ }
25
+ return next();
26
+ };
27
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/runtime/middleware.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,QAAQ,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,CAAC,MAAM,SAAS,GAAsB,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;IACnE,IAAI,qBAAqB,EAAE;QAAE,OAAO,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC;QACJ,MAAM,EAAE,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC7B,IAAI,EAAE,IAAI,CAAC,MAAM,YAAY,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,MAAM,WAAW,EAAE,CAAC;YACpC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,EAAE,CAAC;gBAC9C,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;gBAC5C,IAAI,EAAE,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC;oBAChC,OAAO,IAAI,QAAQ,CAAC,gBAAgB,EAAE;wBACrC,MAAM,EAAE,GAAG;wBACX,OAAO,EAAE,EAAE,cAAc,EAAE,2BAA2B,EAAE,eAAe,EAAE,UAAU,EAAE;qBACrF,CAAC,CAAC;gBACJ,CAAC;YACF,CAAC;QACF,CAAC;IACF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,IAAI,CAAC,yDAAyD,EAAE,KAAK,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,IAAI,EAAE,CAAC;AACf,CAAC,CAAC","sourcesContent":["/**\n * Site-wide bans (ADR 0001 §2): every request from a banned address gets a bare 403 before anything else\n * runs (no page, no session). Whitelisted addresses are never banned. Fails open: if the lists can't be\n * read, the request goes through.\n */\nimport type { MiddlewareHandler } from 'astro';\nimport { listsOf } from '../model.js';\nimport { clientIp, disabledByEnvironment } from './guard.js';\nimport { loadEntries, loadSettings } from './store.js';\n\nexport const onRequest: MiddlewareHandler = async (context, next) => {\n\tif (disabledByEnvironment()) return next();\n\ttry {\n\t\tconst ip = clientIp(context);\n\t\tif (ip && (await loadSettings()).enabled) {\n\t\t\tconst entries = await loadEntries();\n\t\t\tif (entries.some((e) => e.list === 'banned')) {\n\t\t\t\tconst on = listsOf(entries, ip, Date.now());\n\t\t\t\tif (on.banned && !on.whitelist) {\n\t\t\t\t\treturn new Response('Access denied.', {\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\theaders: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\tconsole.warn('[ipguard] ban check failed; letting the request through', error);\n\t}\n\treturn next();\n};\n"]}
@@ -0,0 +1,22 @@
1
+ import type { ScoreStore } from '../abuseipdb.js';
2
+ import { type Entry, type IndexedEntry, type Settings } from '../model.js';
3
+ /** Every list entry, indexed for matching (cached for a few seconds). */
4
+ export declare function loadEntries(): Promise<IndexedEntry[]>;
5
+ export declare const newEntryId: () => string;
6
+ export declare function saveEntry(entry: Entry): Promise<void>;
7
+ export declare function deleteEntries(ids: readonly string[]): Promise<void>;
8
+ export declare function loadSettings(): Promise<Settings>;
9
+ export declare function saveSettings(settings: Settings): Promise<void>;
10
+ /** Attempts by a subject within the window (seconds). */
11
+ export declare function countAttempts(subject: string, windowSeconds: number, now?: number): Promise<number>;
12
+ /** Count one more attempt (old ones are dropped; at most 1,000 kept). */
13
+ export declare function addAttempt(subject: string, windowSeconds: number, now?: number): Promise<number>;
14
+ export declare function clearAttempts(subject: string): Promise<void>;
15
+ export declare const scoreStore: ScoreStore;
16
+ export declare function forgetScore(ip: string): Promise<void>;
17
+ /**
18
+ * At most once an hour: drop counters with no attempt inside the longest window, expired scores and
19
+ * expired blacklist entries. Errors are logged and ignored (it runs inside a visitor's request).
20
+ */
21
+ export declare function maybeCleanup(windowSeconds: number, now?: number): Promise<void>;
22
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/runtime/store.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,EAAE,KAAK,KAAK,EAAE,KAAK,YAAY,EAA2C,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAyEpH,yEAAyE;AACzE,wBAAsB,WAAW,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAO3D;AAED,eAAO,MAAM,UAAU,cAGtB,CAAC;AAEF,wBAAsB,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAK3D;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAGzE;AAID,wBAAsB,YAAY,IAAI,OAAO,CAAC,QAAQ,CAAC,CAKtD;AAED,wBAAsB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAGpE;AAWD,yDAAyD;AACzD,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7G;AAED,yEAAyE;AACzE,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAK1G;AAED,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAElE;AAUD,eAAO,MAAM,UAAU,EAAE,UAgBxB,CAAC;AAEF,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE3D;AAID;;;GAGG;AACH,wBAAsB,YAAY,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAwBzF"}
@@ -0,0 +1,194 @@
1
+ /// <reference types="studiocms/v/types" />
2
+ /**
3
+ * IP Guard's storage (ADR 0001 §8): rows in StudioCMS's plugin data table, each kind under its own key
4
+ * prefix, re-validated on every read. Entries and settings are cached in memory for a few seconds (the
5
+ * ban check runs on every request) and the cache is dropped on every write. Attempt counters keep the
6
+ * times of recent attempts per subject (an IPv4 address or an IPv6 /64). Old counters, expired scores and
7
+ * expired blacklist entries are removed lazily, at most once an hour (StudioCMS has no cron).
8
+ * Straight through the SDK's database client (`usePluginData()` can't save in StudioCMS 0.6.1). Server only.
9
+ */
10
+ import { randomBytes } from 'node:crypto';
11
+ import { runSDK, SDKCoreJs } from 'studiocms:sdk';
12
+ import { indexEntries, parseEntry, parseSettings } from '../model.js';
13
+ const P = '@nascencestudio/ipguard';
14
+ const ENTRY = `${P}-entry:`;
15
+ const ATTEMPTS = `${P}-attempts:`;
16
+ const SCORE = `${P}-score:`;
17
+ const SETTINGS_ROW = `${P}-settings`;
18
+ const STATE_ROW = `${P}-state`;
19
+ const CACHE_MS = 10_000;
20
+ const CLEANUP_MS = 3_600_000;
21
+ const db = () => SDKCoreJs.dbService.db;
22
+ async function rowsWithPrefix(prefix) {
23
+ // A range on the primary key instead of LIKE (no wildcards to escape).
24
+ return db()
25
+ .selectFrom('StudioCMSPluginData')
26
+ .select(['id', 'data'])
27
+ .where('id', '>=', prefix)
28
+ .where('id', '<', `${prefix}￿`)
29
+ .execute();
30
+ }
31
+ async function readRow(id) {
32
+ const row = await db().selectFrom('StudioCMSPluginData').select('data').where('id', '=', id).executeTakeFirst();
33
+ if (!row)
34
+ return null;
35
+ try {
36
+ return JSON.parse(row.data);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ async function writeRow(id, value) {
43
+ const data = JSON.stringify(value);
44
+ await db()
45
+ .transaction()
46
+ .execute(async (trx) => {
47
+ const existing = await trx.selectFrom('StudioCMSPluginData').select('id').where('id', '=', id).executeTakeFirst();
48
+ if (existing)
49
+ await trx.updateTable('StudioCMSPluginData').set({ data }).where('id', '=', id).execute();
50
+ else
51
+ await trx.insertInto('StudioCMSPluginData').values({ id, data }).execute();
52
+ });
53
+ }
54
+ async function deleteRows(ids) {
55
+ if (ids.length === 0)
56
+ return;
57
+ await db().deleteFrom('StudioCMSPluginData').where('id', 'in', ids).execute();
58
+ }
59
+ const parseJson = (data) => {
60
+ try {
61
+ return JSON.parse(data);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ };
67
+ // --- Entries ---------------------------------------------------------------------------------------
68
+ let entriesCache = null;
69
+ let settingsCache = null;
70
+ const dropCaches = () => {
71
+ entriesCache = null;
72
+ settingsCache = null;
73
+ runSDK(SDKCoreJs.PLUGINS.clearPluginDataCache()).catch(() => { });
74
+ };
75
+ /** Every list entry, indexed for matching (cached for a few seconds). */
76
+ export async function loadEntries() {
77
+ if (entriesCache && Date.now() - entriesCache.at < CACHE_MS)
78
+ return entriesCache.entries;
79
+ const entries = (await rowsWithPrefix(ENTRY))
80
+ .map((row) => parseEntry(parseJson(row.data)))
81
+ .filter((entry) => entry !== null);
82
+ entriesCache = { at: Date.now(), entries: indexEntries(entries) };
83
+ return entriesCache.entries;
84
+ }
85
+ export const newEntryId = () => {
86
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
87
+ return `ig_${[...randomBytes(16)].map((b) => alphabet[b % 36]).join('')}`;
88
+ };
89
+ export async function saveEntry(entry) {
90
+ const clean = parseEntry(entry);
91
+ if (!clean)
92
+ throw new Error('invalid entry');
93
+ await writeRow(`${ENTRY}${clean.id}`, clean);
94
+ dropCaches();
95
+ }
96
+ export async function deleteEntries(ids) {
97
+ await deleteRows(ids.filter((id) => /^ig_[a-z0-9]{16}$/.test(id)).map((id) => `${ENTRY}${id}`));
98
+ dropCaches();
99
+ }
100
+ // --- Settings --------------------------------------------------------------------------------------
101
+ export async function loadSettings() {
102
+ if (settingsCache && Date.now() - settingsCache.at < CACHE_MS)
103
+ return settingsCache.settings;
104
+ const settings = parseSettings(await readRow(SETTINGS_ROW).catch(() => null));
105
+ settingsCache = { at: Date.now(), settings };
106
+ return settings;
107
+ }
108
+ export async function saveSettings(settings) {
109
+ await writeRow(SETTINGS_ROW, parseSettings(settings));
110
+ dropCaches();
111
+ }
112
+ // --- Attempt counters ------------------------------------------------------------------------------
113
+ const attemptsId = (subject) => `${ATTEMPTS}${subject}`;
114
+ const timesOf = (value) => Array.isArray(value?.times)
115
+ ? value.times.filter((t) => typeof t === 'number' && Number.isFinite(t))
116
+ : [];
117
+ /** Attempts by a subject within the window (seconds). */
118
+ export async function countAttempts(subject, windowSeconds, now = Date.now()) {
119
+ const since = now - windowSeconds * 1000;
120
+ return timesOf(await readRow(attemptsId(subject))).filter((t) => t > since).length;
121
+ }
122
+ /** Count one more attempt (old ones are dropped; at most 1,000 kept). */
123
+ export async function addAttempt(subject, windowSeconds, now = Date.now()) {
124
+ const since = now - windowSeconds * 1000;
125
+ const times = [...timesOf(await readRow(attemptsId(subject))).filter((t) => t > since), now].slice(-1000);
126
+ await writeRow(attemptsId(subject), { times });
127
+ return times.length;
128
+ }
129
+ export async function clearAttempts(subject) {
130
+ await deleteRows([attemptsId(subject)]);
131
+ }
132
+ // --- AbuseIPDB cache and pause ---------------------------------------------------------------------
133
+ async function readState() {
134
+ const raw = (await readRow(STATE_ROW).catch(() => null));
135
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
136
+ return { backoffUntil: num(raw?.backoffUntil), lastCleanup: num(raw?.lastCleanup) };
137
+ }
138
+ export const scoreStore = {
139
+ async getScore(ip) {
140
+ const raw = (await readRow(`${SCORE}${ip}`));
141
+ if (!raw || typeof raw.score !== 'number' || typeof raw.expiresAt !== 'number')
142
+ return null;
143
+ return raw.expiresAt > Date.now() ? raw.score : null;
144
+ },
145
+ async setScore(ip, score, expiresAt) {
146
+ await writeRow(`${SCORE}${ip}`, { score, expiresAt });
147
+ },
148
+ async getBackoff() {
149
+ const { backoffUntil } = await readState();
150
+ return backoffUntil > 0 ? backoffUntil : null;
151
+ },
152
+ async setBackoff(until) {
153
+ await writeRow(STATE_ROW, { ...(await readState()), backoffUntil: until });
154
+ },
155
+ };
156
+ export async function forgetScore(ip) {
157
+ await deleteRows([`${SCORE}${ip}`]);
158
+ }
159
+ // --- Cleanup ---------------------------------------------------------------------------------------
160
+ /**
161
+ * At most once an hour: drop counters with no attempt inside the longest window, expired scores and
162
+ * expired blacklist entries. Errors are logged and ignored (it runs inside a visitor's request).
163
+ */
164
+ export async function maybeCleanup(windowSeconds, now = Date.now()) {
165
+ try {
166
+ const state = await readState();
167
+ if (now - state.lastCleanup < CLEANUP_MS)
168
+ return;
169
+ await writeRow(STATE_ROW, { ...state, lastCleanup: now });
170
+ const since = now - windowSeconds * 1000;
171
+ const stale = [];
172
+ for (const row of await rowsWithPrefix(ATTEMPTS)) {
173
+ if (!timesOf(parseJson(row.data)).some((t) => t > since))
174
+ stale.push(row.id);
175
+ }
176
+ for (const row of await rowsWithPrefix(SCORE)) {
177
+ const expiresAt = parseJson(row.data)?.expiresAt;
178
+ if (typeof expiresAt !== 'number' || expiresAt <= now)
179
+ stale.push(row.id);
180
+ }
181
+ for (const entry of await loadEntries()) {
182
+ if (entry.list === 'blacklist' && entry.expiresAt && Date.parse(entry.expiresAt) <= now) {
183
+ stale.push(`${ENTRY}${entry.id}`);
184
+ }
185
+ }
186
+ await deleteRows(stale);
187
+ if (stale.some((id) => id.startsWith(ENTRY)))
188
+ dropCaches();
189
+ }
190
+ catch (error) {
191
+ console.warn('[ipguard] cleanup failed', error);
192
+ }
193
+ }
194
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/runtime/store.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference types=\"studiocms/v/types\" />\n/**\n * IP Guard's storage (ADR 0001 §8): rows in StudioCMS's plugin data table, each kind under its own key\n * prefix, re-validated on every read. Entries and settings are cached in memory for a few seconds (the\n * ban check runs on every request) and the cache is dropped on every write. Attempt counters keep the\n * times of recent attempts per subject (an IPv4 address or an IPv6 /64). Old counters, expired scores and\n * expired blacklist entries are removed lazily, at most once an hour (StudioCMS has no cron).\n * Straight through the SDK's database client (`usePluginData()` can't save in StudioCMS 0.6.1). Server only.\n */\nimport { randomBytes } from 'node:crypto';\nimport { runSDK, SDKCoreJs } from 'studiocms:sdk';\nimport type { ScoreStore } from '../abuseipdb.js';\nimport { type Entry, type IndexedEntry, indexEntries, parseEntry, parseSettings, type Settings } from '../model.js';\n\nconst P = '@nascencestudio/ipguard';\nconst ENTRY = `${P}-entry:`;\nconst ATTEMPTS = `${P}-attempts:`;\nconst SCORE = `${P}-score:`;\nconst SETTINGS_ROW = `${P}-settings`;\nconst STATE_ROW = `${P}-state`;\nconst CACHE_MS = 10_000;\nconst CLEANUP_MS = 3_600_000;\n\nconst db = () => SDKCoreJs.dbService.db;\n\ninterface Row {\n\tid: string;\n\tdata: string;\n}\n\nasync function rowsWithPrefix(prefix: string): Promise<Row[]> {\n\t// A range on the primary key instead of LIKE (no wildcards to escape).\n\treturn db()\n\t\t.selectFrom('StudioCMSPluginData')\n\t\t.select(['id', 'data'])\n\t\t.where('id', '>=', prefix)\n\t\t.where('id', '<', `${prefix}￿`)\n\t\t.execute();\n}\n\nasync function readRow(id: string): Promise<unknown> {\n\tconst row = await db().selectFrom('StudioCMSPluginData').select('data').where('id', '=', id).executeTakeFirst();\n\tif (!row) return null;\n\ttry {\n\t\treturn JSON.parse(row.data) as unknown;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nasync function writeRow(id: string, value: unknown): Promise<void> {\n\tconst data = JSON.stringify(value);\n\tawait db()\n\t\t.transaction()\n\t\t.execute(async (trx) => {\n\t\t\tconst existing = await trx.selectFrom('StudioCMSPluginData').select('id').where('id', '=', id).executeTakeFirst();\n\t\t\tif (existing) await trx.updateTable('StudioCMSPluginData').set({ data }).where('id', '=', id).execute();\n\t\t\telse await trx.insertInto('StudioCMSPluginData').values({ id, data }).execute();\n\t\t});\n}\n\nasync function deleteRows(ids: readonly string[]): Promise<void> {\n\tif (ids.length === 0) return;\n\tawait db().deleteFrom('StudioCMSPluginData').where('id', 'in', ids).execute();\n}\n\nconst parseJson = (data: string): unknown => {\n\ttry {\n\t\treturn JSON.parse(data) as unknown;\n\t} catch {\n\t\treturn null;\n\t}\n};\n\n// --- Entries ---------------------------------------------------------------------------------------\n\nlet entriesCache: { at: number; entries: IndexedEntry[] } | null = null;\nlet settingsCache: { at: number; settings: Settings } | null = null;\n\nconst dropCaches = () => {\n\tentriesCache = null;\n\tsettingsCache = null;\n\trunSDK(SDKCoreJs.PLUGINS.clearPluginDataCache()).catch(() => {});\n};\n\n/** Every list entry, indexed for matching (cached for a few seconds). */\nexport async function loadEntries(): Promise<IndexedEntry[]> {\n\tif (entriesCache && Date.now() - entriesCache.at < CACHE_MS) return entriesCache.entries;\n\tconst entries = (await rowsWithPrefix(ENTRY))\n\t\t.map((row) => parseEntry(parseJson(row.data)))\n\t\t.filter((entry): entry is Entry => entry !== null);\n\tentriesCache = { at: Date.now(), entries: indexEntries(entries) };\n\treturn entriesCache.entries;\n}\n\nexport const newEntryId = () => {\n\tconst alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';\n\treturn `ig_${[...randomBytes(16)].map((b) => alphabet[b % 36]).join('')}`;\n};\n\nexport async function saveEntry(entry: Entry): Promise<void> {\n\tconst clean = parseEntry(entry);\n\tif (!clean) throw new Error('invalid entry');\n\tawait writeRow(`${ENTRY}${clean.id}`, clean);\n\tdropCaches();\n}\n\nexport async function deleteEntries(ids: readonly string[]): Promise<void> {\n\tawait deleteRows(ids.filter((id) => /^ig_[a-z0-9]{16}$/.test(id)).map((id) => `${ENTRY}${id}`));\n\tdropCaches();\n}\n\n// --- Settings --------------------------------------------------------------------------------------\n\nexport async function loadSettings(): Promise<Settings> {\n\tif (settingsCache && Date.now() - settingsCache.at < CACHE_MS) return settingsCache.settings;\n\tconst settings = parseSettings(await readRow(SETTINGS_ROW).catch(() => null));\n\tsettingsCache = { at: Date.now(), settings };\n\treturn settings;\n}\n\nexport async function saveSettings(settings: Settings): Promise<void> {\n\tawait writeRow(SETTINGS_ROW, parseSettings(settings));\n\tdropCaches();\n}\n\n// --- Attempt counters ------------------------------------------------------------------------------\n\nconst attemptsId = (subject: string) => `${ATTEMPTS}${subject}`;\n\nconst timesOf = (value: unknown): number[] =>\n\tArray.isArray((value as { times?: unknown })?.times)\n\t\t? ((value as { times: unknown[] }).times.filter((t) => typeof t === 'number' && Number.isFinite(t)) as number[])\n\t\t: [];\n\n/** Attempts by a subject within the window (seconds). */\nexport async function countAttempts(subject: string, windowSeconds: number, now = Date.now()): Promise<number> {\n\tconst since = now - windowSeconds * 1000;\n\treturn timesOf(await readRow(attemptsId(subject))).filter((t) => t > since).length;\n}\n\n/** Count one more attempt (old ones are dropped; at most 1,000 kept). */\nexport async function addAttempt(subject: string, windowSeconds: number, now = Date.now()): Promise<number> {\n\tconst since = now - windowSeconds * 1000;\n\tconst times = [...timesOf(await readRow(attemptsId(subject))).filter((t) => t > since), now].slice(-1000);\n\tawait writeRow(attemptsId(subject), { times });\n\treturn times.length;\n}\n\nexport async function clearAttempts(subject: string): Promise<void> {\n\tawait deleteRows([attemptsId(subject)]);\n}\n\n// --- AbuseIPDB cache and pause ---------------------------------------------------------------------\n\nasync function readState(): Promise<{ backoffUntil: number; lastCleanup: number }> {\n\tconst raw = (await readRow(STATE_ROW).catch(() => null)) as Record<string, unknown> | null;\n\tconst num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);\n\treturn { backoffUntil: num(raw?.backoffUntil), lastCleanup: num(raw?.lastCleanup) };\n}\n\nexport const scoreStore: ScoreStore = {\n\tasync getScore(ip) {\n\t\tconst raw = (await readRow(`${SCORE}${ip}`)) as { score?: unknown; expiresAt?: unknown } | null;\n\t\tif (!raw || typeof raw.score !== 'number' || typeof raw.expiresAt !== 'number') return null;\n\t\treturn raw.expiresAt > Date.now() ? raw.score : null;\n\t},\n\tasync setScore(ip, score, expiresAt) {\n\t\tawait writeRow(`${SCORE}${ip}`, { score, expiresAt });\n\t},\n\tasync getBackoff() {\n\t\tconst { backoffUntil } = await readState();\n\t\treturn backoffUntil > 0 ? backoffUntil : null;\n\t},\n\tasync setBackoff(until) {\n\t\tawait writeRow(STATE_ROW, { ...(await readState()), backoffUntil: until });\n\t},\n};\n\nexport async function forgetScore(ip: string): Promise<void> {\n\tawait deleteRows([`${SCORE}${ip}`]);\n}\n\n// --- Cleanup ---------------------------------------------------------------------------------------\n\n/**\n * At most once an hour: drop counters with no attempt inside the longest window, expired scores and\n * expired blacklist entries. Errors are logged and ignored (it runs inside a visitor's request).\n */\nexport async function maybeCleanup(windowSeconds: number, now = Date.now()): Promise<void> {\n\ttry {\n\t\tconst state = await readState();\n\t\tif (now - state.lastCleanup < CLEANUP_MS) return;\n\t\tawait writeRow(STATE_ROW, { ...state, lastCleanup: now });\n\t\tconst since = now - windowSeconds * 1000;\n\t\tconst stale: string[] = [];\n\t\tfor (const row of await rowsWithPrefix(ATTEMPTS)) {\n\t\t\tif (!timesOf(parseJson(row.data)).some((t) => t > since)) stale.push(row.id);\n\t\t}\n\t\tfor (const row of await rowsWithPrefix(SCORE)) {\n\t\t\tconst expiresAt = (parseJson(row.data) as { expiresAt?: unknown } | null)?.expiresAt;\n\t\t\tif (typeof expiresAt !== 'number' || expiresAt <= now) stale.push(row.id);\n\t\t}\n\t\tfor (const entry of await loadEntries()) {\n\t\t\tif (entry.list === 'blacklist' && entry.expiresAt && Date.parse(entry.expiresAt) <= now) {\n\t\t\t\tstale.push(`${ENTRY}${entry.id}`);\n\t\t\t}\n\t\t}\n\t\tawait deleteRows(stale);\n\t\tif (stale.some((id) => id.startsWith(ENTRY))) dropCaches();\n\t} catch (error) {\n\t\tconsole.warn('[ipguard] cleanup failed', error);\n\t}\n}\n"]}
@@ -0,0 +1,9 @@
1
+ import type { AstroGlobal } from 'astro';
2
+ export interface IpGuardViewer {
3
+ isEditor: boolean;
4
+ isAdmin: boolean;
5
+ isLoggedIn: boolean;
6
+ name: string | null;
7
+ }
8
+ export declare function getIpGuardViewer(context: Pick<AstroGlobal, 'cookies'>): Promise<IpGuardViewer>;
9
+ //# sourceMappingURL=viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer.d.ts","sourceRoot":"","sources":["../../src/runtime/viewer.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAOzC,MAAM,WAAW,aAAa;IAC7B,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAID,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAepG"}
@@ -0,0 +1,29 @@
1
+ /// <reference types="studiocms/v/types" />
2
+ /**
3
+ * Who is making a request, via StudioCMS's own session validation (the plugin's
4
+ * routes are outside the dashboard, where StudioCMS doesn't resolve the
5
+ * session itself). Any failure counts as anonymous (fail closed).
6
+ */
7
+ import { User } from 'studiocms:auth/lib';
8
+ import { Effect, runEffect } from 'studiocms/effect';
9
+ const RANK = { visitor: 1, editor: 2, admin: 3, owner: 4 };
10
+ const EDITOR = 2;
11
+ const ADMIN = 3;
12
+ const ANONYMOUS = { isEditor: false, isAdmin: false, isLoggedIn: false, name: null };
13
+ export async function getIpGuardViewer(context) {
14
+ try {
15
+ const data = await runEffect(Effect.gen(function* () {
16
+ const user = yield* User;
17
+ return yield* user.getUserData(context);
18
+ }));
19
+ if (!data?.isLoggedIn || !data.user)
20
+ return ANONYMOUS;
21
+ const rank = RANK[data.permissionLevel] ?? 0;
22
+ return { isEditor: rank >= EDITOR, isAdmin: rank >= ADMIN, isLoggedIn: true, name: data.user.name ?? null };
23
+ }
24
+ catch (error) {
25
+ console.warn('[ipguard] session check failed; treating request as anonymous', error);
26
+ return ANONYMOUS;
27
+ }
28
+ }
29
+ //# sourceMappingURL=viewer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewer.js","sourceRoot":"","sources":["../../src/runtime/viewer.ts"],"names":[],"mappings":"AAAA,2CAA2C;AAC3C;;;;GAIG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE1C,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAErD,MAAM,IAAI,GAA2B,EAAE,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;AACnF,MAAM,MAAM,GAAG,CAAC,CAAC;AACjB,MAAM,KAAK,GAAG,CAAC,CAAC;AAShB,MAAM,SAAS,GAAkB,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAEpG,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,OAAqC;IAC3E,IAAI,CAAC;QACJ,MAAM,IAAI,GAAG,MAAM,SAAS,CAC3B,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YACnB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC;YACzB,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,OAAgB,CAAC,CAAC;QAClD,CAAC,CAAC,CACF,CAAC;QACF,IAAI,CAAC,IAAI,EAAE,UAAU,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAO,SAAS,CAAC;QACtD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QAC7C,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,MAAM,EAAE,OAAO,EAAE,IAAI,IAAI,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,EAAE,CAAC;IAC7G,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,IAAI,CAAC,+DAA+D,EAAE,KAAK,CAAC,CAAC;QACrF,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC","sourcesContent":["/// <reference types=\"studiocms/v/types\" />\n/**\n * Who is making a request, via StudioCMS's own session validation (the plugin's\n * routes are outside the dashboard, where StudioCMS doesn't resolve the\n * session itself). Any failure counts as anonymous (fail closed).\n */\nimport { User } from 'studiocms:auth/lib';\nimport type { AstroGlobal } from 'astro';\nimport { Effect, runEffect } from 'studiocms/effect';\n\nconst RANK: Record<string, number> = { visitor: 1, editor: 2, admin: 3, owner: 4 };\nconst EDITOR = 2;\nconst ADMIN = 3;\n\nexport interface IpGuardViewer {\n\tisEditor: boolean;\n\tisAdmin: boolean;\n\tisLoggedIn: boolean;\n\tname: string | null;\n}\n\nconst ANONYMOUS: IpGuardViewer = { isEditor: false, isAdmin: false, isLoggedIn: false, name: null };\n\nexport async function getIpGuardViewer(context: Pick<AstroGlobal, 'cookies'>): Promise<IpGuardViewer> {\n\ttry {\n\t\tconst data = await runEffect(\n\t\t\tEffect.gen(function* () {\n\t\t\t\tconst user = yield* User;\n\t\t\t\treturn yield* user.getUserData(context as never);\n\t\t\t}),\n\t\t);\n\t\tif (!data?.isLoggedIn || !data.user) return ANONYMOUS;\n\t\tconst rank = RANK[data.permissionLevel] ?? 0;\n\t\treturn { isEditor: rank >= EDITOR, isAdmin: rank >= ADMIN, isLoggedIn: true, name: data.user.name ?? null };\n\t} catch (error) {\n\t\tconsole.warn('[ipguard] session check failed; treating request as anonymous', error);\n\t\treturn ANONYMOUS;\n\t}\n}\n"]}
@@ -0,0 +1,12 @@
1
+ // IP Guard's build-time configuration (written by the plugin from its options).
2
+ declare module 'virtual:ipguard/config' {
3
+ const config: {
4
+ /** Proxies whose X-Forwarded-For is believed (CIDR ranges). */
5
+ trustedProxies: string[];
6
+ /** The AbuseIPDB check endpoint (tests point it at a local fake). */
7
+ abuseIpDbEndpoint: string;
8
+ /** The dashboard page (after the dashboard's base path). */
9
+ dashboardPage: string;
10
+ };
11
+ export default config;
12
+ }