@leadertechie/personal-site-kit 0.1.0-alpha.4 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/handlers/auth-handler.d.ts +2 -0
- package/dist/api/handlers/auth-handler.d.ts.map +1 -0
- package/dist/api/handlers/auth.d.ts +23 -0
- package/dist/api/handlers/auth.d.ts.map +1 -0
- package/dist/api/handlers/content.d.ts.map +1 -1
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/website-api.d.ts +1 -1
- package/dist/api/website-api.d.ts.map +1 -1
- package/dist/api.js +17 -2
- package/dist/chunks/{website-api-CVsi-OLc.js → website-api-DI3muo2s.js} +335 -23
- package/dist/index.js +27 -13
- package/package.json +1 -1
- package/src/api/handlers/auth-handler.ts +181 -0
- package/src/api/handlers/auth.ts +157 -0
- package/src/api/handlers/content.ts +81 -14
- package/src/api/index.ts +2 -0
- package/src/api/website-api.ts +22 -16
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-handler.d.ts","sourceRoot":"","sources":["../../../src/api/handlers/auth-handler.ts"],"names":[],"mappings":"AAkBA,wBAAsB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAkC/F"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
declare const AUTH_KV = "auth_store";
|
|
2
|
+
declare const RATE_LIMIT_KV = "rate_limit";
|
|
3
|
+
declare const MAX_ATTEMPTS = 5;
|
|
4
|
+
declare const BASE_DELAY_MS = 1000;
|
|
5
|
+
interface AuthStore {
|
|
6
|
+
username: string;
|
|
7
|
+
passwordHash: string;
|
|
8
|
+
salt: string;
|
|
9
|
+
}
|
|
10
|
+
declare function hashPassword(password: string, salt: string): Promise<string>;
|
|
11
|
+
declare function generateSalt(): Promise<string>;
|
|
12
|
+
declare function checkRateLimit(env: any, ip: string): Promise<{
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
delayMs: number;
|
|
15
|
+
}>;
|
|
16
|
+
declare function recordFailedAttempt(env: any, ip: string): Promise<void>;
|
|
17
|
+
declare function clearRateLimit(env: any, ip: string): Promise<void>;
|
|
18
|
+
declare function getAuthStore(env: any): Promise<AuthStore | null>;
|
|
19
|
+
declare function setupAuth(env: any, username: string, password: string): Promise<void>;
|
|
20
|
+
declare function verifyCredentials(env: any, username: string, password: string): Promise<boolean>;
|
|
21
|
+
declare function getClientIP(request: Request): string;
|
|
22
|
+
export { hashPassword, generateSalt, checkRateLimit, recordFailedAttempt, clearRateLimit, getAuthStore, setupAuth, verifyCredentials, getClientIP, AUTH_KV, RATE_LIMIT_KV, MAX_ATTEMPTS, BASE_DELAY_MS };
|
|
23
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/api/handlers/auth.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,OAAO,eAAe,CAAC;AAC7B,QAAA,MAAM,aAAa,eAAe,CAAC;AACnC,QAAA,MAAM,YAAY,IAAI,CAAC;AACvB,QAAA,MAAM,aAAa,OAAO,CAAC;AAG3B,UAAU,SAAS;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;CACd;AAQD,iBAAe,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuB3E;AAED,iBAAe,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAG7C;AAED,iBAAe,cAAc,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CA2BlG;AAED,iBAAe,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBtE;AAED,iBAAe,cAAc,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGjE;AAED,iBAAe,YAAY,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAG/D;AAED,iBAAe,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASpF;AAED,iBAAe,iBAAiB,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa/F;AAED,iBAAS,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAI7C;AAED,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,cAAc,EACd,YAAY,EACZ,SAAS,EACT,iBAAiB,EACjB,WAAW,EACX,OAAO,EACP,aAAa,EACb,YAAY,EACZ,aAAa,EACd,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../src/api/handlers/content.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../src/api/handlers/content.ts"],"names":[],"mappings":"AAkBA,wBAAsB,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAiElG"}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WebsiteAPI } from './website-api';
|
|
2
2
|
export { WebsiteAPI };
|
|
3
3
|
export type { APIHandler } from './website-api';
|
|
4
|
+
export * from './handlers/auth';
|
|
5
|
+
export * from './handlers/auth-handler';
|
|
4
6
|
declare const defaultAPI: WebsiteAPI;
|
|
5
7
|
export default defaultAPI;
|
|
6
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,CAAC;AACtB,YAAY,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,CAAC;AACtB,YAAY,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AAGxC,QAAA,MAAM,UAAU,YAAmB,CAAC;AACpC,eAAe,UAAU,CAAC"}
|
|
@@ -3,8 +3,8 @@ export declare class WebsiteAPI {
|
|
|
3
3
|
private customHandlers;
|
|
4
4
|
registerHandler(route: string, handler: APIHandler): void;
|
|
5
5
|
private addCORSHeaders;
|
|
6
|
+
private addAdminCORSHeaders;
|
|
6
7
|
private handleCORS;
|
|
7
|
-
private requireAuth;
|
|
8
8
|
fetch(request: Request, env: any): Promise<Response>;
|
|
9
9
|
}
|
|
10
10
|
//# sourceMappingURL=website-api.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"website-api.d.ts","sourceRoot":"","sources":["../../src/api/website-api.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"website-api.d.ts","sourceRoot":"","sources":["../../src/api/website-api.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE3E,qBAAa,UAAU;IACrB,OAAO,CAAC,cAAc,CAAiC;IAEhD,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU;IAIzD,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,UAAU;IAYL,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC;CAkFlE"}
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { W as WebsiteAPI } from "./chunks/website-api-
|
|
1
|
+
import { W as WebsiteAPI } from "./chunks/website-api-DI3muo2s.js";
|
|
2
|
+
import { A, B, M, R, c, a, g, b, d, h, e, r, s, v } from "./chunks/website-api-DI3muo2s.js";
|
|
2
3
|
const defaultAPI = new WebsiteAPI();
|
|
3
4
|
export {
|
|
5
|
+
A as AUTH_KV,
|
|
6
|
+
B as BASE_DELAY_MS,
|
|
7
|
+
M as MAX_ATTEMPTS,
|
|
8
|
+
R as RATE_LIMIT_KV,
|
|
4
9
|
WebsiteAPI,
|
|
5
|
-
|
|
10
|
+
c as checkRateLimit,
|
|
11
|
+
a as clearRateLimit,
|
|
12
|
+
defaultAPI as default,
|
|
13
|
+
g as generateSalt,
|
|
14
|
+
b as getAuthStore,
|
|
15
|
+
d as getClientIP,
|
|
16
|
+
h as handleAuth,
|
|
17
|
+
e as hashPassword,
|
|
18
|
+
r as recordFailedAttempt,
|
|
19
|
+
s as setupAuth,
|
|
20
|
+
v as verifyCredentials
|
|
6
21
|
};
|
|
@@ -147,13 +147,171 @@ async function handleInfo() {
|
|
|
147
147
|
]
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
|
+
const AUTH_KV = "auth_store";
|
|
151
|
+
const RATE_LIMIT_KV = "rate_limit";
|
|
152
|
+
const MAX_ATTEMPTS = 5;
|
|
153
|
+
const BASE_DELAY_MS = 1e3;
|
|
154
|
+
const MAX_DELAY_MS = 6e4;
|
|
155
|
+
async function hashPassword(password, salt) {
|
|
156
|
+
const encoder = new TextEncoder();
|
|
157
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
158
|
+
"raw",
|
|
159
|
+
encoder.encode(password),
|
|
160
|
+
"PBKDF2",
|
|
161
|
+
false,
|
|
162
|
+
["deriveBits"]
|
|
163
|
+
);
|
|
164
|
+
const saltBuffer = encoder.encode(salt);
|
|
165
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
166
|
+
{
|
|
167
|
+
name: "PBKDF2",
|
|
168
|
+
salt: saltBuffer,
|
|
169
|
+
iterations: 1e5,
|
|
170
|
+
hash: "SHA-256"
|
|
171
|
+
},
|
|
172
|
+
keyMaterial,
|
|
173
|
+
256
|
|
174
|
+
);
|
|
175
|
+
return btoa(String.fromCharCode(...new Uint8Array(derivedBits)));
|
|
176
|
+
}
|
|
177
|
+
async function generateSalt() {
|
|
178
|
+
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
|
|
179
|
+
return btoa(String.fromCharCode(...saltBytes));
|
|
180
|
+
}
|
|
181
|
+
async function checkRateLimit(env, ip) {
|
|
182
|
+
const kvKey = `rate:${ip}`;
|
|
183
|
+
const entry = await env.KV.get(kvKey, "json");
|
|
184
|
+
if (!entry) {
|
|
185
|
+
return { allowed: true, delayMs: 0 };
|
|
186
|
+
}
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const windowMs = 15 * 60 * 1e3;
|
|
189
|
+
if (now - entry.firstAttempt > windowMs) {
|
|
190
|
+
return { allowed: true, delayMs: 0 };
|
|
191
|
+
}
|
|
192
|
+
if (entry.attempts >= MAX_ATTEMPTS) {
|
|
193
|
+
const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, entry.attempts - MAX_ATTEMPTS), MAX_DELAY_MS);
|
|
194
|
+
const timeSinceLast = now - entry.lastAttempt;
|
|
195
|
+
if (timeSinceLast < delayMs) {
|
|
196
|
+
return { allowed: false, delayMs: delayMs - timeSinceLast };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { allowed: true, delayMs: 0 };
|
|
200
|
+
}
|
|
201
|
+
async function recordFailedAttempt(env, ip) {
|
|
202
|
+
const kvKey = `rate:${ip}`;
|
|
203
|
+
const entry = await env.KV.get(kvKey, "json");
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const windowMs = 15 * 60 * 1e3;
|
|
206
|
+
if (!entry || now - entry.firstAttempt > windowMs) {
|
|
207
|
+
await env.KV.put(kvKey, JSON.stringify({
|
|
208
|
+
attempts: 1,
|
|
209
|
+
firstAttempt: now,
|
|
210
|
+
lastAttempt: now
|
|
211
|
+
}), { expirationTtl: Math.ceil(windowMs / 1e3) + 60 });
|
|
212
|
+
} else {
|
|
213
|
+
entry.attempts++;
|
|
214
|
+
entry.lastAttempt = now;
|
|
215
|
+
await env.KV.put(kvKey, JSON.stringify(entry), {
|
|
216
|
+
expirationTtl: Math.ceil(windowMs / 1e3) + 60
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function clearRateLimit(env, ip) {
|
|
221
|
+
const kvKey = `rate:${ip}`;
|
|
222
|
+
await env.KV.delete(kvKey);
|
|
223
|
+
}
|
|
224
|
+
async function getAuthStore(env) {
|
|
225
|
+
const store = await env.KV.get(AUTH_KV, "json");
|
|
226
|
+
return store;
|
|
227
|
+
}
|
|
228
|
+
async function setupAuth(env, username, password) {
|
|
229
|
+
const salt = await generateSalt();
|
|
230
|
+
const passwordHash = await hashPassword(password, salt);
|
|
231
|
+
await env.KV.put(AUTH_KV, JSON.stringify({
|
|
232
|
+
username,
|
|
233
|
+
passwordHash,
|
|
234
|
+
salt
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
async function verifyCredentials(env, username, password) {
|
|
238
|
+
const store = await getAuthStore(env);
|
|
239
|
+
if (!store) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
if (username !== store.username) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const hash = await hashPassword(password, store.salt);
|
|
246
|
+
return hash === store.passwordHash;
|
|
247
|
+
}
|
|
248
|
+
function getClientIP(request) {
|
|
249
|
+
return request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || "unknown";
|
|
250
|
+
}
|
|
251
|
+
function getSessionToken(request) {
|
|
252
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
253
|
+
if (!cookieHeader) return null;
|
|
254
|
+
const match = cookieHeader.split(";").find((c) => c.trim().startsWith("session="));
|
|
255
|
+
return match?.split("=")[1] || null;
|
|
256
|
+
}
|
|
150
257
|
async function handleContent(request, env, subpath) {
|
|
151
258
|
const bucket = env.CONTENT_BUCKET;
|
|
152
259
|
if (!bucket) {
|
|
153
260
|
return createErrorResponse("Content bucket not configured", 500);
|
|
154
261
|
}
|
|
155
262
|
const method = request.method;
|
|
156
|
-
|
|
263
|
+
const clientIP = getClientIP(request);
|
|
264
|
+
const rateCheck = await checkRateLimit(env, clientIP);
|
|
265
|
+
if (!rateCheck.allowed) {
|
|
266
|
+
return new Response(JSON.stringify({
|
|
267
|
+
error: "Too many failed attempts. Please wait.",
|
|
268
|
+
retryAfter: Math.ceil(rateCheck.delayMs / 1e3)
|
|
269
|
+
}), {
|
|
270
|
+
status: 429,
|
|
271
|
+
headers: {
|
|
272
|
+
"Content-Type": "application/json",
|
|
273
|
+
"Retry-After": String(Math.ceil(rateCheck.delayMs / 1e3))
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const store = await getAuthStore(env);
|
|
278
|
+
if (!store) {
|
|
279
|
+
if (method === "GET") {
|
|
280
|
+
return handleGet(request, bucket, subpath);
|
|
281
|
+
}
|
|
282
|
+
return createErrorResponse("Admin not configured. Use POST /auth/setup to configure.", 401);
|
|
283
|
+
}
|
|
284
|
+
const sessionToken = getSessionToken(request);
|
|
285
|
+
let isAuthenticated = false;
|
|
286
|
+
if (sessionToken) {
|
|
287
|
+
const session = await env.KV.get(`session:${sessionToken}`, "json");
|
|
288
|
+
if (session && session.expiresAt > Date.now()) {
|
|
289
|
+
isAuthenticated = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const authHeader = request.headers.get("Authorization");
|
|
293
|
+
if (!isAuthenticated && authHeader?.startsWith("Basic ")) {
|
|
294
|
+
try {
|
|
295
|
+
const credentials = atob(authHeader.slice(6));
|
|
296
|
+
const [username, password] = credentials.split(":");
|
|
297
|
+
if (await verifyCredentials(env, username, password)) {
|
|
298
|
+
isAuthenticated = true;
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!isAuthenticated) {
|
|
304
|
+
await recordFailedAttempt(env, clientIP);
|
|
305
|
+
return createErrorResponse("Unauthorized", 401);
|
|
306
|
+
}
|
|
307
|
+
await clearRateLimit(env, clientIP);
|
|
308
|
+
if (method === "GET") {
|
|
309
|
+
return handleGet(request, bucket, subpath);
|
|
310
|
+
}
|
|
311
|
+
return handleWrite(request, bucket, subpath, env, method);
|
|
312
|
+
}
|
|
313
|
+
async function handleGet(request, bucket, subpath) {
|
|
314
|
+
if (request.method === "GET" && (!subpath || subpath === "/")) {
|
|
157
315
|
try {
|
|
158
316
|
const list = await bucket.list();
|
|
159
317
|
return createJSONResponse(list.objects.map((o) => ({
|
|
@@ -166,7 +324,7 @@ async function handleContent(request, env, subpath) {
|
|
|
166
324
|
return createErrorResponse("Failed to list content: " + e.message, 500);
|
|
167
325
|
}
|
|
168
326
|
}
|
|
169
|
-
if (method === "GET" && subpath) {
|
|
327
|
+
if (request.method === "GET" && subpath) {
|
|
170
328
|
try {
|
|
171
329
|
const object = await bucket.get(subpath);
|
|
172
330
|
if (!object) {
|
|
@@ -180,11 +338,9 @@ async function handleContent(request, env, subpath) {
|
|
|
180
338
|
return createErrorResponse("Failed to get content: " + e.message, 500);
|
|
181
339
|
}
|
|
182
340
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return createErrorResponse("Unauthorized", 401);
|
|
187
|
-
}
|
|
341
|
+
return createErrorResponse("Method not allowed", 405);
|
|
342
|
+
}
|
|
343
|
+
async function handleWrite(request, bucket, subpath, env, method) {
|
|
188
344
|
if (method === "PUT" && subpath) {
|
|
189
345
|
try {
|
|
190
346
|
await bucket.put(subpath, request.body);
|
|
@@ -203,6 +359,146 @@ async function handleContent(request, env, subpath) {
|
|
|
203
359
|
}
|
|
204
360
|
return createErrorResponse("Method not allowed", 405);
|
|
205
361
|
}
|
|
362
|
+
function createSessionCookie(token, secure) {
|
|
363
|
+
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toUTCString();
|
|
364
|
+
const SameSite = secure ? "Strict" : "Lax";
|
|
365
|
+
return `session=${token}; HttpOnly; Secure; SameSite=${SameSite}; Path=/; Expires=${expires}`;
|
|
366
|
+
}
|
|
367
|
+
async function handleAuth(request, env, subpath) {
|
|
368
|
+
request.method;
|
|
369
|
+
const clientIP = getClientIP(request);
|
|
370
|
+
const path = subpath.replace(/^\//, "").split("/")[0];
|
|
371
|
+
const url = new URL(request.url);
|
|
372
|
+
const isSecure = url.protocol === "https:";
|
|
373
|
+
const rateCheck = await checkRateLimit(env, clientIP);
|
|
374
|
+
if (!rateCheck.allowed) {
|
|
375
|
+
return new Response(JSON.stringify({
|
|
376
|
+
error: "Too many failed attempts. Please wait.",
|
|
377
|
+
retryAfter: Math.ceil(rateCheck.delayMs / 1e3)
|
|
378
|
+
}), {
|
|
379
|
+
status: 429,
|
|
380
|
+
headers: {
|
|
381
|
+
"Content-Type": "application/json",
|
|
382
|
+
"Retry-After": String(Math.ceil(rateCheck.delayMs / 1e3))
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
switch (path) {
|
|
387
|
+
case "setup":
|
|
388
|
+
return handleSetup(request, env, clientIP, isSecure);
|
|
389
|
+
case "status":
|
|
390
|
+
return handleStatus(env);
|
|
391
|
+
case "login":
|
|
392
|
+
return handleLogin(request, env, clientIP, isSecure);
|
|
393
|
+
case "logout":
|
|
394
|
+
return handleLogout(request, env);
|
|
395
|
+
default:
|
|
396
|
+
return createErrorResponse("Not found", 404);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function handleSetup(request, env, clientIP, isSecure) {
|
|
400
|
+
if (request.method !== "POST") {
|
|
401
|
+
return createErrorResponse("Method not allowed", 405);
|
|
402
|
+
}
|
|
403
|
+
const existing = await getAuthStore(env);
|
|
404
|
+
if (existing) {
|
|
405
|
+
return createErrorResponse("Admin already configured. Use /auth/login to login.", 400);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const body = await request.json();
|
|
409
|
+
const { username, password } = body;
|
|
410
|
+
if (!username || !password) {
|
|
411
|
+
return createErrorResponse("Username and password required", 400);
|
|
412
|
+
}
|
|
413
|
+
if (username.length < 3 || password.length < 8) {
|
|
414
|
+
return createErrorResponse("Username must be 3+ chars, password must be 8+ chars", 400);
|
|
415
|
+
}
|
|
416
|
+
await setupAuth(env, username, password);
|
|
417
|
+
await clearRateLimit(env, clientIP);
|
|
418
|
+
const token = crypto.randomUUID();
|
|
419
|
+
await env.KV.put(`session:${token}`, JSON.stringify({
|
|
420
|
+
createdAt: Date.now(),
|
|
421
|
+
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1e3
|
|
422
|
+
}), { expirationTtl: 7 * 24 * 60 * 60 });
|
|
423
|
+
const headers = {
|
|
424
|
+
"Content-Type": "application/json",
|
|
425
|
+
"Set-Cookie": createSessionCookie(token, isSecure)
|
|
426
|
+
};
|
|
427
|
+
return new Response(JSON.stringify({
|
|
428
|
+
success: true,
|
|
429
|
+
message: "Admin configured successfully"
|
|
430
|
+
}), {
|
|
431
|
+
status: 201,
|
|
432
|
+
headers
|
|
433
|
+
});
|
|
434
|
+
} catch (e) {
|
|
435
|
+
return createErrorResponse("Invalid request body", 400);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function handleStatus(env) {
|
|
439
|
+
const store = await getAuthStore(env);
|
|
440
|
+
return createJSONResponse({
|
|
441
|
+
configured: !!store,
|
|
442
|
+
username: store?.username || null
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function handleLogin(request, env, clientIP, isSecure) {
|
|
446
|
+
if (request.method !== "POST") {
|
|
447
|
+
return createErrorResponse("Method not allowed", 405);
|
|
448
|
+
}
|
|
449
|
+
const store = await getAuthStore(env);
|
|
450
|
+
if (!store) {
|
|
451
|
+
return createErrorResponse("Admin not configured. Use POST /auth/setup first.", 401);
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const body = await request.json();
|
|
455
|
+
const { username, password } = body;
|
|
456
|
+
if (!username || !password) {
|
|
457
|
+
return createErrorResponse("Username and password required", 400);
|
|
458
|
+
}
|
|
459
|
+
if (await verifyCredentials(env, username, password)) {
|
|
460
|
+
await clearRateLimit(env, clientIP);
|
|
461
|
+
const token = crypto.randomUUID();
|
|
462
|
+
await env.KV.put(`session:${token}`, JSON.stringify({
|
|
463
|
+
createdAt: Date.now(),
|
|
464
|
+
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1e3
|
|
465
|
+
}), { expirationTtl: 7 * 24 * 60 * 60 });
|
|
466
|
+
const headers = {
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
"Set-Cookie": createSessionCookie(token, isSecure)
|
|
469
|
+
};
|
|
470
|
+
return new Response(JSON.stringify({
|
|
471
|
+
success: true,
|
|
472
|
+
message: "Login successful"
|
|
473
|
+
}), {
|
|
474
|
+
status: 200,
|
|
475
|
+
headers
|
|
476
|
+
});
|
|
477
|
+
} else {
|
|
478
|
+
await recordFailedAttempt(env, clientIP);
|
|
479
|
+
return createErrorResponse("Invalid credentials", 401);
|
|
480
|
+
}
|
|
481
|
+
} catch (e) {
|
|
482
|
+
return createErrorResponse("Invalid request body", 400);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function handleLogout(request, env) {
|
|
486
|
+
if (request.method !== "POST") {
|
|
487
|
+
return createErrorResponse("Method not allowed", 405);
|
|
488
|
+
}
|
|
489
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
490
|
+
const sessionToken = cookieHeader?.split(";").find((c) => c.trim().startsWith("session="))?.split("=")[1];
|
|
491
|
+
if (sessionToken) {
|
|
492
|
+
await env.KV.delete(`session:${sessionToken}`);
|
|
493
|
+
}
|
|
494
|
+
return new Response(JSON.stringify({ success: true, message: "Logged out" }), {
|
|
495
|
+
status: 200,
|
|
496
|
+
headers: {
|
|
497
|
+
"Content-Type": "application/json",
|
|
498
|
+
"Set-Cookie": "session=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0"
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
206
502
|
const contentCache = /* @__PURE__ */ new Map();
|
|
207
503
|
const CACHE_TTL = 5 * 60 * 1e3;
|
|
208
504
|
async function getCachedOrFetch(key, fetchFn) {
|
|
@@ -504,6 +800,13 @@ class WebsiteAPI {
|
|
|
504
800
|
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
505
801
|
return response;
|
|
506
802
|
}
|
|
803
|
+
addAdminCORSHeaders(response) {
|
|
804
|
+
response.headers.set("Access-Control-Allow-Origin", "same-origin");
|
|
805
|
+
response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
806
|
+
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
807
|
+
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
808
|
+
return response;
|
|
809
|
+
}
|
|
507
810
|
handleCORS() {
|
|
508
811
|
return new Response(null, {
|
|
509
812
|
status: 200,
|
|
@@ -515,17 +818,6 @@ class WebsiteAPI {
|
|
|
515
818
|
}
|
|
516
819
|
});
|
|
517
820
|
}
|
|
518
|
-
requireAuth(request, env) {
|
|
519
|
-
const authHeader = request.headers.get("Authorization");
|
|
520
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
521
|
-
return createErrorResponse("Unauthorized", 401);
|
|
522
|
-
}
|
|
523
|
-
const token = authHeader.slice(7);
|
|
524
|
-
if (token !== env?.ADMIN_API_KEY) {
|
|
525
|
-
return createErrorResponse("Unauthorized", 401);
|
|
526
|
-
}
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
821
|
async fetch(request, env) {
|
|
530
822
|
const url = new URL(request.url);
|
|
531
823
|
if (request.method === "OPTIONS") {
|
|
@@ -540,7 +832,7 @@ class WebsiteAPI {
|
|
|
540
832
|
try {
|
|
541
833
|
if (route === "content" || route.startsWith("content/")) {
|
|
542
834
|
const subpath = route.replace(/^content\/?/, "");
|
|
543
|
-
return this.
|
|
835
|
+
return this.addAdminCORSHeaders(await handleContent(request, env, subpath));
|
|
544
836
|
}
|
|
545
837
|
switch (route) {
|
|
546
838
|
case "info":
|
|
@@ -548,16 +840,22 @@ class WebsiteAPI {
|
|
|
548
840
|
case "home":
|
|
549
841
|
return this.addCORSHeaders(await handleHome(env));
|
|
550
842
|
case "cache-clear":
|
|
551
|
-
const
|
|
552
|
-
|
|
843
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
844
|
+
const sessionToken = cookieHeader?.split(";").find((c) => c.trim().startsWith("session="))?.split("=")[1];
|
|
845
|
+
const session = sessionToken ? await env.KV.get(`session:${sessionToken}`, "json") : null;
|
|
846
|
+
if (!session || session.expiresAt < Date.now()) {
|
|
847
|
+
return this.addAdminCORSHeaders(createErrorResponse("Unauthorized", 401));
|
|
848
|
+
}
|
|
553
849
|
clearContentCache();
|
|
554
|
-
return this.
|
|
850
|
+
return this.addAdminCORSHeaders(new Response(JSON.stringify({ success: true, message: "Cache cleared" }), { status: 200 }));
|
|
555
851
|
case "aboutme":
|
|
556
852
|
return this.addCORSHeaders(await handleAboutMe(env));
|
|
557
853
|
case "logo":
|
|
558
854
|
return this.addCORSHeaders(await handleLogo(env));
|
|
559
855
|
case "static":
|
|
560
856
|
return this.addCORSHeaders(await handleStaticDetails(env));
|
|
857
|
+
case "auth":
|
|
858
|
+
return this.addAdminCORSHeaders(await handleAuth(request, env, "/auth"));
|
|
561
859
|
case "blogs":
|
|
562
860
|
return this.addCORSHeaders(await handleBlogs(env));
|
|
563
861
|
case "blogs/latest":
|
|
@@ -592,5 +890,19 @@ class WebsiteAPI {
|
|
|
592
890
|
}
|
|
593
891
|
}
|
|
594
892
|
export {
|
|
595
|
-
|
|
893
|
+
AUTH_KV as A,
|
|
894
|
+
BASE_DELAY_MS as B,
|
|
895
|
+
MAX_ATTEMPTS as M,
|
|
896
|
+
RATE_LIMIT_KV as R,
|
|
897
|
+
WebsiteAPI as W,
|
|
898
|
+
clearRateLimit as a,
|
|
899
|
+
getAuthStore as b,
|
|
900
|
+
checkRateLimit as c,
|
|
901
|
+
getClientIP as d,
|
|
902
|
+
hashPassword as e,
|
|
903
|
+
generateSalt as g,
|
|
904
|
+
handleAuth as h,
|
|
905
|
+
recordFailedAttempt as r,
|
|
906
|
+
setupAuth as s,
|
|
907
|
+
verifyCredentials as v
|
|
596
908
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
|
-
import { W } from "./chunks/website-api-
|
|
1
|
+
import { A, B, M, R, W, c, a, g, b, d, h, e, r, s, v } from "./chunks/website-api-DI3muo2s.js";
|
|
2
2
|
import { WebsitePrerender } from "./prerender.js";
|
|
3
|
-
import { A, B, F, M, a, S } from "./chunks/index-VimKeB5W.js";
|
|
4
|
-
import { R, S as S2, T, W as W2, b, c, g, a as
|
|
3
|
+
import { A as A2, B as B2, F, M as M2, a as a2, S } from "./chunks/index-VimKeB5W.js";
|
|
4
|
+
import { R as R2, S as S2, T, W as W2, b as b2, c as c2, g as g2, a as a3, i, r as r2 } from "./chunks/template-MawmknFQ.js";
|
|
5
5
|
export {
|
|
6
|
-
A as
|
|
7
|
-
|
|
6
|
+
A as AUTH_KV,
|
|
7
|
+
A2 as AdminPortal,
|
|
8
|
+
B as BASE_DELAY_MS,
|
|
9
|
+
B2 as BlogViewer,
|
|
8
10
|
F as FooterComponent,
|
|
9
|
-
M as
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
M as MAX_ATTEMPTS,
|
|
12
|
+
M2 as MyAboutme,
|
|
13
|
+
a2 as MyBanner,
|
|
14
|
+
R as RATE_LIMIT_KV,
|
|
15
|
+
R2 as Router,
|
|
12
16
|
S2 as SiteStore,
|
|
13
17
|
S as StoryViewer,
|
|
14
18
|
T as ThemeToggle,
|
|
15
19
|
W as WebsiteAPI,
|
|
16
20
|
WebsitePrerender,
|
|
17
21
|
W2 as WebsiteUI,
|
|
18
|
-
|
|
19
|
-
c as
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
b2 as bootstrap,
|
|
23
|
+
c as checkRateLimit,
|
|
24
|
+
a as clearRateLimit,
|
|
25
|
+
c2 as createHtmlTemplate,
|
|
26
|
+
g2 as generatePageContent,
|
|
27
|
+
g as generateSalt,
|
|
28
|
+
b as getAuthStore,
|
|
29
|
+
d as getClientIP,
|
|
30
|
+
a3 as getConfig,
|
|
31
|
+
h as handleAuth,
|
|
32
|
+
e as hashPassword,
|
|
22
33
|
i as initializeConfig,
|
|
23
|
-
r as
|
|
34
|
+
r as recordFailedAttempt,
|
|
35
|
+
r2 as renderMarkdown,
|
|
36
|
+
s as setupAuth,
|
|
37
|
+
v as verifyCredentials
|
|
24
38
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createJSONResponse, createErrorResponse } from '../utils';
|
|
2
|
+
import {
|
|
3
|
+
setupAuth,
|
|
4
|
+
getAuthStore,
|
|
5
|
+
checkRateLimit,
|
|
6
|
+
recordFailedAttempt,
|
|
7
|
+
clearRateLimit,
|
|
8
|
+
verifyCredentials,
|
|
9
|
+
getClientIP,
|
|
10
|
+
MAX_ATTEMPTS
|
|
11
|
+
} from './auth';
|
|
12
|
+
|
|
13
|
+
function createSessionCookie(token: string, secure: boolean): string {
|
|
14
|
+
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toUTCString();
|
|
15
|
+
const SameSite = secure ? 'Strict' : 'Lax';
|
|
16
|
+
return `session=${token}; HttpOnly; Secure; SameSite=${SameSite}; Path=/; Expires=${expires}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function handleAuth(request: Request, env: any, subpath: string): Promise<Response> {
|
|
20
|
+
const method = request.method;
|
|
21
|
+
const clientIP = getClientIP(request);
|
|
22
|
+
const path = subpath.replace(/^\//, '').split('/')[0];
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
const isSecure = url.protocol === 'https:';
|
|
25
|
+
|
|
26
|
+
// Check rate limit for login attempts
|
|
27
|
+
const rateCheck = await checkRateLimit(env, clientIP);
|
|
28
|
+
if (!rateCheck.allowed) {
|
|
29
|
+
return new Response(JSON.stringify({
|
|
30
|
+
error: 'Too many failed attempts. Please wait.',
|
|
31
|
+
retryAfter: Math.ceil(rateCheck.delayMs / 1000)
|
|
32
|
+
}), {
|
|
33
|
+
status: 429,
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'Retry-After': String(Math.ceil(rateCheck.delayMs / 1000))
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
switch (path) {
|
|
42
|
+
case 'setup':
|
|
43
|
+
return handleSetup(request, env, clientIP, isSecure);
|
|
44
|
+
case 'status':
|
|
45
|
+
return handleStatus(env);
|
|
46
|
+
case 'login':
|
|
47
|
+
return handleLogin(request, env, clientIP, isSecure);
|
|
48
|
+
case 'logout':
|
|
49
|
+
return handleLogout(request, env);
|
|
50
|
+
default:
|
|
51
|
+
return createErrorResponse('Not found', 404);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleSetup(request: Request, env: any, clientIP: string, isSecure: boolean): Promise<Response> {
|
|
56
|
+
if (request.method !== 'POST') {
|
|
57
|
+
return createErrorResponse('Method not allowed', 405);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const existing = await getAuthStore(env);
|
|
61
|
+
if (existing) {
|
|
62
|
+
return createErrorResponse('Admin already configured. Use /auth/login to login.', 400);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const body = await request.json();
|
|
67
|
+
const { username, password } = body;
|
|
68
|
+
|
|
69
|
+
if (!username || !password) {
|
|
70
|
+
return createErrorResponse('Username and password required', 400);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (username.length < 3 || password.length < 8) {
|
|
74
|
+
return createErrorResponse('Username must be 3+ chars, password must be 8+ chars', 400);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await setupAuth(env, username, password);
|
|
78
|
+
await clearRateLimit(env, clientIP);
|
|
79
|
+
|
|
80
|
+
const token = crypto.randomUUID();
|
|
81
|
+
await env.KV.put(`session:${token}`, JSON.stringify({
|
|
82
|
+
createdAt: Date.now(),
|
|
83
|
+
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000)
|
|
84
|
+
}), { expirationTtl: 7 * 24 * 60 * 60 });
|
|
85
|
+
|
|
86
|
+
const headers: Record<string, string> = {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
'Set-Cookie': createSessionCookie(token, isSecure)
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return new Response(JSON.stringify({
|
|
92
|
+
success: true,
|
|
93
|
+
message: 'Admin configured successfully'
|
|
94
|
+
}), {
|
|
95
|
+
status: 201,
|
|
96
|
+
headers
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return createErrorResponse('Invalid request body', 400);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleStatus(env: any): Promise<Response> {
|
|
104
|
+
const store = await getAuthStore(env);
|
|
105
|
+
|
|
106
|
+
return createJSONResponse({
|
|
107
|
+
configured: !!store,
|
|
108
|
+
username: store?.username || null
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleLogin(request: Request, env: any, clientIP: string, isSecure: boolean): Promise<Response> {
|
|
113
|
+
if (request.method !== 'POST') {
|
|
114
|
+
return createErrorResponse('Method not allowed', 405);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const store = await getAuthStore(env);
|
|
118
|
+
if (!store) {
|
|
119
|
+
return createErrorResponse('Admin not configured. Use POST /auth/setup first.', 401);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const body = await request.json();
|
|
124
|
+
const { username, password } = body;
|
|
125
|
+
|
|
126
|
+
if (!username || !password) {
|
|
127
|
+
return createErrorResponse('Username and password required', 400);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (await verifyCredentials(env, username, password)) {
|
|
131
|
+
await clearRateLimit(env, clientIP);
|
|
132
|
+
|
|
133
|
+
const token = crypto.randomUUID();
|
|
134
|
+
await env.KV.put(`session:${token}`, JSON.stringify({
|
|
135
|
+
createdAt: Date.now(),
|
|
136
|
+
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000)
|
|
137
|
+
}), { expirationTtl: 7 * 24 * 60 * 60 });
|
|
138
|
+
|
|
139
|
+
const headers: Record<string, string> = {
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
'Set-Cookie': createSessionCookie(token, isSecure)
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return new Response(JSON.stringify({
|
|
145
|
+
success: true,
|
|
146
|
+
message: 'Login successful'
|
|
147
|
+
}), {
|
|
148
|
+
status: 200,
|
|
149
|
+
headers
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
await recordFailedAttempt(env, clientIP);
|
|
153
|
+
return createErrorResponse('Invalid credentials', 401);
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return createErrorResponse('Invalid request body', 400);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function handleLogout(request: Request, env: any): Promise<Response> {
|
|
161
|
+
if (request.method !== 'POST') {
|
|
162
|
+
return createErrorResponse('Method not allowed', 405);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
166
|
+
const sessionToken = cookieHeader?.split(';')
|
|
167
|
+
.find(c => c.trim().startsWith('session='))
|
|
168
|
+
?.split('=')[1];
|
|
169
|
+
|
|
170
|
+
if (sessionToken) {
|
|
171
|
+
await env.KV.delete(`session:${sessionToken}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return new Response(JSON.stringify({ success: true, message: 'Logged out' }), {
|
|
175
|
+
status: 200,
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
'Set-Cookie': 'session=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0'
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const AUTH_KV = 'auth_store';
|
|
2
|
+
const RATE_LIMIT_KV = 'rate_limit';
|
|
3
|
+
const MAX_ATTEMPTS = 5;
|
|
4
|
+
const BASE_DELAY_MS = 1000; // 1 second
|
|
5
|
+
const MAX_DELAY_MS = 60000; // 1 minute
|
|
6
|
+
|
|
7
|
+
interface AuthStore {
|
|
8
|
+
username: string;
|
|
9
|
+
passwordHash: string;
|
|
10
|
+
salt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RateLimitEntry {
|
|
14
|
+
attempts: number;
|
|
15
|
+
firstAttempt: number;
|
|
16
|
+
lastAttempt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function hashPassword(password: string, salt: string): Promise<string> {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
22
|
+
'raw',
|
|
23
|
+
encoder.encode(password),
|
|
24
|
+
'PBKDF2',
|
|
25
|
+
false,
|
|
26
|
+
['deriveBits']
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const saltBuffer = encoder.encode(salt);
|
|
30
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
31
|
+
{
|
|
32
|
+
name: 'PBKDF2',
|
|
33
|
+
salt: saltBuffer,
|
|
34
|
+
iterations: 100000,
|
|
35
|
+
hash: 'SHA-256'
|
|
36
|
+
},
|
|
37
|
+
keyMaterial,
|
|
38
|
+
256
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return btoa(String.fromCharCode(...new Uint8Array(derivedBits)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function generateSalt(): Promise<string> {
|
|
45
|
+
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
|
|
46
|
+
return btoa(String.fromCharCode(...saltBytes));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function checkRateLimit(env: any, ip: string): Promise<{ allowed: boolean; delayMs: number }> {
|
|
50
|
+
const kvKey = `rate:${ip}`;
|
|
51
|
+
const entry = await env.KV.get(kvKey, 'json') as RateLimitEntry | null;
|
|
52
|
+
|
|
53
|
+
if (!entry) {
|
|
54
|
+
return { allowed: true, delayMs: 0 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const windowMs = 15 * 60 * 1000; // 15 minute window
|
|
59
|
+
|
|
60
|
+
// Reset if window expired
|
|
61
|
+
if (now - entry.firstAttempt > windowMs) {
|
|
62
|
+
return { allowed: true, delayMs: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if exceeded max attempts
|
|
66
|
+
if (entry.attempts >= MAX_ATTEMPTS) {
|
|
67
|
+
const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, entry.attempts - MAX_ATTEMPTS), MAX_DELAY_MS);
|
|
68
|
+
const timeSinceLast = now - entry.lastAttempt;
|
|
69
|
+
|
|
70
|
+
if (timeSinceLast < delayMs) {
|
|
71
|
+
return { allowed: false, delayMs: delayMs - timeSinceLast };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { allowed: true, delayMs: 0 };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function recordFailedAttempt(env: any, ip: string): Promise<void> {
|
|
79
|
+
const kvKey = `rate:${ip}`;
|
|
80
|
+
const entry = await env.KV.get(kvKey, 'json') as RateLimitEntry | null;
|
|
81
|
+
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const windowMs = 15 * 60 * 1000;
|
|
84
|
+
|
|
85
|
+
if (!entry || now - entry.firstAttempt > windowMs) {
|
|
86
|
+
// Start new window
|
|
87
|
+
await env.KV.put(kvKey, JSON.stringify({
|
|
88
|
+
attempts: 1,
|
|
89
|
+
firstAttempt: now,
|
|
90
|
+
lastAttempt: now
|
|
91
|
+
}), { expirationTtl: Math.ceil(windowMs / 1000) + 60 });
|
|
92
|
+
} else {
|
|
93
|
+
entry.attempts++;
|
|
94
|
+
entry.lastAttempt = now;
|
|
95
|
+
await env.KV.put(kvKey, JSON.stringify(entry), {
|
|
96
|
+
expirationTtl: Math.ceil(windowMs / 1000) + 60
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function clearRateLimit(env: any, ip: string): Promise<void> {
|
|
102
|
+
const kvKey = `rate:${ip}`;
|
|
103
|
+
await env.KV.delete(kvKey);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getAuthStore(env: any): Promise<AuthStore | null> {
|
|
107
|
+
const store = await env.KV.get(AUTH_KV, 'json') as AuthStore | null;
|
|
108
|
+
return store;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function setupAuth(env: any, username: string, password: string): Promise<void> {
|
|
112
|
+
const salt = await generateSalt();
|
|
113
|
+
const passwordHash = await hashPassword(password, salt);
|
|
114
|
+
|
|
115
|
+
await env.KV.put(AUTH_KV, JSON.stringify({
|
|
116
|
+
username,
|
|
117
|
+
passwordHash,
|
|
118
|
+
salt
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function verifyCredentials(env: any, username: string, password: string): Promise<boolean> {
|
|
123
|
+
const store = await getAuthStore(env);
|
|
124
|
+
|
|
125
|
+
if (!store) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (username !== store.username) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const hash = await hashPassword(password, store.salt);
|
|
134
|
+
return hash === store.passwordHash;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getClientIP(request: Request): string {
|
|
138
|
+
return request.headers.get('CF-Connecting-IP') ||
|
|
139
|
+
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
|
|
140
|
+
'unknown';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
hashPassword,
|
|
145
|
+
generateSalt,
|
|
146
|
+
checkRateLimit,
|
|
147
|
+
recordFailedAttempt,
|
|
148
|
+
clearRateLimit,
|
|
149
|
+
getAuthStore,
|
|
150
|
+
setupAuth,
|
|
151
|
+
verifyCredentials,
|
|
152
|
+
getClientIP,
|
|
153
|
+
AUTH_KV,
|
|
154
|
+
RATE_LIMIT_KV,
|
|
155
|
+
MAX_ATTEMPTS,
|
|
156
|
+
BASE_DELAY_MS
|
|
157
|
+
};
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { createJSONResponse, createErrorResponse } from '../utils';
|
|
2
|
+
import {
|
|
3
|
+
checkRateLimit,
|
|
4
|
+
recordFailedAttempt,
|
|
5
|
+
clearRateLimit,
|
|
6
|
+
verifyCredentials,
|
|
7
|
+
getClientIP,
|
|
8
|
+
getAuthStore
|
|
9
|
+
} from './auth';
|
|
10
|
+
|
|
11
|
+
function getSessionToken(request: Request): string | null {
|
|
12
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
13
|
+
if (!cookieHeader) return null;
|
|
14
|
+
const match = cookieHeader.split(';')
|
|
15
|
+
.find(c => c.trim().startsWith('session='));
|
|
16
|
+
return match?.split('=')[1] || null;
|
|
17
|
+
}
|
|
2
18
|
|
|
3
19
|
export async function handleContent(request: Request, env: any, subpath: string): Promise<Response> {
|
|
4
20
|
const bucket = env.CONTENT_BUCKET;
|
|
@@ -7,9 +23,68 @@ export async function handleContent(request: Request, env: any, subpath: string)
|
|
|
7
23
|
}
|
|
8
24
|
|
|
9
25
|
const method = request.method;
|
|
26
|
+
const clientIP = getClientIP(request);
|
|
27
|
+
|
|
28
|
+
const rateCheck = await checkRateLimit(env, clientIP);
|
|
29
|
+
if (!rateCheck.allowed) {
|
|
30
|
+
return new Response(JSON.stringify({
|
|
31
|
+
error: 'Too many failed attempts. Please wait.',
|
|
32
|
+
retryAfter: Math.ceil(rateCheck.delayMs / 1000)
|
|
33
|
+
}), {
|
|
34
|
+
status: 429,
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
'Retry-After': String(Math.ceil(rateCheck.delayMs / 1000))
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const store = await getAuthStore(env);
|
|
43
|
+
|
|
44
|
+
if (!store) {
|
|
45
|
+
if (method === 'GET') {
|
|
46
|
+
return handleGet(request, bucket, subpath);
|
|
47
|
+
}
|
|
48
|
+
return createErrorResponse('Admin not configured. Use POST /auth/setup to configure.', 401);
|
|
49
|
+
}
|
|
10
50
|
|
|
11
|
-
|
|
12
|
-
|
|
51
|
+
const sessionToken = getSessionToken(request);
|
|
52
|
+
let isAuthenticated = false;
|
|
53
|
+
|
|
54
|
+
if (sessionToken) {
|
|
55
|
+
const session = await env.KV.get(`session:${sessionToken}`, 'json');
|
|
56
|
+
if (session && session.expiresAt > Date.now()) {
|
|
57
|
+
isAuthenticated = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const authHeader = request.headers.get('Authorization');
|
|
62
|
+
if (!isAuthenticated && authHeader?.startsWith('Basic ')) {
|
|
63
|
+
try {
|
|
64
|
+
const credentials = atob(authHeader.slice(6));
|
|
65
|
+
const [username, password] = credentials.split(':');
|
|
66
|
+
if (await verifyCredentials(env, username, password)) {
|
|
67
|
+
isAuthenticated = true;
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isAuthenticated) {
|
|
73
|
+
await recordFailedAttempt(env, clientIP);
|
|
74
|
+
return createErrorResponse('Unauthorized', 401);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await clearRateLimit(env, clientIP);
|
|
78
|
+
|
|
79
|
+
if (method === 'GET') {
|
|
80
|
+
return handleGet(request, bucket, subpath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return handleWrite(request, bucket, subpath, env, method);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleGet(request: Request, bucket: any, subpath: string): Promise<Response> {
|
|
87
|
+
if (request.method === 'GET' && (!subpath || subpath === '/')) {
|
|
13
88
|
try {
|
|
14
89
|
const list = await bucket.list();
|
|
15
90
|
return createJSONResponse(list.objects.map((o: any) => ({
|
|
@@ -23,8 +98,7 @@ export async function handleContent(request: Request, env: any, subpath: string)
|
|
|
23
98
|
}
|
|
24
99
|
}
|
|
25
100
|
|
|
26
|
-
|
|
27
|
-
if (method === 'GET' && subpath) {
|
|
101
|
+
if (request.method === 'GET' && subpath) {
|
|
28
102
|
try {
|
|
29
103
|
const object = await bucket.get(subpath);
|
|
30
104
|
if (!object) {
|
|
@@ -39,16 +113,10 @@ export async function handleContent(request: Request, env: any, subpath: string)
|
|
|
39
113
|
}
|
|
40
114
|
}
|
|
41
115
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const apiKey = env.ADMIN_API_KEY;
|
|
45
|
-
// Allow if apiKey is not set (dev mode) OR matches
|
|
46
|
-
// WARN: In prod, ADMIN_API_KEY should be set!
|
|
47
|
-
if (apiKey && authHeader !== `Bearer ${apiKey}`) {
|
|
48
|
-
return createErrorResponse('Unauthorized', 401);
|
|
49
|
-
}
|
|
116
|
+
return createErrorResponse('Method not allowed', 405);
|
|
117
|
+
}
|
|
50
118
|
|
|
51
|
-
|
|
119
|
+
async function handleWrite(request: Request, bucket: any, subpath: string, env: any, method: string): Promise<Response> {
|
|
52
120
|
if (method === 'PUT' && subpath) {
|
|
53
121
|
try {
|
|
54
122
|
await bucket.put(subpath, request.body);
|
|
@@ -58,7 +126,6 @@ export async function handleContent(request: Request, env: any, subpath: string)
|
|
|
58
126
|
}
|
|
59
127
|
}
|
|
60
128
|
|
|
61
|
-
// Delete content: DELETE /content/:key
|
|
62
129
|
if (method === 'DELETE' && subpath) {
|
|
63
130
|
try {
|
|
64
131
|
await bucket.delete(subpath);
|
package/src/api/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { WebsiteAPI } from './website-api';
|
|
2
2
|
export { WebsiteAPI };
|
|
3
3
|
export type { APIHandler } from './website-api';
|
|
4
|
+
export * from './handlers/auth';
|
|
5
|
+
export * from './handlers/auth-handler';
|
|
4
6
|
|
|
5
7
|
// Default worker export using WebsiteAPI
|
|
6
8
|
const defaultAPI = new WebsiteAPI();
|
package/src/api/website-api.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { handleAboutMe, clearContentCache } from './handlers/about-me';
|
|
|
3
3
|
import { handleHome } from './handlers/home';
|
|
4
4
|
import { handleInfo } from './handlers/info';
|
|
5
5
|
import { handleContent } from './handlers/content';
|
|
6
|
+
import { handleAuth } from './handlers/auth-handler';
|
|
6
7
|
import { handleBlogs, handleStories, handleSearch } from './handlers/content-api';
|
|
7
8
|
import { handleLogo } from './handlers/logo';
|
|
8
9
|
import { handleStaticDetails } from './handlers/static-details';
|
|
10
|
+
import { getAuthStore } from './handlers/auth';
|
|
9
11
|
|
|
10
12
|
export type APIHandler = (request: Request, env: any) => Promise<Response>;
|
|
11
13
|
|
|
@@ -23,6 +25,14 @@ export class WebsiteAPI {
|
|
|
23
25
|
return response;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
private addAdminCORSHeaders(response: Response): Response {
|
|
29
|
+
response.headers.set('Access-Control-Allow-Origin', 'same-origin');
|
|
30
|
+
response.headers.set('Access-Control-Allow-Credentials', 'true');
|
|
31
|
+
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
32
|
+
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
private handleCORS(): Response {
|
|
27
37
|
return new Response(null, {
|
|
28
38
|
status: 200,
|
|
@@ -35,18 +45,6 @@ export class WebsiteAPI {
|
|
|
35
45
|
});
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
private requireAuth(request: Request, env?: any): Response | null {
|
|
39
|
-
const authHeader = request.headers.get('Authorization');
|
|
40
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
41
|
-
return createErrorResponse('Unauthorized', 401);
|
|
42
|
-
}
|
|
43
|
-
const token = authHeader.slice(7);
|
|
44
|
-
if (token !== env?.ADMIN_API_KEY) {
|
|
45
|
-
return createErrorResponse('Unauthorized', 401);
|
|
46
|
-
}
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
48
|
public async fetch(request: Request, env: any): Promise<Response> {
|
|
51
49
|
const url = new URL(request.url);
|
|
52
50
|
|
|
@@ -70,7 +68,7 @@ export class WebsiteAPI {
|
|
|
70
68
|
// Check for content route first (content/*)
|
|
71
69
|
if (route === 'content' || route.startsWith('content/')) {
|
|
72
70
|
const subpath = route.replace(/^content\/?/, '');
|
|
73
|
-
return this.
|
|
71
|
+
return this.addAdminCORSHeaders(await handleContent(request, env, subpath));
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
switch (route) {
|
|
@@ -79,16 +77,24 @@ export class WebsiteAPI {
|
|
|
79
77
|
case 'home':
|
|
80
78
|
return this.addCORSHeaders(await handleHome(env));
|
|
81
79
|
case 'cache-clear':
|
|
82
|
-
const
|
|
83
|
-
|
|
80
|
+
const cookieHeader = request.headers.get('Cookie');
|
|
81
|
+
const sessionToken = cookieHeader?.split(';')
|
|
82
|
+
.find(c => c.trim().startsWith('session='))
|
|
83
|
+
?.split('=')[1];
|
|
84
|
+
const session = sessionToken ? await env.KV.get(`session:${sessionToken}`, 'json') : null;
|
|
85
|
+
if (!session || session.expiresAt < Date.now()) {
|
|
86
|
+
return this.addAdminCORSHeaders(createErrorResponse('Unauthorized', 401));
|
|
87
|
+
}
|
|
84
88
|
clearContentCache();
|
|
85
|
-
return this.
|
|
89
|
+
return this.addAdminCORSHeaders(new Response(JSON.stringify({ success: true, message: 'Cache cleared' }), { status: 200 }));
|
|
86
90
|
case 'aboutme':
|
|
87
91
|
return this.addCORSHeaders(await handleAboutMe(env));
|
|
88
92
|
case 'logo':
|
|
89
93
|
return this.addCORSHeaders(await handleLogo(env));
|
|
90
94
|
case 'static':
|
|
91
95
|
return this.addCORSHeaders(await handleStaticDetails(env));
|
|
96
|
+
case 'auth':
|
|
97
|
+
return this.addAdminCORSHeaders(await handleAuth(request, env, '/auth'));
|
|
92
98
|
case 'blogs':
|
|
93
99
|
return this.addCORSHeaders(await handleBlogs(env));
|
|
94
100
|
case 'blogs/latest':
|