@access-dlsu/leapify 0.260531.1 → 0.260601.1
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/auth/auth.d.ts +2 -2
- package/dist/client/auth.d.ts +41 -41
- package/dist/client/index.cjs +2 -0
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.js +2 -0
- package/dist/client/index.js.map +1 -1
- package/dist/index.cjs +126 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +124 -59
- package/dist/index.js.map +1 -1
- package/dist/lib/middleware/cors.d.ts.map +1 -1
- package/dist/lib/middleware/referer-guard.d.ts +1 -1
- package/dist/lib/middleware/referer-guard.d.ts.map +1 -1
- package/dist/routes/site-config.d.ts +2 -2
- package/dist/routes/site-config.d.ts.map +1 -1
- package/dist/worker.js +278 -213
- package/dist/worker.js.map +1 -1
- package/package.json +155 -155
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/cors.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/cors.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAoB7C,wBAAgB,oBAAoB,CAClC,cAAc,EAAE,MAAM,EAAE,GACvB,iBAAiB,CAgEnB"}
|
|
@@ -7,7 +7,7 @@ import type { LeapifyBindings } from '../../types';
|
|
|
7
7
|
* always allowed through without a Referer check.
|
|
8
8
|
*
|
|
9
9
|
* This is a friction layer — it stops naive raw-HTTP clients that don't set Referer.
|
|
10
|
-
* Sophisticated clients can
|
|
10
|
+
* Sophisticated clients can spook it, so this must NOT be relied on as the sole control
|
|
11
11
|
* for authenticated mutation endpoints (Firebase JWT is the primary control there).
|
|
12
12
|
*
|
|
13
13
|
* Skipped entirely for /health and /internal routes.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"referer-guard.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/referer-guard.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"referer-guard.d.ts","sourceRoot":"","sources":["../../../src/lib/middleware/referer-guard.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAqBlD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,cAAc,EAAE,MAAM,EAAE;cAIrB,eAAe;yBA2CpD"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Hono } from
|
|
2
|
-
import type { LeapifyEnv } from
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { LeapifyEnv } from '../types';
|
|
3
3
|
export declare const siteConfigRoute: Hono<LeapifyEnv, import("hono/types").BlankSchema, "/">;
|
|
4
4
|
//# sourceMappingURL=site-config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/routes/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"site-config.d.ts","sourceRoot":"","sources":["../../src/routes/site-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAC3B,OAAO,KAAK,EAAE,UAAU,EAAgC,MAAM,UAAU,CAAA;AAMxE,eAAO,MAAM,eAAe,yDAAyB,CAAA"}
|
package/dist/worker.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
4
|
+
import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
5
|
+
import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
|
|
3
6
|
import { createMiddleware } from 'hono/factory';
|
|
4
7
|
import { betterAuth } from 'better-auth';
|
|
5
8
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
6
9
|
import { bearer } from 'better-auth/plugins';
|
|
7
|
-
import { sql, relations, eq, and, count, lte } from 'drizzle-orm';
|
|
8
|
-
import { drizzle } from 'drizzle-orm/d1';
|
|
9
|
-
import { sqliteTable, integer, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
10
10
|
import { zValidator } from '@hono/zod-validator';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
|
|
@@ -24,6 +24,8 @@ var LeapifyError = class extends Error {
|
|
|
24
24
|
this.code = code;
|
|
25
25
|
this.name = "LeapifyError";
|
|
26
26
|
}
|
|
27
|
+
statusCode;
|
|
28
|
+
code;
|
|
27
29
|
};
|
|
28
30
|
var unauthorized = (message = "Unauthorized") => new LeapifyError(401, "UNAUTHORIZED", message);
|
|
29
31
|
var domainRestricted = () => new LeapifyError(
|
|
@@ -52,212 +54,6 @@ var errorHandler = (err, c) => {
|
|
|
52
54
|
500
|
|
53
55
|
);
|
|
54
56
|
};
|
|
55
|
-
function createCorsMiddleware(allowedOrigins) {
|
|
56
|
-
return async (c, next) => {
|
|
57
|
-
const origin = c.req.header("origin");
|
|
58
|
-
const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
|
|
59
|
-
const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
60
|
-
if (c.req.path.startsWith("/api/uploads/images")) {
|
|
61
|
-
c.header("Access-Control-Allow-Origin", "*");
|
|
62
|
-
c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
63
|
-
if (c.req.method === "OPTIONS") {
|
|
64
|
-
return c.body(null, 204);
|
|
65
|
-
}
|
|
66
|
-
return next();
|
|
67
|
-
}
|
|
68
|
-
if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin)) {
|
|
69
|
-
return c.json(
|
|
70
|
-
{
|
|
71
|
-
error: {
|
|
72
|
-
code: "DOMAIN_RESTRICTED",
|
|
73
|
-
message: `Origin ${origin} is not allowed`
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
403
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
const honoCors = cors({
|
|
80
|
-
origin: currentAllowedOrigins,
|
|
81
|
-
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
82
|
-
allowHeaders: ["Content-Type", "Authorization"],
|
|
83
|
-
exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
|
|
84
|
-
maxAge: 86400,
|
|
85
|
-
credentials: true
|
|
86
|
-
});
|
|
87
|
-
return honoCors(c, next);
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
function createRefererGuard(allowedOrigins) {
|
|
91
|
-
const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
92
|
-
const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
|
|
93
|
-
return createMiddleware(async (c, next) => {
|
|
94
|
-
if (!MUTATION_METHODS.has(c.req.method)) return next();
|
|
95
|
-
if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
|
|
96
|
-
const dynamicOriginsJson = await c.env.KV.get("config:allowed_origins", "json");
|
|
97
|
-
const currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
98
|
-
if (currentAllowedOrigins.includes("*")) return next();
|
|
99
|
-
const referer = c.req.header("referer") ?? "";
|
|
100
|
-
const isAllowed = currentAllowedOrigins.some((origin) => referer.startsWith(origin));
|
|
101
|
-
if (!isAllowed) {
|
|
102
|
-
throw forbidden("Request origin not permitted");
|
|
103
|
-
}
|
|
104
|
-
return next();
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
var TURNSTILE_PATH = "/.well-known/leapify/turnstile";
|
|
108
|
-
var TURNSTILE_VERIFY_PATH = `${TURNSTILE_PATH}/verify`;
|
|
109
|
-
var TURNSTILE_COOKIE_NAME = "leapify-turnstile";
|
|
110
|
-
var VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
111
|
-
var COOKIE_MAX_AGE_SEC = 86400;
|
|
112
|
-
var EXEMPT_PATHS = [
|
|
113
|
-
"/health",
|
|
114
|
-
"/internal",
|
|
115
|
-
"/api/auth",
|
|
116
|
-
"/api/uploads/images",
|
|
117
|
-
"/api/classes",
|
|
118
|
-
"/api/faqs",
|
|
119
|
-
"/api/config",
|
|
120
|
-
TURNSTILE_VERIFY_PATH
|
|
121
|
-
];
|
|
122
|
-
function base64urlEncode(bytes) {
|
|
123
|
-
let binary = "";
|
|
124
|
-
for (const byte of bytes) {
|
|
125
|
-
binary += String.fromCharCode(byte);
|
|
126
|
-
}
|
|
127
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
128
|
-
}
|
|
129
|
-
function base64urlDecode(str) {
|
|
130
|
-
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
131
|
-
const binary = atob(padded);
|
|
132
|
-
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
133
|
-
for (let i = 0; i < binary.length; i++) {
|
|
134
|
-
bytes[i] = binary.charCodeAt(i);
|
|
135
|
-
}
|
|
136
|
-
return bytes;
|
|
137
|
-
}
|
|
138
|
-
async function importHmacKey(secret) {
|
|
139
|
-
return crypto.subtle.importKey(
|
|
140
|
-
"raw",
|
|
141
|
-
new TextEncoder().encode(secret),
|
|
142
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
143
|
-
false,
|
|
144
|
-
["sign", "verify"]
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
async function signCookie(secret, ip) {
|
|
148
|
-
const ts = Date.now();
|
|
149
|
-
const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(8)));
|
|
150
|
-
const payload = `${ip}:${ts}:${nonce}`;
|
|
151
|
-
const key = await importHmacKey(secret);
|
|
152
|
-
const sig = await crypto.subtle.sign(
|
|
153
|
-
"HMAC",
|
|
154
|
-
key,
|
|
155
|
-
new TextEncoder().encode(payload)
|
|
156
|
-
);
|
|
157
|
-
const sigB64 = base64urlEncode(new Uint8Array(sig));
|
|
158
|
-
return `${base64urlEncode(new TextEncoder().encode(payload))}.${sigB64}`;
|
|
159
|
-
}
|
|
160
|
-
async function validateCookie(secret, cookie, ip) {
|
|
161
|
-
try {
|
|
162
|
-
const [payloadB64, sigB64] = cookie.split(".");
|
|
163
|
-
if (!payloadB64 || !sigB64) return false;
|
|
164
|
-
const payloadBytes = base64urlDecode(payloadB64);
|
|
165
|
-
const sigBytes = base64urlDecode(sigB64);
|
|
166
|
-
const key = await importHmacKey(secret);
|
|
167
|
-
const valid = await crypto.subtle.verify(
|
|
168
|
-
"HMAC",
|
|
169
|
-
key,
|
|
170
|
-
sigBytes,
|
|
171
|
-
payloadBytes
|
|
172
|
-
);
|
|
173
|
-
if (!valid) return false;
|
|
174
|
-
const payload = new TextDecoder().decode(payloadBytes);
|
|
175
|
-
const [cookieIp, tsStr] = payload.split(":");
|
|
176
|
-
if (cookieIp !== ip) return false;
|
|
177
|
-
const ts = parseInt(tsStr, 10);
|
|
178
|
-
if (isNaN(ts) || Date.now() - ts > COOKIE_MAX_AGE_SEC * 1e3) return false;
|
|
179
|
-
return true;
|
|
180
|
-
} catch {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
function getClientIp(c) {
|
|
185
|
-
return c.req.header("CF-Connecting-IP") ?? c.req.header("X-Real-IP") ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? "unknown";
|
|
186
|
-
}
|
|
187
|
-
function isExempt(path) {
|
|
188
|
-
const normalized = path.toLowerCase().replace(/\/$/, "");
|
|
189
|
-
return EXEMPT_PATHS.some((p) => {
|
|
190
|
-
const ep = p.toLowerCase().replace(/\/$/, "");
|
|
191
|
-
return normalized === ep || normalized.startsWith(ep + "/");
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
function setCookieHeader(c, token) {
|
|
195
|
-
const isSecure = c.req.raw.url.startsWith("https") || c.req.header("x-forwarded-proto") === "https";
|
|
196
|
-
c.header(
|
|
197
|
-
"Set-Cookie",
|
|
198
|
-
`${TURNSTILE_COOKIE_NAME}=${token}; Path=/; Max-Age=${COOKIE_MAX_AGE_SEC}; ${isSecure ? "Secure; " : ""}HttpOnly; SameSite=Lax`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
async function handleTurnstileVerify(c) {
|
|
202
|
-
const body = await c.req.json();
|
|
203
|
-
const { token } = body;
|
|
204
|
-
if (!token) {
|
|
205
|
-
return c.json(
|
|
206
|
-
{ error: { code: "VALIDATION_ERROR", message: "Missing Turnstile token" } },
|
|
207
|
-
422
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
const secret = c.env.TURNSTILE_SECRET_KEY;
|
|
211
|
-
if (!secret) {
|
|
212
|
-
return c.json(
|
|
213
|
-
{ error: { code: "CONFIG_ERROR", message: "Turnstile not configured" } },
|
|
214
|
-
500
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
const ip = getClientIp(c);
|
|
218
|
-
const formData = new URLSearchParams();
|
|
219
|
-
formData.append("secret", secret);
|
|
220
|
-
formData.append("response", token);
|
|
221
|
-
if (ip !== "unknown") {
|
|
222
|
-
formData.append("remoteip", ip);
|
|
223
|
-
}
|
|
224
|
-
const res = await fetch(VERIFY_URL, {
|
|
225
|
-
method: "POST",
|
|
226
|
-
body: formData
|
|
227
|
-
});
|
|
228
|
-
const outcome = await res.json();
|
|
229
|
-
if (!outcome.success) {
|
|
230
|
-
return c.json(
|
|
231
|
-
{ error: { code: "TURNSTILE_FAILED", message: "Turnstile verification failed", details: outcome["error-codes"] } },
|
|
232
|
-
403
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
const cookieToken = await signCookie(secret, ip);
|
|
236
|
-
setCookieHeader(c, cookieToken);
|
|
237
|
-
return c.json({ success: true });
|
|
238
|
-
}
|
|
239
|
-
function createTurnstileMiddleware() {
|
|
240
|
-
return createMiddleware(async (c, next) => {
|
|
241
|
-
if (isExempt(c.req.path)) return next();
|
|
242
|
-
if (c.req.method === "OPTIONS") return next();
|
|
243
|
-
if (c.req.header("Authorization")) return next();
|
|
244
|
-
const secret = c.env.TURNSTILE_SECRET_KEY;
|
|
245
|
-
if (!secret) return next();
|
|
246
|
-
const cookieHeader = c.req.header("Cookie") ?? "";
|
|
247
|
-
const cookieMatch = cookieHeader.match(
|
|
248
|
-
new RegExp(`${TURNSTILE_COOKIE_NAME}=([^;]+)`)
|
|
249
|
-
);
|
|
250
|
-
if (cookieMatch) {
|
|
251
|
-
const ip = getClientIp(c);
|
|
252
|
-
const valid = await validateCookie(secret, cookieMatch[1], ip);
|
|
253
|
-
if (valid) return next();
|
|
254
|
-
}
|
|
255
|
-
return c.json(
|
|
256
|
-
{ error: { code: "TURNSTILE_REQUIRED", message: "Turnstile verification required" } },
|
|
257
|
-
401
|
|
258
|
-
);
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
57
|
|
|
262
58
|
// src/db/schema/index.ts
|
|
263
59
|
var schema_exports = {};
|
|
@@ -490,8 +286,266 @@ var authVerification = sqliteTable(
|
|
|
490
286
|
function createDb(d1) {
|
|
491
287
|
return drizzle(d1, { schema: schema_exports });
|
|
492
288
|
}
|
|
493
|
-
|
|
494
|
-
|
|
289
|
+
async function getOriginsFromDb(env) {
|
|
290
|
+
try {
|
|
291
|
+
const db = createDb(env.DB);
|
|
292
|
+
const row = await db.query.siteConfig.findFirst({
|
|
293
|
+
where: eq(siteConfig.key, "allowed_origins")
|
|
294
|
+
});
|
|
295
|
+
if (row) return JSON.parse(row.value);
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
function createCorsMiddleware(allowedOrigins) {
|
|
301
|
+
return async (c, next) => {
|
|
302
|
+
const origin = c.req.header("origin");
|
|
303
|
+
const dynamicOriginsJson = await c.env.KV.get(
|
|
304
|
+
"config:allowed_origins",
|
|
305
|
+
"json"
|
|
306
|
+
);
|
|
307
|
+
let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
308
|
+
if (!dynamicOriginsJson) {
|
|
309
|
+
const dbOrigins = await getOriginsFromDb(c.env);
|
|
310
|
+
if (dbOrigins) {
|
|
311
|
+
currentAllowedOrigins = dbOrigins;
|
|
312
|
+
await c.env.KV.put(
|
|
313
|
+
"config:allowed_origins",
|
|
314
|
+
JSON.stringify(dbOrigins),
|
|
315
|
+
{ expirationTtl: 86400 }
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (c.req.path.startsWith("/api/uploads/images")) {
|
|
320
|
+
c.header("Access-Control-Allow-Origin", "*");
|
|
321
|
+
c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
322
|
+
if (c.req.method === "OPTIONS") {
|
|
323
|
+
return c.body(null, 204);
|
|
324
|
+
}
|
|
325
|
+
return next();
|
|
326
|
+
}
|
|
327
|
+
if (!c.req.path.startsWith("/health") && !c.req.path.startsWith("/api/auth") && !c.req.path.startsWith("/internal") && origin && !currentAllowedOrigins.includes("*") && !currentAllowedOrigins.includes(origin) && origin !== new URL(c.req.url).origin) {
|
|
328
|
+
return c.json(
|
|
329
|
+
{
|
|
330
|
+
error: {
|
|
331
|
+
code: "DOMAIN_RESTRICTED",
|
|
332
|
+
message: `Origin ${origin} is not allowed`
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
403
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const honoCors = cors({
|
|
339
|
+
origin: currentAllowedOrigins,
|
|
340
|
+
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
341
|
+
allowHeaders: ["Content-Type", "Authorization"],
|
|
342
|
+
exposeHeaders: ["ETag", "Last-Modified", "Cache-Control"],
|
|
343
|
+
maxAge: 86400,
|
|
344
|
+
credentials: true
|
|
345
|
+
});
|
|
346
|
+
return honoCors(c, next);
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function getOriginsFromDb2(env) {
|
|
350
|
+
try {
|
|
351
|
+
const db = createDb(env.DB);
|
|
352
|
+
const row = await db.query.siteConfig.findFirst({
|
|
353
|
+
where: eq(siteConfig.key, "allowed_origins")
|
|
354
|
+
});
|
|
355
|
+
if (row) return JSON.parse(row.value);
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
function createRefererGuard(allowedOrigins) {
|
|
361
|
+
const MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
362
|
+
const SKIP_PREFIXES = ["/health", "/internal", "/api/auth", "/.well-known"];
|
|
363
|
+
return createMiddleware(async (c, next) => {
|
|
364
|
+
if (!MUTATION_METHODS.has(c.req.method)) return next();
|
|
365
|
+
if (SKIP_PREFIXES.some((p) => c.req.path.startsWith(p))) return next();
|
|
366
|
+
const dynamicOriginsJson = await c.env.KV.get(
|
|
367
|
+
"config:allowed_origins",
|
|
368
|
+
"json"
|
|
369
|
+
);
|
|
370
|
+
let currentAllowedOrigins = dynamicOriginsJson ?? allowedOrigins;
|
|
371
|
+
if (!dynamicOriginsJson) {
|
|
372
|
+
const dbOrigins = await getOriginsFromDb2(c.env);
|
|
373
|
+
if (dbOrigins) {
|
|
374
|
+
currentAllowedOrigins = dbOrigins;
|
|
375
|
+
await c.env.KV.put(
|
|
376
|
+
"config:allowed_origins",
|
|
377
|
+
JSON.stringify(dbOrigins),
|
|
378
|
+
{ expirationTtl: 86400 }
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (currentAllowedOrigins.includes("*")) return next();
|
|
383
|
+
const referer = c.req.header("referer") ?? "";
|
|
384
|
+
const requestOrigin = new URL(c.req.url).origin;
|
|
385
|
+
if (referer.startsWith(requestOrigin)) return next();
|
|
386
|
+
const isAllowed = currentAllowedOrigins.some(
|
|
387
|
+
(origin) => referer.startsWith(origin)
|
|
388
|
+
);
|
|
389
|
+
if (!isAllowed) {
|
|
390
|
+
throw forbidden("Request origin not permitted");
|
|
391
|
+
}
|
|
392
|
+
return next();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
var TURNSTILE_PATH = "/.well-known/leapify/turnstile";
|
|
396
|
+
var TURNSTILE_VERIFY_PATH = `${TURNSTILE_PATH}/verify`;
|
|
397
|
+
var TURNSTILE_COOKIE_NAME = "leapify-turnstile";
|
|
398
|
+
var VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
399
|
+
var COOKIE_MAX_AGE_SEC = 86400;
|
|
400
|
+
var EXEMPT_PATHS = [
|
|
401
|
+
"/health",
|
|
402
|
+
"/internal",
|
|
403
|
+
"/api/auth",
|
|
404
|
+
"/api/uploads/images",
|
|
405
|
+
"/api/classes",
|
|
406
|
+
"/api/faqs",
|
|
407
|
+
"/api/config",
|
|
408
|
+
TURNSTILE_VERIFY_PATH
|
|
409
|
+
];
|
|
410
|
+
function base64urlEncode(bytes) {
|
|
411
|
+
let binary = "";
|
|
412
|
+
for (const byte of bytes) {
|
|
413
|
+
binary += String.fromCharCode(byte);
|
|
414
|
+
}
|
|
415
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
416
|
+
}
|
|
417
|
+
function base64urlDecode(str) {
|
|
418
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
419
|
+
const binary = atob(padded);
|
|
420
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
421
|
+
for (let i = 0; i < binary.length; i++) {
|
|
422
|
+
bytes[i] = binary.charCodeAt(i);
|
|
423
|
+
}
|
|
424
|
+
return bytes;
|
|
425
|
+
}
|
|
426
|
+
async function importHmacKey(secret) {
|
|
427
|
+
return crypto.subtle.importKey(
|
|
428
|
+
"raw",
|
|
429
|
+
new TextEncoder().encode(secret),
|
|
430
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
431
|
+
false,
|
|
432
|
+
["sign", "verify"]
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
async function signCookie(secret, ip) {
|
|
436
|
+
const ts = Date.now();
|
|
437
|
+
const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(8)));
|
|
438
|
+
const payload = `${ip}:${ts}:${nonce}`;
|
|
439
|
+
const key = await importHmacKey(secret);
|
|
440
|
+
const sig = await crypto.subtle.sign(
|
|
441
|
+
"HMAC",
|
|
442
|
+
key,
|
|
443
|
+
new TextEncoder().encode(payload)
|
|
444
|
+
);
|
|
445
|
+
const sigB64 = base64urlEncode(new Uint8Array(sig));
|
|
446
|
+
return `${base64urlEncode(new TextEncoder().encode(payload))}.${sigB64}`;
|
|
447
|
+
}
|
|
448
|
+
async function validateCookie(secret, cookie, ip) {
|
|
449
|
+
try {
|
|
450
|
+
const [payloadB64, sigB64] = cookie.split(".");
|
|
451
|
+
if (!payloadB64 || !sigB64) return false;
|
|
452
|
+
const payloadBytes = base64urlDecode(payloadB64);
|
|
453
|
+
const sigBytes = base64urlDecode(sigB64);
|
|
454
|
+
const key = await importHmacKey(secret);
|
|
455
|
+
const valid = await crypto.subtle.verify(
|
|
456
|
+
"HMAC",
|
|
457
|
+
key,
|
|
458
|
+
sigBytes,
|
|
459
|
+
payloadBytes
|
|
460
|
+
);
|
|
461
|
+
if (!valid) return false;
|
|
462
|
+
const payload = new TextDecoder().decode(payloadBytes);
|
|
463
|
+
const [cookieIp, tsStr] = payload.split(":");
|
|
464
|
+
if (cookieIp !== ip) return false;
|
|
465
|
+
const ts = parseInt(tsStr, 10);
|
|
466
|
+
if (isNaN(ts) || Date.now() - ts > COOKIE_MAX_AGE_SEC * 1e3) return false;
|
|
467
|
+
return true;
|
|
468
|
+
} catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function getClientIp(c) {
|
|
473
|
+
return c.req.header("CF-Connecting-IP") ?? c.req.header("X-Real-IP") ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? "unknown";
|
|
474
|
+
}
|
|
475
|
+
function isExempt(path) {
|
|
476
|
+
const normalized = path.toLowerCase().replace(/\/$/, "");
|
|
477
|
+
return EXEMPT_PATHS.some((p) => {
|
|
478
|
+
const ep = p.toLowerCase().replace(/\/$/, "");
|
|
479
|
+
return normalized === ep || normalized.startsWith(ep + "/");
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function setCookieHeader(c, token) {
|
|
483
|
+
const isSecure = c.req.raw.url.startsWith("https") || c.req.header("x-forwarded-proto") === "https";
|
|
484
|
+
c.header(
|
|
485
|
+
"Set-Cookie",
|
|
486
|
+
`${TURNSTILE_COOKIE_NAME}=${token}; Path=/; Max-Age=${COOKIE_MAX_AGE_SEC}; ${isSecure ? "Secure; " : ""}HttpOnly; SameSite=Lax`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
async function handleTurnstileVerify(c) {
|
|
490
|
+
const body = await c.req.json();
|
|
491
|
+
const { token } = body;
|
|
492
|
+
if (!token) {
|
|
493
|
+
return c.json(
|
|
494
|
+
{ error: { code: "VALIDATION_ERROR", message: "Missing Turnstile token" } },
|
|
495
|
+
422
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const secret = c.env.TURNSTILE_SECRET_KEY;
|
|
499
|
+
if (!secret) {
|
|
500
|
+
return c.json(
|
|
501
|
+
{ error: { code: "CONFIG_ERROR", message: "Turnstile not configured" } },
|
|
502
|
+
500
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const ip = getClientIp(c);
|
|
506
|
+
const formData = new URLSearchParams();
|
|
507
|
+
formData.append("secret", secret);
|
|
508
|
+
formData.append("response", token);
|
|
509
|
+
if (ip !== "unknown") {
|
|
510
|
+
formData.append("remoteip", ip);
|
|
511
|
+
}
|
|
512
|
+
const res = await fetch(VERIFY_URL, {
|
|
513
|
+
method: "POST",
|
|
514
|
+
body: formData
|
|
515
|
+
});
|
|
516
|
+
const outcome = await res.json();
|
|
517
|
+
if (!outcome.success) {
|
|
518
|
+
return c.json(
|
|
519
|
+
{ error: { code: "TURNSTILE_FAILED", message: "Turnstile verification failed", details: outcome["error-codes"] } },
|
|
520
|
+
403
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const cookieToken = await signCookie(secret, ip);
|
|
524
|
+
setCookieHeader(c, cookieToken);
|
|
525
|
+
return c.json({ success: true });
|
|
526
|
+
}
|
|
527
|
+
function createTurnstileMiddleware() {
|
|
528
|
+
return createMiddleware(async (c, next) => {
|
|
529
|
+
if (isExempt(c.req.path)) return next();
|
|
530
|
+
if (c.req.method === "OPTIONS") return next();
|
|
531
|
+
if (c.req.header("Authorization")) return next();
|
|
532
|
+
const secret = c.env.TURNSTILE_SECRET_KEY;
|
|
533
|
+
if (!secret) return next();
|
|
534
|
+
const cookieHeader = c.req.header("Cookie") ?? "";
|
|
535
|
+
const cookieMatch = cookieHeader.match(
|
|
536
|
+
new RegExp(`${TURNSTILE_COOKIE_NAME}=([^;]+)`)
|
|
537
|
+
);
|
|
538
|
+
if (cookieMatch) {
|
|
539
|
+
const ip = getClientIp(c);
|
|
540
|
+
const valid = await validateCookie(secret, cookieMatch[1], ip);
|
|
541
|
+
if (valid) return next();
|
|
542
|
+
}
|
|
543
|
+
return c.json(
|
|
544
|
+
{ error: { code: "TURNSTILE_REQUIRED", message: "Turnstile verification required" } },
|
|
545
|
+
401
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
495
549
|
var DLSU_DOMAIN = "@dlsu.edu.ph";
|
|
496
550
|
function createAuth(env) {
|
|
497
551
|
const db = createDb(env.DB);
|
|
@@ -787,7 +841,14 @@ healthRoute.get("/", async (c) => {
|
|
|
787
841
|
const env = c.env;
|
|
788
842
|
const hasSes = Boolean(env.SES_REGION) && Boolean(env.SES_ACCESS_KEY_ID) && Boolean(env.SES_SECRET_ACCESS_KEY);
|
|
789
843
|
const hasResend = Boolean(env.RESEND_API_KEY);
|
|
790
|
-
|
|
844
|
+
let hasGForms = false;
|
|
845
|
+
if (env.GFORMS_SERVICE_ACCOUNT_JSON) {
|
|
846
|
+
try {
|
|
847
|
+
const parsed = JSON.parse(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
848
|
+
hasGForms = Boolean(parsed.client_email && parsed.private_key);
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
791
852
|
const probes = [];
|
|
792
853
|
if (hasSes) {
|
|
793
854
|
probes.push(
|
|
@@ -845,6 +906,7 @@ var CacheService = class {
|
|
|
845
906
|
constructor(kv) {
|
|
846
907
|
this.kv = kv;
|
|
847
908
|
}
|
|
909
|
+
kv;
|
|
848
910
|
async get(key) {
|
|
849
911
|
return this.kv.get(key, "json");
|
|
850
912
|
}
|
|
@@ -890,6 +952,8 @@ var SlotsService = class {
|
|
|
890
952
|
this.db = db;
|
|
891
953
|
this.cache = cache;
|
|
892
954
|
}
|
|
955
|
+
db;
|
|
956
|
+
cache;
|
|
893
957
|
kvKey(slug) {
|
|
894
958
|
return `${SLOT_KV_PREFIX}${slug}`;
|
|
895
959
|
}
|
|
@@ -1504,7 +1568,7 @@ siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
|
|
|
1504
1568
|
set: { value: JSON.stringify(value), updatedAt: now }
|
|
1505
1569
|
});
|
|
1506
1570
|
await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
|
|
1507
|
-
expirationTtl:
|
|
1571
|
+
expirationTtl: 86400
|
|
1508
1572
|
});
|
|
1509
1573
|
return c.json({ data: { key, value } });
|
|
1510
1574
|
});
|
|
@@ -1996,6 +2060,7 @@ var SesError = class extends Error {
|
|
|
1996
2060
|
this.status = status;
|
|
1997
2061
|
this.name = "SesError";
|
|
1998
2062
|
}
|
|
2063
|
+
status;
|
|
1999
2064
|
/**
|
|
2000
2065
|
* True for errors that are permanent (not worth retrying via SES again).
|
|
2001
2066
|
* 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
|