@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.
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/dist/abuseipdb.d.ts +42 -0
- package/dist/abuseipdb.d.ts.map +1 -0
- package/dist/abuseipdb.js +84 -0
- package/dist/abuseipdb.js.map +1 -0
- package/dist/admin.d.ts +41 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +87 -0
- package/dist/admin.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/dist/ip.d.ts +54 -0
- package/dist/ip.d.ts.map +1 -0
- package/dist/ip.js +218 -0
- package/dist/ip.js.map +1 -0
- package/dist/model.d.ts +94 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +148 -0
- package/dist/model.js.map +1 -0
- package/dist/runtime/IpGuardNav.astro +63 -0
- package/dist/runtime/IpGuardPage.astro +408 -0
- package/dist/runtime/admin-endpoint.d.ts +10 -0
- package/dist/runtime/admin-endpoint.d.ts.map +1 -0
- package/dist/runtime/admin-endpoint.js +168 -0
- package/dist/runtime/admin-endpoint.js.map +1 -0
- package/dist/runtime/guard.d.ts +44 -0
- package/dist/runtime/guard.d.ts.map +1 -0
- package/dist/runtime/guard.js +136 -0
- package/dist/runtime/guard.js.map +1 -0
- package/dist/runtime/middleware.d.ts +8 -0
- package/dist/runtime/middleware.d.ts.map +1 -0
- package/dist/runtime/middleware.js +27 -0
- package/dist/runtime/middleware.js.map +1 -0
- package/dist/runtime/store.d.ts +22 -0
- package/dist/runtime/store.d.ts.map +1 -0
- package/dist/runtime/store.js +194 -0
- package/dist/runtime/store.js.map +1 -0
- package/dist/runtime/viewer.d.ts +9 -0
- package/dist/runtime/viewer.d.ts.map +1 -0
- package/dist/runtime/viewer.js +29 -0
- package/dist/runtime/viewer.js.map +1 -0
- package/dist/virtual.d.ts +12 -0
- package/package.json +65 -3
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Dashboard → IP Guard (ADR 0001 §7), admins only: Flagged (the review queue), Blacklist, Banned,
|
|
4
|
+
* Whitelist, Settings and Check an address. Server-rendered with plain forms posting to
|
|
5
|
+
* /_ipguard/admin, which redirects back with `saved` or `error=<code>`. Row buttons belong to a small form
|
|
6
|
+
* per row (`form="…"`), since forms can't nest inside the bulk-selection form.
|
|
7
|
+
*/
|
|
8
|
+
/// <reference types="studiocms/v/types" />
|
|
9
|
+
import { type AdminError, ERRORS } from '../admin.js';
|
|
10
|
+
import { formatCidr, formatIp, parseIp, subjectOf } from '../ip.js';
|
|
11
|
+
import { decide, type IndexedEntry, type ListName, listsOf, WINDOWS } from '../model.js';
|
|
12
|
+
import { abuseIpDb, apiKeyOf, clientIp, disabledByEnvironment } from './guard.js';
|
|
13
|
+
import { countAttempts, loadEntries, loadSettings, scoreStore } from './store.js';
|
|
14
|
+
import { getIpGuardViewer } from './viewer.js';
|
|
15
|
+
|
|
16
|
+
const ROUTE = '/_ipguard/admin';
|
|
17
|
+
const here = Astro.url.pathname;
|
|
18
|
+
const viewer = await getIpGuardViewer(Astro);
|
|
19
|
+
const VIEWS = ['flagged', 'blacklist', 'banned', 'whitelist', 'settings', 'check'] as const;
|
|
20
|
+
type View = (typeof VIEWS)[number];
|
|
21
|
+
const requested = Astro.url.searchParams.get('view');
|
|
22
|
+
const view: View = VIEWS.includes(requested as View) ? (requested as View) : 'flagged';
|
|
23
|
+
const back = view === 'flagged' ? here : `${here}?view=${view}`;
|
|
24
|
+
const errorCode = Astro.url.searchParams.get('error');
|
|
25
|
+
const error = errorCode && errorCode in ERRORS ? ERRORS[errorCode as AdminError] : null;
|
|
26
|
+
const saved = Astro.url.searchParams.has('saved');
|
|
27
|
+
|
|
28
|
+
const entries = viewer.isAdmin ? await loadEntries() : [];
|
|
29
|
+
const settings = await loadSettings();
|
|
30
|
+
const yours = clientIp(Astro);
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const of = (list: ListName) =>
|
|
33
|
+
entries.filter((e) => e.list === list).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
34
|
+
const on = (list: ListName, range: string) => entries.some((e) => e.list === list && e.range === range);
|
|
35
|
+
const when = (iso: string) =>
|
|
36
|
+
`${new Date(iso).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`;
|
|
37
|
+
const SOURCE = { manual: 'Manual', abuseipdb: 'AbuseIPDB', rate_limit: 'Rate limit' } as const;
|
|
38
|
+
const WINDOW_LABEL: Record<number, string> = {
|
|
39
|
+
300: '5 minutes',
|
|
40
|
+
900: '15 minutes',
|
|
41
|
+
1800: '30 minutes',
|
|
42
|
+
3600: '1 hour',
|
|
43
|
+
7200: '2 hours',
|
|
44
|
+
21600: '6 hours',
|
|
45
|
+
43200: '12 hours',
|
|
46
|
+
86400: '24 hours',
|
|
47
|
+
};
|
|
48
|
+
const envKey = Boolean(process.env.ABUSEIPDB_API_KEY?.trim());
|
|
49
|
+
const pausedUntil = viewer.isAdmin ? await abuseIpDb().pausedUntil() : null;
|
|
50
|
+
|
|
51
|
+
// "Check an address": what each step would decide (the Drupal module's pipeline report).
|
|
52
|
+
const checkedText = Astro.url.searchParams.get('ip') ?? '';
|
|
53
|
+
const checked = view === 'check' && checkedText ? parseIp(checkedText) : null;
|
|
54
|
+
let report: null | {
|
|
55
|
+
address: string;
|
|
56
|
+
subject: string;
|
|
57
|
+
lists: ReturnType<typeof listsOf>;
|
|
58
|
+
attempts: number;
|
|
59
|
+
score: number | null;
|
|
60
|
+
decision: ReturnType<typeof decide>;
|
|
61
|
+
} = null;
|
|
62
|
+
if (checked && viewer.isAdmin) {
|
|
63
|
+
const lists = listsOf(entries, checked, now);
|
|
64
|
+
const subject = formatCidr(subjectOf(checked));
|
|
65
|
+
const attempts = await countAttempts(subject, settings.windowSeconds, now);
|
|
66
|
+
const score = await scoreStore.getScore(formatIp(checked)).catch(() => null);
|
|
67
|
+
report = {
|
|
68
|
+
address: formatIp(checked),
|
|
69
|
+
subject,
|
|
70
|
+
lists,
|
|
71
|
+
attempts,
|
|
72
|
+
score,
|
|
73
|
+
decision: decide({ on: lists, attempts, score, settings }),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const OUTCOME: Record<string, string> = {
|
|
77
|
+
allow: 'Allowed: the submission goes through and is counted.',
|
|
78
|
+
ban: 'Banned (and blacklisted): over the rate limit with a high AbuseIPDB score.',
|
|
79
|
+
blacklist: 'Blacklisted: the AbuseIPDB score is at or above the threshold.',
|
|
80
|
+
flag: 'Flagged and blocked until reviewed: over the rate limit.',
|
|
81
|
+
};
|
|
82
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
83
|
+
|
|
84
|
+
const rowForm = (id: string) => `ig-row-${id}`;
|
|
85
|
+
type Row = IndexedEntry;
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
<div class="ipguard" data-ipguard data-view={view}>
|
|
89
|
+
{!viewer.isAdmin && <p class="ipguard__notice ipguard__notice--error" role="alert">Only admins can use IP Guard.</p>}
|
|
90
|
+
{viewer.isAdmin && (
|
|
91
|
+
<>
|
|
92
|
+
{disabledByEnvironment() && (
|
|
93
|
+
<p class="ipguard__notice ipguard__notice--error" role="alert" data-ipguard-env-off>
|
|
94
|
+
IP Guard is switched off by the <code>IPGUARD_DISABLE</code> environment variable: nothing is checked or banned.
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
{!settings.enabled && !disabledByEnvironment() && (
|
|
98
|
+
<p class="ipguard__notice" role="note">IP Guard is turned off in Settings: nothing is checked or banned.</p>
|
|
99
|
+
)}
|
|
100
|
+
{saved && <p class="ipguard__notice" role="status" data-ipguard-saved>Saved.</p>}
|
|
101
|
+
{error && <p class="ipguard__notice ipguard__notice--error" role="alert" data-ipguard-error={errorCode}>{error}</p>}
|
|
102
|
+
{yours && (
|
|
103
|
+
<p class="ipguard__muted">
|
|
104
|
+
Your current address: <code data-ipguard-yours>{formatIp(yours)}</code> (it can't be banned, blacklisted or flagged).
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{view === 'flagged' && (
|
|
109
|
+
<section class="ipguard__group" aria-labelledby="ig-flagged">
|
|
110
|
+
<h2 class="ipguard__title" id="ig-flagged">Flagged</h2>
|
|
111
|
+
<p class="ipguard__muted">
|
|
112
|
+
Addresses that went over the rate limit ({settings.maxAttempts} attempts per {WINDOW_LABEL[settings.windowSeconds]}).
|
|
113
|
+
They <strong>can't submit forms until you decide</strong>: ban (no access to the site at all), blacklist (no
|
|
114
|
+
form submissions), whitelist (never checked again) or dismiss (they can submit again).
|
|
115
|
+
</p>
|
|
116
|
+
{of('flagged').length === 0 ? (
|
|
117
|
+
<p class="ipguard__muted" data-ipguard-empty>Nothing to review.</p>
|
|
118
|
+
) : (
|
|
119
|
+
<form method="post" action={ROUTE} data-ipguard-bulk="flagged">
|
|
120
|
+
<input type="hidden" name="action" value="review" />
|
|
121
|
+
<input type="hidden" name="return" value={back} />
|
|
122
|
+
<table class="ipguard__table">
|
|
123
|
+
<thead>
|
|
124
|
+
<tr><th scope="col"><span class="ipguard__sr">Select</span></th><th scope="col">Address</th><th scope="col">Reason</th><th scope="col">Flagged</th><th scope="col">Also on</th><th scope="col">Actions</th></tr>
|
|
125
|
+
</thead>
|
|
126
|
+
<tbody>
|
|
127
|
+
{of('flagged').map((row: Row) => (
|
|
128
|
+
<tr data-ipguard-row={row.id} data-ipguard-range={row.range}>
|
|
129
|
+
<td><input type="checkbox" name="ids" value={row.id} aria-label={`Select ${row.range}`} /></td>
|
|
130
|
+
<td><code>{row.range}</code></td>
|
|
131
|
+
<td>{row.reason}</td>
|
|
132
|
+
<td>{when(row.createdAt)}</td>
|
|
133
|
+
<td>{[on('blacklist', row.range) && 'Blacklist', on('banned', row.range) && 'Banned'].filter(Boolean).join(', ') || '—'}</td>
|
|
134
|
+
<td class="ipguard__actions">
|
|
135
|
+
<button type="submit" form={rowForm(row.id)} name="op" value="ban" class="ipguard__button ipguard__button--danger" data-ipguard-op="ban">Ban</button>
|
|
136
|
+
<button type="submit" form={rowForm(row.id)} name="op" value="blacklist" class="ipguard__button ipguard__button--danger" data-ipguard-op="blacklist">Blacklist</button>
|
|
137
|
+
<button type="submit" form={rowForm(row.id)} name="op" value="whitelist" class="ipguard__button" data-ipguard-op="whitelist">Whitelist</button>
|
|
138
|
+
<button type="submit" form={rowForm(row.id)} name="op" value="dismiss" class="ipguard__button" data-ipguard-op="dismiss">Dismiss</button>
|
|
139
|
+
</td>
|
|
140
|
+
</tr>
|
|
141
|
+
))}
|
|
142
|
+
</tbody>
|
|
143
|
+
</table>
|
|
144
|
+
<div class="ipguard__bulk">
|
|
145
|
+
<span class="ipguard__muted">Selected:</span>
|
|
146
|
+
<button type="submit" name="op" value="dismiss" class="ipguard__button">Dismiss</button>
|
|
147
|
+
<button type="submit" name="op" value="whitelist" class="ipguard__button">Whitelist</button>
|
|
148
|
+
<button type="submit" name="op" value="blacklist" class="ipguard__button ipguard__button--danger">Blacklist</button>
|
|
149
|
+
<button type="submit" name="op" value="ban" class="ipguard__button ipguard__button--danger">Ban</button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
)}
|
|
153
|
+
{of('flagged').map((row: Row) => (
|
|
154
|
+
<form method="post" action={ROUTE} id={rowForm(row.id)} hidden>
|
|
155
|
+
<input type="hidden" name="action" value="review" />
|
|
156
|
+
<input type="hidden" name="return" value={back} />
|
|
157
|
+
<input type="hidden" name="ids" value={row.id} />
|
|
158
|
+
</form>
|
|
159
|
+
))}
|
|
160
|
+
</section>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{(view === 'blacklist' || view === 'banned' || view === 'whitelist') && (
|
|
164
|
+
<section class="ipguard__group" aria-labelledby="ig-list">
|
|
165
|
+
<h2 class="ipguard__title" id="ig-list">{view === 'blacklist' ? 'Blacklist' : view === 'banned' ? 'Banned' : 'Whitelist'}</h2>
|
|
166
|
+
<p class="ipguard__muted">
|
|
167
|
+
{view === 'blacklist' && 'These addresses can’t submit forms; the site stays reachable. A block can end on a date.'}
|
|
168
|
+
{view === 'banned' && 'These addresses get “Access denied” for every page of the site.'}
|
|
169
|
+
{view === 'whitelist' && 'These addresses are never checked, flagged, blacklisted or banned (your office, for testing).'}
|
|
170
|
+
</p>
|
|
171
|
+
{of(view).length === 0 ? (
|
|
172
|
+
<p class="ipguard__muted" data-ipguard-empty>None yet.</p>
|
|
173
|
+
) : (
|
|
174
|
+
<form method="post" action={ROUTE} data-ipguard-bulk={view}>
|
|
175
|
+
<input type="hidden" name="return" value={back} />
|
|
176
|
+
<table class="ipguard__table">
|
|
177
|
+
<thead>
|
|
178
|
+
<tr>
|
|
179
|
+
<th scope="col"><span class="ipguard__sr">Select</span></th>
|
|
180
|
+
<th scope="col">Address</th>
|
|
181
|
+
<th scope="col">{view === 'whitelist' ? 'Note' : 'Reason'}</th>
|
|
182
|
+
{view === 'blacklist' && <><th scope="col">Source</th><th scope="col">Expires</th></>}
|
|
183
|
+
<th scope="col">Added</th>
|
|
184
|
+
<th scope="col">Actions</th>
|
|
185
|
+
</tr>
|
|
186
|
+
</thead>
|
|
187
|
+
<tbody>
|
|
188
|
+
{of(view).map((row: Row) => {
|
|
189
|
+
const expired = Boolean(row.expiresAt && Date.parse(row.expiresAt) <= now);
|
|
190
|
+
return (
|
|
191
|
+
<tr data-ipguard-row={row.id} data-ipguard-range={row.range}>
|
|
192
|
+
<td><input type="checkbox" name="ids" value={row.id} aria-label={`Select ${row.range}`} /></td>
|
|
193
|
+
<td><code>{row.range}</code></td>
|
|
194
|
+
<td>{row.reason || '—'}</td>
|
|
195
|
+
{view === 'blacklist' && (
|
|
196
|
+
<>
|
|
197
|
+
<td>{SOURCE[row.source]}{row.score !== undefined ? ` (${row.score}%)` : ''}</td>
|
|
198
|
+
<td>{row.expiresAt ? `${when(row.expiresAt)}${expired ? ' (ended)' : ''}` : 'Never'}</td>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
<td>{when(row.createdAt)} · {row.createdBy ?? 'Automatic'}</td>
|
|
202
|
+
<td class="ipguard__actions">
|
|
203
|
+
<button type="submit" form={rowForm(row.id)} name="action" value="remove" class="ipguard__button" data-ipguard-op="remove">Remove</button>
|
|
204
|
+
{view === 'blacklist' && !on('banned', row.range) && (
|
|
205
|
+
<button type="submit" form={rowForm(row.id)} name="action" value="ban" class="ipguard__button ipguard__button--danger" data-ipguard-op="ban">Ban</button>
|
|
206
|
+
)}
|
|
207
|
+
</td>
|
|
208
|
+
</tr>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</tbody>
|
|
212
|
+
</table>
|
|
213
|
+
<div class="ipguard__bulk">
|
|
214
|
+
<span class="ipguard__muted">Selected:</span>
|
|
215
|
+
<button type="submit" name="action" value="remove" class="ipguard__button">Remove</button>
|
|
216
|
+
{view === 'blacklist' && <button type="submit" name="action" value="ban" class="ipguard__button ipguard__button--danger">Ban</button>}
|
|
217
|
+
</div>
|
|
218
|
+
</form>
|
|
219
|
+
)}
|
|
220
|
+
{of(view).map((row: Row) => (
|
|
221
|
+
<form method="post" action={ROUTE} id={rowForm(row.id)} hidden>
|
|
222
|
+
<input type="hidden" name="return" value={back} />
|
|
223
|
+
<input type="hidden" name="ids" value={row.id} />
|
|
224
|
+
</form>
|
|
225
|
+
))}
|
|
226
|
+
|
|
227
|
+
<form method="post" action={ROUTE} class="ipguard__add" data-ipguard-add={view}>
|
|
228
|
+
<input type="hidden" name="action" value="add" />
|
|
229
|
+
<input type="hidden" name="list" value={view} />
|
|
230
|
+
<input type="hidden" name="return" value={back} />
|
|
231
|
+
<h3 class="ipguard__subtitle">Add an address</h3>
|
|
232
|
+
{view === 'banned' && (
|
|
233
|
+
<p class="ipguard__muted">A banned address can't load any page. Your own address and whitelisted ones are refused.</p>
|
|
234
|
+
)}
|
|
235
|
+
<div class="ipguard__fields">
|
|
236
|
+
<label class="ipguard__field">
|
|
237
|
+
<span>Address or range</span>
|
|
238
|
+
<input class="ipguard__input" name="range" required maxlength="64" placeholder="203.0.113.7 or 203.0.113.0/24" autocomplete="off" spellcheck="false" />
|
|
239
|
+
</label>
|
|
240
|
+
<label class="ipguard__field ipguard__field--wide">
|
|
241
|
+
<span>{view === 'whitelist' ? 'Note' : 'Reason'}</span>
|
|
242
|
+
<input class="ipguard__input" name="reason" maxlength="255" placeholder={view === 'whitelist' ? 'e.g. Office' : 'For admins only; never shown to visitors'} />
|
|
243
|
+
</label>
|
|
244
|
+
{view === 'blacklist' && (
|
|
245
|
+
<label class="ipguard__field">
|
|
246
|
+
<span>Ends on (optional)</span>
|
|
247
|
+
<input class="ipguard__input" type="date" name="expires" />
|
|
248
|
+
</label>
|
|
249
|
+
)}
|
|
250
|
+
<button type="submit" class="ipguard__button ipguard__button--primary">Add</button>
|
|
251
|
+
</div>
|
|
252
|
+
</form>
|
|
253
|
+
</section>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{view === 'settings' && (
|
|
257
|
+
<section class="ipguard__group" aria-labelledby="ig-settings">
|
|
258
|
+
<h2 class="ipguard__title" id="ig-settings">Settings</h2>
|
|
259
|
+
<form method="post" action={ROUTE} class="ipguard__settings" data-ipguard-settings>
|
|
260
|
+
<input type="hidden" name="action" value="settings" />
|
|
261
|
+
<input type="hidden" name="return" value={back} />
|
|
262
|
+
<label class="ipguard__check">
|
|
263
|
+
<input type="checkbox" name="enabled" checked={settings.enabled} />
|
|
264
|
+
<span>Check protected actions and enforce bans</span>
|
|
265
|
+
</label>
|
|
266
|
+
<fieldset class="ipguard__fieldset">
|
|
267
|
+
<legend>Rate limit</legend>
|
|
268
|
+
<div class="ipguard__fields">
|
|
269
|
+
<label class="ipguard__field">
|
|
270
|
+
<span>Attempts allowed</span>
|
|
271
|
+
<input class="ipguard__input" type="number" name="maxAttempts" min="1" max="1000" required value={settings.maxAttempts} />
|
|
272
|
+
</label>
|
|
273
|
+
<label class="ipguard__field">
|
|
274
|
+
<span>Per</span>
|
|
275
|
+
<select class="ipguard__input" name="windowSeconds">
|
|
276
|
+
{WINDOWS.map((w) => <option value={w} selected={w === settings.windowSeconds}>{WINDOW_LABEL[w]}</option>)}
|
|
277
|
+
</select>
|
|
278
|
+
</label>
|
|
279
|
+
</div>
|
|
280
|
+
<p class="ipguard__muted">An address over the limit is flagged and blocked until you review it.</p>
|
|
281
|
+
</fieldset>
|
|
282
|
+
<fieldset class="ipguard__fieldset">
|
|
283
|
+
<legend>AbuseIPDB</legend>
|
|
284
|
+
<p class="ipguard__muted">
|
|
285
|
+
With a key, each submitting address is checked against <a href="https://www.abuseipdb.com/" rel="noopener noreferrer">AbuseIPDB</a>
|
|
286
|
+
(results kept 24 hours; the free plan allows 1,000 checks a day). Addresses at or above the score are
|
|
287
|
+
blacklisted; banned if they're also over the rate limit. If AbuseIPDB can't answer, nobody is blocked.
|
|
288
|
+
Mention the check in the site's privacy policy.
|
|
289
|
+
</p>
|
|
290
|
+
{envKey ? (
|
|
291
|
+
<p class="ipguard__muted" data-ipguard-env-key>The key comes from the <code>ABUSEIPDB_API_KEY</code> environment variable.</p>
|
|
292
|
+
) : (
|
|
293
|
+
<div class="ipguard__fields">
|
|
294
|
+
<label class="ipguard__field ipguard__field--wide">
|
|
295
|
+
<span>API key {settings.apiKey ? '(saved; leave empty to keep it)' : ''}</span>
|
|
296
|
+
<input class="ipguard__input" type="password" name="apiKey" autocomplete="off" maxlength="200" />
|
|
297
|
+
</label>
|
|
298
|
+
{settings.apiKey && (
|
|
299
|
+
<label class="ipguard__check"><input type="checkbox" name="clearKey" /><span>Remove the saved key</span></label>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
<div class="ipguard__fields">
|
|
304
|
+
<label class="ipguard__field">
|
|
305
|
+
<span>Blacklist at score (0–100)</span>
|
|
306
|
+
<input class="ipguard__input" type="number" name="minScore" min="0" max="100" required value={settings.minScore} />
|
|
307
|
+
</label>
|
|
308
|
+
</div>
|
|
309
|
+
{pausedUntil && (
|
|
310
|
+
<p class="ipguard__notice" data-ipguard-paused>
|
|
311
|
+
Lookups are paused until {when(new Date(pausedUntil).toISOString())} (the key was refused or the daily limit was reached).
|
|
312
|
+
<button type="submit" form="ig-resume" class="ipguard__button">Resume now</button>
|
|
313
|
+
</p>
|
|
314
|
+
)}
|
|
315
|
+
</fieldset>
|
|
316
|
+
<label class="ipguard__field">
|
|
317
|
+
<span>Message for refused visitors (never says why)</span>
|
|
318
|
+
<textarea class="ipguard__input" name="blockMessage" rows="3" maxlength="512" required>{settings.blockMessage}</textarea>
|
|
319
|
+
</label>
|
|
320
|
+
<div><button type="submit" class="ipguard__button ipguard__button--primary">Save settings</button></div>
|
|
321
|
+
</form>
|
|
322
|
+
<form method="post" action={ROUTE} id="ig-resume" hidden>
|
|
323
|
+
<input type="hidden" name="action" value="resume" />
|
|
324
|
+
<input type="hidden" name="return" value={back} />
|
|
325
|
+
</form>
|
|
326
|
+
</section>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{view === 'check' && (
|
|
330
|
+
<section class="ipguard__group" aria-labelledby="ig-check">
|
|
331
|
+
<h2 class="ipguard__title" id="ig-check">Check an address</h2>
|
|
332
|
+
<p class="ipguard__muted">What would happen to a form submission from this address right now, step by step.</p>
|
|
333
|
+
<form method="get" action={here} class="ipguard__fields" data-ipguard-check>
|
|
334
|
+
<input type="hidden" name="view" value="check" />
|
|
335
|
+
<label class="ipguard__field">
|
|
336
|
+
<span>Address</span>
|
|
337
|
+
<input class="ipguard__input" name="ip" required maxlength="64" value={checkedText} autocomplete="off" spellcheck="false" />
|
|
338
|
+
</label>
|
|
339
|
+
<button type="submit" class="ipguard__button ipguard__button--primary">Check</button>
|
|
340
|
+
</form>
|
|
341
|
+
{checkedText && !checked && <p class="ipguard__notice ipguard__notice--error" role="alert">That isn't a valid address.</p>}
|
|
342
|
+
{report && (
|
|
343
|
+
<ol class="ipguard__report" data-ipguard-report>
|
|
344
|
+
<li>Address <code>{report.address}</code>; automatic actions apply to <code>{report.subject}</code>.</li>
|
|
345
|
+
<li>{report.lists.whitelist ? `Whitelisted (${report.lists.whitelist.range}): always allowed.` : 'Not whitelisted.'}</li>
|
|
346
|
+
<li>{report.lists.banned ? `Banned (${report.lists.banned.range}): no access to the site.` : 'Not banned.'}</li>
|
|
347
|
+
<li>{report.lists.blacklist ? `Blacklisted (${report.lists.blacklist.range}): submissions refused.` : 'Not blacklisted.'}</li>
|
|
348
|
+
<li>{report.lists.flagged ? `Flagged (${report.lists.flagged.range}): blocked until reviewed.` : 'Not flagged.'}</li>
|
|
349
|
+
<li>Rate limit: {report.attempts} of {settings.maxAttempts} attempts in the last {WINDOW_LABEL[settings.windowSeconds]}.</li>
|
|
350
|
+
<li>
|
|
351
|
+
AbuseIPDB: {report.score !== null ? `score ${report.score}% (threshold ${settings.minScore}%).` : 'not looked up yet.'}
|
|
352
|
+
{report.score === null && apiKeyOf(settings) && (
|
|
353
|
+
<form method="post" action={ROUTE} class="ipguard__inline">
|
|
354
|
+
<input type="hidden" name="action" value="lookup" />
|
|
355
|
+
<input type="hidden" name="ip" value={report.address} />
|
|
356
|
+
<input type="hidden" name="return" value={`${here}?view=check`} />
|
|
357
|
+
<button type="submit" class="ipguard__button" data-ipguard-lookup>Ask AbuseIPDB now (uses one lookup)</button>
|
|
358
|
+
</form>
|
|
359
|
+
)}
|
|
360
|
+
</li>
|
|
361
|
+
<li data-ipguard-outcome={report.decision.action}>
|
|
362
|
+
<strong>Outcome: {report.decision.action === 'refuse' ? `refused (${report.decision.because}).` : OUTCOME[report.decision.action]}</strong>
|
|
363
|
+
</li>
|
|
364
|
+
</ol>
|
|
365
|
+
)}
|
|
366
|
+
</section>
|
|
367
|
+
)}
|
|
368
|
+
</>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<style>
|
|
373
|
+
.ipguard { display: flex; flex-direction: column; gap: 1rem; padding-bottom: 2rem; color: var(--text-normal, inherit); }
|
|
374
|
+
.ipguard__group { display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; background: var(--background-step-1); border: 1px solid var(--border); border-radius: var(--radius-md, 0.5rem); }
|
|
375
|
+
.ipguard__title { margin: 0; font-size: 1.125rem; }
|
|
376
|
+
.ipguard__subtitle { margin: 0.5rem 0 0; font-size: 1rem; }
|
|
377
|
+
.ipguard__muted { margin: 0; color: var(--text-muted, inherit); font-size: 0.875rem; line-height: 1.5; }
|
|
378
|
+
.ipguard__notice { margin: 0; padding: 0.6rem 0.8rem; background: var(--background-step-1); border: 1px solid var(--border); border-radius: var(--radius-md, 0.5rem); }
|
|
379
|
+
.ipguard__notice--error { border-left: 4px solid var(--danger-base, hsl(339 80% 45%)); }
|
|
380
|
+
.ipguard__table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
|
381
|
+
.ipguard__table th, .ipguard__table td { padding: 0.45rem 0.5rem; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
|
|
382
|
+
.ipguard__table th { font-weight: 600; }
|
|
383
|
+
.ipguard code {
|
|
384
|
+
font-size: 0.8125rem;
|
|
385
|
+
overflow-wrap: anywhere;
|
|
386
|
+
}
|
|
387
|
+
.ipguard__table code {
|
|
388
|
+
white-space: nowrap;
|
|
389
|
+
}
|
|
390
|
+
.ipguard__actions { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
|
391
|
+
.ipguard__bulk { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.5rem; }
|
|
392
|
+
.ipguard__add, .ipguard__settings { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
393
|
+
.ipguard__fields { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
|
|
394
|
+
.ipguard__field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.875rem; min-width: 12rem; }
|
|
395
|
+
.ipguard__field--wide { flex: 1 1 18rem; }
|
|
396
|
+
.ipguard__input { padding: 0.45rem 0.6rem; font: inherit; color: inherit; background: var(--background-base); border: 1px solid var(--border); border-radius: calc(var(--radius-md, 0.5rem) - 0.125rem); }
|
|
397
|
+
.ipguard__check { display: flex; gap: 0.5rem; align-items: center; font-size: 0.875rem; }
|
|
398
|
+
.ipguard__fieldset { display: flex; flex-direction: column; gap: 0.6rem; margin: 0; padding: 0.75rem; border: 1px solid var(--border); border-radius: var(--radius-md, 0.5rem); }
|
|
399
|
+
.ipguard__fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
|
400
|
+
.ipguard__button { padding: 0.35rem 0.7rem; font: inherit; font-size: 0.8125rem; color: inherit; background: var(--background-base); border: 1px solid var(--border); border-radius: calc(var(--radius-md, 0.5rem) - 0.125rem); cursor: pointer; }
|
|
401
|
+
.ipguard__button:hover { border-color: var(--primary-base); }
|
|
402
|
+
.ipguard__button--primary { color: var(--text-inverted, #fff); background: var(--primary-base); border-color: var(--primary-base); }
|
|
403
|
+
.ipguard__button--danger { color: #fff; background: var(--danger-base, hsl(339 80% 45%)); border-color: var(--danger-base, hsl(339 80% 45%)); }
|
|
404
|
+
.ipguard__button:focus-visible, .ipguard__input:focus-visible, .ipguard__check input:focus-visible { outline: 2px solid var(--primary-base); outline-offset: 2px; }
|
|
405
|
+
.ipguard__report { display: flex; flex-direction: column; gap: 0.4rem; margin: 0; padding-left: 1.25rem; font-size: 0.9375rem; }
|
|
406
|
+
.ipguard__inline { display: inline; margin-left: 0.5rem; }
|
|
407
|
+
.ipguard__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
|
408
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /_ipguard/admin`: the dashboard's forms (ADR 0001 §7). Admins only, same origin. Each form names
|
|
3
|
+
* an `action` and where to go back (`return`, a path on this site); the answer redirects there with
|
|
4
|
+
* `saved` or `error=<code>` (admin.ts). Every way of blocking an address goes through the lockout guard
|
|
5
|
+
* (`refusalFor`): never the admin's own address, never a whitelisted one.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIRoute } from 'astro';
|
|
8
|
+
export declare const prerender = false;
|
|
9
|
+
export declare const POST: APIRoute;
|
|
10
|
+
//# sourceMappingURL=admin-endpoint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-endpoint.d.ts","sourceRoot":"","sources":["../../src/runtime/admin-endpoint.ts"],"names":[],"mappings":"AACA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAiBtC,eAAO,MAAM,SAAS,QAAQ,CAAC;AAwD/B,eAAO,MAAM,IAAI,EAAE,QA4HlB,CAAC"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { parseAdd, parseSettingsForm, selectedIds } from '../admin.js';
|
|
2
|
+
import { formatCidr, formatIp, isSingleAddress, parseCidr, parseIp, subjectOf } from '../ip.js';
|
|
3
|
+
import { refusalFor } from '../model.js';
|
|
4
|
+
import { abuseIpDb, apiKeyOf, clientIp } from './guard.js';
|
|
5
|
+
import { clearAttempts, deleteEntries, forgetScore, loadEntries, loadSettings, newEntryId, saveEntry, saveSettings, } from './store.js';
|
|
6
|
+
import { getIpGuardViewer } from './viewer.js';
|
|
7
|
+
export const prerender = false;
|
|
8
|
+
const MAX_BODY = 64 * 1024;
|
|
9
|
+
const safeReturn = (value) => typeof value === 'string' && /^\/(?!\/)[^\s\\]{0,500}$/.test(value) ? value : '/';
|
|
10
|
+
function redirect(back, outcome) {
|
|
11
|
+
const url = new URL(back, 'http://x');
|
|
12
|
+
url.searchParams.delete('saved');
|
|
13
|
+
url.searchParams.delete('error');
|
|
14
|
+
if ('saved' in outcome)
|
|
15
|
+
url.searchParams.set('saved', '');
|
|
16
|
+
else if ('error' in outcome)
|
|
17
|
+
url.searchParams.set('error', outcome.error);
|
|
18
|
+
else
|
|
19
|
+
for (const [k, v] of Object.entries(outcome.params))
|
|
20
|
+
url.searchParams.set(k, v);
|
|
21
|
+
return new Response(null, {
|
|
22
|
+
status: 303,
|
|
23
|
+
headers: { Location: `${url.pathname}${url.search}`, 'Cache-Control': 'no-store' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Why this range can't be blocked, as an error code, or null. */
|
|
27
|
+
function guard(list, range, adminIp, entries) {
|
|
28
|
+
return refusalFor(list, range, { adminIp, entries })?.code ?? null;
|
|
29
|
+
}
|
|
30
|
+
const entryFor = (list, range, reason, by, extra = {}) => ({
|
|
31
|
+
id: newEntryId(),
|
|
32
|
+
list,
|
|
33
|
+
range,
|
|
34
|
+
reason,
|
|
35
|
+
source: 'manual',
|
|
36
|
+
createdAt: new Date().toISOString(),
|
|
37
|
+
createdBy: by,
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
/** After a review, forget the flagged range's attempts, so a dismissed address can submit again. */
|
|
41
|
+
async function forgetAttempts(range) {
|
|
42
|
+
await clearAttempts(range);
|
|
43
|
+
const cidr = parseCidr(range);
|
|
44
|
+
if (cidr && cidr.version === 6 && isSingleAddress(cidr)) {
|
|
45
|
+
await clearAttempts(formatCidr(subjectOf({ version: 6, value: cidr.network })));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export const POST = async (context) => {
|
|
49
|
+
if (context.request.headers.get('origin') !== context.url.origin) {
|
|
50
|
+
return new Response('Cross-origin request refused', { status: 403 });
|
|
51
|
+
}
|
|
52
|
+
const viewer = await getIpGuardViewer(context);
|
|
53
|
+
if (!viewer.isAdmin)
|
|
54
|
+
return new Response('Admins only', { status: 403 });
|
|
55
|
+
if (Number(context.request.headers.get('content-length') ?? 0) > MAX_BODY) {
|
|
56
|
+
return new Response('Too large', { status: 413 });
|
|
57
|
+
}
|
|
58
|
+
let form;
|
|
59
|
+
try {
|
|
60
|
+
form = await context.request.formData();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return new Response('Bad form', { status: 400 });
|
|
64
|
+
}
|
|
65
|
+
const back = safeReturn(form.get('return'));
|
|
66
|
+
const by = viewer.name ?? 'admin';
|
|
67
|
+
const adminIp = clientIp(context);
|
|
68
|
+
try {
|
|
69
|
+
const entries = await loadEntries();
|
|
70
|
+
const action = form.get('action');
|
|
71
|
+
if (action === 'add') {
|
|
72
|
+
const request = parseAdd(form);
|
|
73
|
+
if ('error' in request)
|
|
74
|
+
return redirect(back, request);
|
|
75
|
+
const range = formatCidr(request.range);
|
|
76
|
+
if (entries.some((e) => e.list === request.list && e.range === range))
|
|
77
|
+
return redirect(back, { error: 'exists' });
|
|
78
|
+
const refused = guard(request.list, request.range, adminIp, entries);
|
|
79
|
+
if (refused)
|
|
80
|
+
return redirect(back, { error: refused });
|
|
81
|
+
await saveEntry(entryFor(request.list, range, request.reason, by, request.list === 'blacklist' ? { expiresAt: request.expiresAt } : {}));
|
|
82
|
+
return redirect(back, { saved: true });
|
|
83
|
+
}
|
|
84
|
+
if (action === 'remove') {
|
|
85
|
+
const ids = selectedIds(form);
|
|
86
|
+
if (ids.length === 0)
|
|
87
|
+
return redirect(back, { error: 'nothing-selected' });
|
|
88
|
+
await deleteEntries(ids);
|
|
89
|
+
return redirect(back, { saved: true });
|
|
90
|
+
}
|
|
91
|
+
// Flagged review: ban, blacklist, whitelist or dismiss the selected entries.
|
|
92
|
+
if (action === 'review') {
|
|
93
|
+
const op = form.get('op');
|
|
94
|
+
const picked = entries.filter((e) => e.list === 'flagged' && selectedIds(form).includes(e.id));
|
|
95
|
+
if (picked.length === 0)
|
|
96
|
+
return redirect(back, { error: 'nothing-selected' });
|
|
97
|
+
const lists = op === 'ban'
|
|
98
|
+
? ['banned', 'blacklist']
|
|
99
|
+
: op === 'blacklist'
|
|
100
|
+
? ['blacklist']
|
|
101
|
+
: op === 'whitelist'
|
|
102
|
+
? ['whitelist']
|
|
103
|
+
: [];
|
|
104
|
+
if (op !== 'dismiss' && lists.length === 0)
|
|
105
|
+
return redirect(back, { error: 'unknown' });
|
|
106
|
+
for (const flag of picked) {
|
|
107
|
+
for (const list of lists) {
|
|
108
|
+
const refused = guard(list, flag.cidr, adminIp, entries);
|
|
109
|
+
if (refused)
|
|
110
|
+
return redirect(back, { error: refused });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const flag of picked) {
|
|
114
|
+
for (const list of lists) {
|
|
115
|
+
if (entries.some((e) => e.list === list && e.range === flag.range))
|
|
116
|
+
continue;
|
|
117
|
+
await saveEntry(entryFor(list, flag.range, `Flagged: ${flag.reason}`, by, list === 'blacklist' ? { expiresAt: null } : {}));
|
|
118
|
+
}
|
|
119
|
+
await forgetAttempts(flag.range);
|
|
120
|
+
}
|
|
121
|
+
await deleteEntries(picked.map((e) => e.id));
|
|
122
|
+
return redirect(back, { saved: true });
|
|
123
|
+
}
|
|
124
|
+
// Ban blacklisted entries (they stay on the blacklist).
|
|
125
|
+
if (action === 'ban') {
|
|
126
|
+
const picked = entries.filter((e) => e.list === 'blacklist' && selectedIds(form).includes(e.id));
|
|
127
|
+
if (picked.length === 0)
|
|
128
|
+
return redirect(back, { error: 'nothing-selected' });
|
|
129
|
+
for (const entry of picked) {
|
|
130
|
+
const refused = guard('banned', entry.cidr, adminIp, entries);
|
|
131
|
+
if (refused)
|
|
132
|
+
return redirect(back, { error: refused });
|
|
133
|
+
}
|
|
134
|
+
for (const entry of picked) {
|
|
135
|
+
if (!entries.some((e) => e.list === 'banned' && e.range === entry.range)) {
|
|
136
|
+
await saveEntry(entryFor('banned', entry.range, entry.reason || 'From the blacklist', by));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return redirect(back, { saved: true });
|
|
140
|
+
}
|
|
141
|
+
if (action === 'settings') {
|
|
142
|
+
const settings = parseSettingsForm(form, await loadSettings());
|
|
143
|
+
if ('error' in settings)
|
|
144
|
+
return redirect(back, settings);
|
|
145
|
+
await saveSettings(settings);
|
|
146
|
+
return redirect(back, { saved: true });
|
|
147
|
+
}
|
|
148
|
+
if (action === 'resume') {
|
|
149
|
+
await abuseIpDb().resume();
|
|
150
|
+
return redirect(back, { saved: true });
|
|
151
|
+
}
|
|
152
|
+
// "Check an address": ask AbuseIPDB now (uses one lookup; the answer is cached for the report).
|
|
153
|
+
if (action === 'lookup') {
|
|
154
|
+
const ip = parseIp(form.get('ip'));
|
|
155
|
+
if (!ip)
|
|
156
|
+
return redirect(back, { error: 'bad-range' });
|
|
157
|
+
await forgetScore(formatIp(ip));
|
|
158
|
+
await abuseIpDb().check(ip, apiKeyOf(await loadSettings()));
|
|
159
|
+
return redirect(back, { params: { view: 'check', ip: formatIp(ip), looked: '' } });
|
|
160
|
+
}
|
|
161
|
+
return redirect(back, { error: 'unknown' });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error('[ipguard] admin action failed', error);
|
|
165
|
+
return redirect(back, { error: 'failed' });
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
//# sourceMappingURL=admin-endpoint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-endpoint.js","sourceRoot":"","sources":["../../src/runtime/admin-endpoint.ts"],"names":[],"mappings":"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","sourcesContent":["/// <reference types=\"studiocms/v/types\" />\n/**\n * `POST /_ipguard/admin`: the dashboard's forms (ADR 0001 §7). Admins only, same origin. Each form names\n * an `action` and where to go back (`return`, a path on this site); the answer redirects there with\n * `saved` or `error=<code>` (admin.ts). Every way of blocking an address goes through the lockout guard\n * (`refusalFor`): never the admin's own address, never a whitelisted one.\n */\nimport type { APIRoute } from 'astro';\nimport { type AdminError, parseAdd, parseSettingsForm, selectedIds } from '../admin.js';\nimport { formatCidr, formatIp, isSingleAddress, parseCidr, parseIp, subjectOf } from '../ip.js';\nimport { type Entry, type IndexedEntry, type ListName, refusalFor } from '../model.js';\nimport { abuseIpDb, apiKeyOf, clientIp } from './guard.js';\nimport {\n\tclearAttempts,\n\tdeleteEntries,\n\tforgetScore,\n\tloadEntries,\n\tloadSettings,\n\tnewEntryId,\n\tsaveEntry,\n\tsaveSettings,\n} from './store.js';\nimport { getIpGuardViewer } from './viewer.js';\n\nexport const prerender = false;\n\nconst MAX_BODY = 64 * 1024;\n\nconst safeReturn = (value: unknown) =>\n\ttypeof value === 'string' && /^\\/(?!\\/)[^\\s\\\\]{0,500}$/.test(value) ? value : '/';\n\nfunction redirect(back: string, outcome: { saved: true } | { error: AdminError } | { params: Record<string, string> }) {\n\tconst url = new URL(back, 'http://x');\n\turl.searchParams.delete('saved');\n\turl.searchParams.delete('error');\n\tif ('saved' in outcome) url.searchParams.set('saved', '');\n\telse if ('error' in outcome) url.searchParams.set('error', outcome.error);\n\telse for (const [k, v] of Object.entries(outcome.params)) url.searchParams.set(k, v);\n\treturn new Response(null, {\n\t\tstatus: 303,\n\t\theaders: { Location: `${url.pathname}${url.search}`, 'Cache-Control': 'no-store' },\n\t});\n}\n\n/** Why this range can't be blocked, as an error code, or null. */\nfunction guard(\n\tlist: ListName,\n\trange: IndexedEntry['cidr'],\n\tadminIp: ReturnType<typeof clientIp>,\n\tentries: IndexedEntry[],\n) {\n\treturn refusalFor(list, range, { adminIp, entries })?.code ?? null;\n}\n\nconst entryFor = (\n\tlist: ListName,\n\trange: string,\n\treason: string,\n\tby: string | null,\n\textra: Partial<Entry> = {},\n): Entry => ({\n\tid: newEntryId(),\n\tlist,\n\trange,\n\treason,\n\tsource: 'manual',\n\tcreatedAt: new Date().toISOString(),\n\tcreatedBy: by,\n\t...extra,\n});\n\n/** After a review, forget the flagged range's attempts, so a dismissed address can submit again. */\nasync function forgetAttempts(range: string) {\n\tawait clearAttempts(range);\n\tconst cidr = parseCidr(range);\n\tif (cidr && cidr.version === 6 && isSingleAddress(cidr)) {\n\t\tawait clearAttempts(formatCidr(subjectOf({ version: 6, value: cidr.network })));\n\t}\n}\n\nexport const POST: APIRoute = async (context) => {\n\tif (context.request.headers.get('origin') !== context.url.origin) {\n\t\treturn new Response('Cross-origin request refused', { status: 403 });\n\t}\n\tconst viewer = await getIpGuardViewer(context);\n\tif (!viewer.isAdmin) return new Response('Admins only', { status: 403 });\n\tif (Number(context.request.headers.get('content-length') ?? 0) > MAX_BODY) {\n\t\treturn new Response('Too large', { status: 413 });\n\t}\n\tlet form: FormData;\n\ttry {\n\t\tform = await context.request.formData();\n\t} catch {\n\t\treturn new Response('Bad form', { status: 400 });\n\t}\n\tconst back = safeReturn(form.get('return'));\n\tconst by = viewer.name ?? 'admin';\n\tconst adminIp = clientIp(context);\n\n\ttry {\n\t\tconst entries = await loadEntries();\n\t\tconst action = form.get('action');\n\n\t\tif (action === 'add') {\n\t\t\tconst request = parseAdd(form);\n\t\t\tif ('error' in request) return redirect(back, request);\n\t\t\tconst range = formatCidr(request.range);\n\t\t\tif (entries.some((e) => e.list === request.list && e.range === range)) return redirect(back, { error: 'exists' });\n\t\t\tconst refused = guard(request.list, request.range, adminIp, entries);\n\t\t\tif (refused) return redirect(back, { error: refused });\n\t\t\tawait saveEntry(\n\t\t\t\tentryFor(\n\t\t\t\t\trequest.list,\n\t\t\t\t\trange,\n\t\t\t\t\trequest.reason,\n\t\t\t\t\tby,\n\t\t\t\t\trequest.list === 'blacklist' ? { expiresAt: request.expiresAt } : {},\n\t\t\t\t),\n\t\t\t);\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\tif (action === 'remove') {\n\t\t\tconst ids = selectedIds(form);\n\t\t\tif (ids.length === 0) return redirect(back, { error: 'nothing-selected' });\n\t\t\tawait deleteEntries(ids);\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\t// Flagged review: ban, blacklist, whitelist or dismiss the selected entries.\n\t\tif (action === 'review') {\n\t\t\tconst op = form.get('op');\n\t\t\tconst picked = entries.filter((e) => e.list === 'flagged' && selectedIds(form).includes(e.id));\n\t\t\tif (picked.length === 0) return redirect(back, { error: 'nothing-selected' });\n\t\t\tconst lists: ListName[] =\n\t\t\t\top === 'ban'\n\t\t\t\t\t? ['banned', 'blacklist']\n\t\t\t\t\t: op === 'blacklist'\n\t\t\t\t\t\t? ['blacklist']\n\t\t\t\t\t\t: op === 'whitelist'\n\t\t\t\t\t\t\t? ['whitelist']\n\t\t\t\t\t\t\t: [];\n\t\t\tif (op !== 'dismiss' && lists.length === 0) return redirect(back, { error: 'unknown' });\n\t\t\tfor (const flag of picked) {\n\t\t\t\tfor (const list of lists) {\n\t\t\t\t\tconst refused = guard(list, flag.cidr, adminIp, entries);\n\t\t\t\t\tif (refused) return redirect(back, { error: refused });\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor (const flag of picked) {\n\t\t\t\tfor (const list of lists) {\n\t\t\t\t\tif (entries.some((e) => e.list === list && e.range === flag.range)) continue;\n\t\t\t\t\tawait saveEntry(\n\t\t\t\t\t\tentryFor(list, flag.range, `Flagged: ${flag.reason}`, by, list === 'blacklist' ? { expiresAt: null } : {}),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tawait forgetAttempts(flag.range);\n\t\t\t}\n\t\t\tawait deleteEntries(picked.map((e) => e.id));\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\t// Ban blacklisted entries (they stay on the blacklist).\n\t\tif (action === 'ban') {\n\t\t\tconst picked = entries.filter((e) => e.list === 'blacklist' && selectedIds(form).includes(e.id));\n\t\t\tif (picked.length === 0) return redirect(back, { error: 'nothing-selected' });\n\t\t\tfor (const entry of picked) {\n\t\t\t\tconst refused = guard('banned', entry.cidr, adminIp, entries);\n\t\t\t\tif (refused) return redirect(back, { error: refused });\n\t\t\t}\n\t\t\tfor (const entry of picked) {\n\t\t\t\tif (!entries.some((e) => e.list === 'banned' && e.range === entry.range)) {\n\t\t\t\t\tawait saveEntry(entryFor('banned', entry.range, entry.reason || 'From the blacklist', by));\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\tif (action === 'settings') {\n\t\t\tconst settings = parseSettingsForm(form, await loadSettings());\n\t\t\tif ('error' in settings) return redirect(back, settings);\n\t\t\tawait saveSettings(settings);\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\tif (action === 'resume') {\n\t\t\tawait abuseIpDb().resume();\n\t\t\treturn redirect(back, { saved: true });\n\t\t}\n\n\t\t// \"Check an address\": ask AbuseIPDB now (uses one lookup; the answer is cached for the report).\n\t\tif (action === 'lookup') {\n\t\t\tconst ip = parseIp(form.get('ip'));\n\t\t\tif (!ip) return redirect(back, { error: 'bad-range' });\n\t\t\tawait forgetScore(formatIp(ip));\n\t\t\tawait abuseIpDb().check(ip, apiKeyOf(await loadSettings()));\n\t\t\treturn redirect(back, { params: { view: 'check', ip: formatIp(ip), looked: '' } });\n\t\t}\n\n\t\treturn redirect(back, { error: 'unknown' });\n\t} catch (error) {\n\t\tconsole.error('[ipguard] admin action failed', error);\n\t\treturn redirect(back, { error: 'failed' });\n\t}\n};\n"]}
|