@leadertechie/personal-site-kit 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
@@ -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
- // List content: GET /content (subpath is empty or just slash)
12
- if (method === 'GET' && (!subpath || subpath === '/')) {
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
- // Get content: GET /content/:key
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
- // Auth check for write operations
43
- const authHeader = request.headers.get('Authorization');
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
- // Upload content: PUT /content/:key
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();
@@ -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.addCORSHeaders(await handleContent(request, env, subpath));
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 authError = this.requireAuth(request, env);
83
- if (authError) return this.addCORSHeaders(authError);
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.addCORSHeaders(new Response(JSON.stringify({ success: true, message: 'Cache cleared' }), { status: 200 }));
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':