@access-dlsu/leapify 0.260531.1 → 0.260601.2
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/app.d.ts.map +1 -1
- package/dist/auth/auth.d.ts +2 -2
- package/dist/client/auth.d.ts +41 -41
- package/dist/client/index.cjs +9 -0
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.ts +7 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -0
- package/dist/client/index.js.map +1 -1
- package/dist/cron/reconcile-slots.d.ts.map +1 -1
- package/dist/index.cjs +343 -222
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +346 -225
- 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/internal/batch-release.d.ts +4 -0
- package/dist/routes/internal/batch-release.d.ts.map +1 -0
- package/dist/routes/internal/reconcile-slots.d.ts +4 -0
- package/dist/routes/internal/reconcile-slots.d.ts.map +1 -0
- package/dist/routes/internal/reminder-emails.d.ts +4 -0
- package/dist/routes/internal/reminder-emails.d.ts.map +1 -0
- package/dist/routes/internal/renew-watches.d.ts +4 -0
- package/dist/routes/internal/renew-watches.d.ts.map +1 -0
- package/dist/routes/site-config.d.ts +2 -2
- package/dist/routes/site-config.d.ts.map +1 -1
- package/dist/services/gforms.d.ts.map +1 -1
- package/dist/worker.js +539 -418
- package/dist/worker.js.map +1 -1
- package/package.json +155 -155
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, isNotNull, 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
|
}
|
|
@@ -1044,7 +1108,10 @@ var GFormsService = class {
|
|
|
1044
1108
|
const response = await fetch(url.toString(), {
|
|
1045
1109
|
headers: { Authorization: `Bearer ${token}` }
|
|
1046
1110
|
});
|
|
1047
|
-
if (!response.ok)
|
|
1111
|
+
if (!response.ok) {
|
|
1112
|
+
const err = await response.text();
|
|
1113
|
+
throw new Error(`Forms API error: ${response.status} ${err}`);
|
|
1114
|
+
}
|
|
1048
1115
|
const data = await response.json();
|
|
1049
1116
|
allResponses.push(...data.responses ?? []);
|
|
1050
1117
|
pageToken = data.nextPageToken;
|
|
@@ -1318,6 +1385,27 @@ classesRoute.get("/:slug/slots", eventsSlotsRateLimit, async (c) => {
|
|
|
1318
1385
|
c.header("Cache-Control", "public, max-age=5, stale-while-revalidate=5");
|
|
1319
1386
|
return c.json({ data: info });
|
|
1320
1387
|
});
|
|
1388
|
+
classesRoute.post("/:slug/reconcile", authMiddleware, adminMiddleware, async (c) => {
|
|
1389
|
+
const { slug } = c.req.param();
|
|
1390
|
+
const db = createDb(c.env.DB);
|
|
1391
|
+
const cache = new CacheService(c.env.KV);
|
|
1392
|
+
const gforms = new GFormsService(c.env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1393
|
+
const slots = new SlotsService(db, cache);
|
|
1394
|
+
const event = await db.query.events.findFirst({
|
|
1395
|
+
where: eq(events.slug, slug),
|
|
1396
|
+
columns: { gformsId: true }
|
|
1397
|
+
});
|
|
1398
|
+
if (!event) throw notFound("Event");
|
|
1399
|
+
if (!event.gformsId) return c.json({ error: "No gformsId set for this event" }, 400);
|
|
1400
|
+
try {
|
|
1401
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1402
|
+
await slots.correctCount(slug, googleCount);
|
|
1403
|
+
return c.json({ data: { registeredSlots: googleCount } });
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
const message = err?.message ?? "Failed to fetch from Google Forms API";
|
|
1406
|
+
return c.json({ error: { code: "GFORMS_API_ERROR", message } }, 502);
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1321
1409
|
classesRoute.post(
|
|
1322
1410
|
"/",
|
|
1323
1411
|
authMiddleware,
|
|
@@ -1504,7 +1592,7 @@ siteConfigRoute.patch("/:key", authMiddleware, adminMiddleware, async (c) => {
|
|
|
1504
1592
|
set: { value: JSON.stringify(value), updatedAt: now }
|
|
1505
1593
|
});
|
|
1506
1594
|
await c.env.KV.put(`config:${key}`, JSON.stringify(value), {
|
|
1507
|
-
expirationTtl:
|
|
1595
|
+
expirationTtl: 86400
|
|
1508
1596
|
});
|
|
1509
1597
|
return c.json({ data: { key, value } });
|
|
1510
1598
|
});
|
|
@@ -1577,54 +1665,244 @@ gformsWebhookRoute.post("/", internalMiddleware, async (c) => {
|
|
|
1577
1665
|
return c.json({ error: "Invalid signature" }, 403);
|
|
1578
1666
|
}
|
|
1579
1667
|
}
|
|
1580
|
-
let payload;
|
|
1581
|
-
try {
|
|
1582
|
-
payload = JSON.parse(rawBody);
|
|
1583
|
-
} catch {
|
|
1584
|
-
return c.json({ error: "Invalid payload" }, 400);
|
|
1585
|
-
}
|
|
1586
|
-
const { formId } = payload;
|
|
1587
|
-
if (!formId) return c.json({ error: "Missing formId" }, 400);
|
|
1588
|
-
const db = createDb(c.env.DB);
|
|
1589
|
-
const cache = new CacheService(c.env.KV);
|
|
1590
|
-
const event = await db.query.events.findFirst({
|
|
1591
|
-
where: eq(events.gformsId, formId),
|
|
1592
|
-
columns: { slug: true, maxSlots: true, registeredSlots: true }
|
|
1668
|
+
let payload;
|
|
1669
|
+
try {
|
|
1670
|
+
payload = JSON.parse(rawBody);
|
|
1671
|
+
} catch {
|
|
1672
|
+
return c.json({ error: "Invalid payload" }, 400);
|
|
1673
|
+
}
|
|
1674
|
+
const { formId } = payload;
|
|
1675
|
+
if (!formId) return c.json({ error: "Missing formId" }, 400);
|
|
1676
|
+
const db = createDb(c.env.DB);
|
|
1677
|
+
const cache = new CacheService(c.env.KV);
|
|
1678
|
+
const event = await db.query.events.findFirst({
|
|
1679
|
+
where: eq(events.gformsId, formId),
|
|
1680
|
+
columns: { slug: true, maxSlots: true, registeredSlots: true }
|
|
1681
|
+
});
|
|
1682
|
+
if (!event) {
|
|
1683
|
+
console.warn(`[gforms-webhook] Unknown formId: ${formId}`);
|
|
1684
|
+
return c.json({ ok: true });
|
|
1685
|
+
}
|
|
1686
|
+
const slotsService = new SlotsService(db, cache);
|
|
1687
|
+
const updated = await slotsService.increment(event.slug);
|
|
1688
|
+
console.log(
|
|
1689
|
+
`[gforms-webhook] Incremented "${event.slug}": ${updated?.registered}/${updated?.total}`
|
|
1690
|
+
);
|
|
1691
|
+
return c.json({ ok: true });
|
|
1692
|
+
});
|
|
1693
|
+
async function verifyGoogSignature(body, signature, secret) {
|
|
1694
|
+
try {
|
|
1695
|
+
const key = await crypto.subtle.importKey(
|
|
1696
|
+
"raw",
|
|
1697
|
+
new TextEncoder().encode(secret),
|
|
1698
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1699
|
+
false,
|
|
1700
|
+
["verify"]
|
|
1701
|
+
);
|
|
1702
|
+
const sigHex = signature.replace(/^hmac-sha256=/, "");
|
|
1703
|
+
const sigBytes = Uint8Array.from(
|
|
1704
|
+
sigHex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? []
|
|
1705
|
+
);
|
|
1706
|
+
return crypto.subtle.verify(
|
|
1707
|
+
"HMAC",
|
|
1708
|
+
key,
|
|
1709
|
+
sigBytes,
|
|
1710
|
+
new TextEncoder().encode(body)
|
|
1711
|
+
);
|
|
1712
|
+
} catch {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
1717
|
+
var LOCK_TTL = 300;
|
|
1718
|
+
async function reconcileSlots(env) {
|
|
1719
|
+
const db = createDb(env.DB);
|
|
1720
|
+
const cache = new CacheService(env.KV);
|
|
1721
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1722
|
+
const slots = new SlotsService(db, cache);
|
|
1723
|
+
const lock = await cache.get(LOCK_KEY);
|
|
1724
|
+
if (lock) {
|
|
1725
|
+
console.log("[reconcile-slots] Lock held, skipping.");
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
1729
|
+
try {
|
|
1730
|
+
const publishedEvents = await db.query.events.findMany({
|
|
1731
|
+
where: isNotNull(events.gformsId),
|
|
1732
|
+
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
1733
|
+
});
|
|
1734
|
+
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
1735
|
+
let corrected = 0;
|
|
1736
|
+
for (const event of eventsWithForms) {
|
|
1737
|
+
try {
|
|
1738
|
+
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
1739
|
+
const localCount = event.registeredSlots;
|
|
1740
|
+
if (googleCount !== localCount) {
|
|
1741
|
+
console.warn(
|
|
1742
|
+
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
1743
|
+
);
|
|
1744
|
+
await slots.correctCount(event.slug, googleCount);
|
|
1745
|
+
corrected++;
|
|
1746
|
+
}
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
console.log(
|
|
1752
|
+
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
1753
|
+
);
|
|
1754
|
+
} finally {
|
|
1755
|
+
await cache.del(LOCK_KEY);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/routes/internal/reconcile-slots.ts
|
|
1760
|
+
var reconcileSlotsRoute = new Hono();
|
|
1761
|
+
reconcileSlotsRoute.post("/", internalMiddleware, async (c) => {
|
|
1762
|
+
await reconcileSlots(c.env);
|
|
1763
|
+
return c.json({ ok: true });
|
|
1764
|
+
});
|
|
1765
|
+
async function batchRelease(env) {
|
|
1766
|
+
const db = createDb(env.DB);
|
|
1767
|
+
const cache = new CacheService(env.KV);
|
|
1768
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1769
|
+
const toPublish = await db.query.events.findMany({
|
|
1770
|
+
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
1771
|
+
columns: { id: true, slug: true }
|
|
1772
|
+
});
|
|
1773
|
+
if (toPublish.length === 0) return;
|
|
1774
|
+
const ids = toPublish.map((e) => e.id);
|
|
1775
|
+
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
1776
|
+
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
1777
|
+
sql`${events.id} IN (${sql.join(
|
|
1778
|
+
ids.map((id) => sql`${id}`),
|
|
1779
|
+
sql`, `
|
|
1780
|
+
)})`
|
|
1781
|
+
);
|
|
1782
|
+
await cache.del("events:list");
|
|
1783
|
+
await cache.del("events:etag");
|
|
1784
|
+
console.log(
|
|
1785
|
+
`[batch-release] Published ${toPublish.length} events:`,
|
|
1786
|
+
toPublish.map((e) => e.slug).join(", ")
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// src/routes/internal/batch-release.ts
|
|
1791
|
+
var batchReleaseRoute = new Hono();
|
|
1792
|
+
batchReleaseRoute.post("/", internalMiddleware, async (c) => {
|
|
1793
|
+
await batchRelease(c.env);
|
|
1794
|
+
return c.json({ ok: true });
|
|
1795
|
+
});
|
|
1796
|
+
function parseStartTimestamp(dateTime, startTime) {
|
|
1797
|
+
if (!dateTime) return null;
|
|
1798
|
+
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
1799
|
+
const ms = Date.parse(combined);
|
|
1800
|
+
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
1801
|
+
}
|
|
1802
|
+
async function reminderEmails(env) {
|
|
1803
|
+
if (!env.EMAIL_QUEUE) {
|
|
1804
|
+
console.warn(
|
|
1805
|
+
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
1806
|
+
);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
1810
|
+
const hasResend = !!env.RESEND_API_KEY;
|
|
1811
|
+
if (!hasSes && !hasResend) {
|
|
1812
|
+
console.warn(
|
|
1813
|
+
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
1814
|
+
);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const db = createDb(env.DB);
|
|
1818
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1819
|
+
const candidates24h = await db.query.events.findMany({
|
|
1820
|
+
where: and(
|
|
1821
|
+
eq(events.status, "published"),
|
|
1822
|
+
eq(events.reminder24hSent, false)
|
|
1823
|
+
),
|
|
1824
|
+
columns: {
|
|
1825
|
+
id: true,
|
|
1826
|
+
dateTime: true,
|
|
1827
|
+
startTime: true
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
for (const event of candidates24h) {
|
|
1831
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1832
|
+
if (!startsAt) continue;
|
|
1833
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1834
|
+
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
1835
|
+
await env.EMAIL_QUEUE.send({
|
|
1836
|
+
type: "send_reminder_email",
|
|
1837
|
+
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const candidates1h = await db.query.events.findMany({
|
|
1842
|
+
where: and(
|
|
1843
|
+
eq(events.status, "published"),
|
|
1844
|
+
eq(events.reminder1hSent, false)
|
|
1845
|
+
),
|
|
1846
|
+
columns: {
|
|
1847
|
+
id: true,
|
|
1848
|
+
dateTime: true,
|
|
1849
|
+
startTime: true
|
|
1850
|
+
}
|
|
1593
1851
|
});
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1852
|
+
for (const event of candidates1h) {
|
|
1853
|
+
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
1854
|
+
if (!startsAt) continue;
|
|
1855
|
+
const hoursUntil = (startsAt - now) / 3600;
|
|
1856
|
+
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
1857
|
+
await env.EMAIL_QUEUE.send({
|
|
1858
|
+
type: "send_reminder_email",
|
|
1859
|
+
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1597
1862
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/routes/internal/reminder-emails.ts
|
|
1866
|
+
var reminderEmailsRoute = new Hono();
|
|
1867
|
+
reminderEmailsRoute.post("/", internalMiddleware, async (c) => {
|
|
1868
|
+
await reminderEmails(c.env);
|
|
1603
1869
|
return c.json({ ok: true });
|
|
1604
1870
|
});
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1871
|
+
var RENEWAL_WINDOW = 86400;
|
|
1872
|
+
async function renewWatches(env) {
|
|
1873
|
+
const db = createDb(env.DB);
|
|
1874
|
+
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
1875
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1876
|
+
const threshold = now + RENEWAL_WINDOW;
|
|
1877
|
+
const expiring = await db.query.events.findMany({
|
|
1878
|
+
where: and(
|
|
1879
|
+
eq(events.status, "published"),
|
|
1880
|
+
lte(events.watchExpiresAt, threshold)
|
|
1881
|
+
),
|
|
1882
|
+
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
1883
|
+
});
|
|
1884
|
+
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
1885
|
+
let renewed = 0;
|
|
1886
|
+
for (const event of watchEvents) {
|
|
1887
|
+
try {
|
|
1888
|
+
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
1889
|
+
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
1890
|
+
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
1891
|
+
renewed++;
|
|
1892
|
+
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
1895
|
+
}
|
|
1626
1896
|
}
|
|
1897
|
+
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
1627
1898
|
}
|
|
1899
|
+
|
|
1900
|
+
// src/routes/internal/renew-watches.ts
|
|
1901
|
+
var renewWatchesRoute = new Hono();
|
|
1902
|
+
renewWatchesRoute.post("/", internalMiddleware, async (c) => {
|
|
1903
|
+
await renewWatches(c.env);
|
|
1904
|
+
return c.json({ ok: true });
|
|
1905
|
+
});
|
|
1628
1906
|
var ALLOWED_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
1629
1907
|
"image/jpeg",
|
|
1630
1908
|
"image/png",
|
|
@@ -1891,6 +2169,10 @@ function createApp(options = {}) {
|
|
|
1891
2169
|
app2.route("/api/faqs", faqsRoute);
|
|
1892
2170
|
app2.route("/api/uploads", uploadsRoute);
|
|
1893
2171
|
app2.route("/internal/gforms-webhook", gformsWebhookRoute);
|
|
2172
|
+
app2.route("/internal/reconcile-slots", reconcileSlotsRoute);
|
|
2173
|
+
app2.route("/internal/batch-release", batchReleaseRoute);
|
|
2174
|
+
app2.route("/internal/reminder-emails", reminderEmailsRoute);
|
|
2175
|
+
app2.route("/internal/renew-watches", renewWatchesRoute);
|
|
1894
2176
|
app2.onError(errorHandler);
|
|
1895
2177
|
app2.notFound(
|
|
1896
2178
|
(c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404)
|
|
@@ -1996,6 +2278,7 @@ var SesError = class extends Error {
|
|
|
1996
2278
|
this.status = status;
|
|
1997
2279
|
this.name = "SesError";
|
|
1998
2280
|
}
|
|
2281
|
+
status;
|
|
1999
2282
|
/**
|
|
2000
2283
|
* True for errors that are permanent (not worth retrying via SES again).
|
|
2001
2284
|
* 400 BadRequest, 403 Forbidden, 404 NotFound → non-retryable.
|
|
@@ -2320,168 +2603,6 @@ async function processJob(job, services) {
|
|
|
2320
2603
|
}
|
|
2321
2604
|
}
|
|
2322
2605
|
}
|
|
2323
|
-
async function batchRelease(env) {
|
|
2324
|
-
const db = createDb(env.DB);
|
|
2325
|
-
const cache = new CacheService(env.KV);
|
|
2326
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2327
|
-
const toPublish = await db.query.events.findMany({
|
|
2328
|
-
where: and(eq(events.status, "queued"), lte(events.releaseAt, now)),
|
|
2329
|
-
columns: { id: true, slug: true }
|
|
2330
|
-
});
|
|
2331
|
-
if (toPublish.length === 0) return;
|
|
2332
|
-
const ids = toPublish.map((e) => e.id);
|
|
2333
|
-
await db.update(events).set({ status: "published", publishedAt: sql`(unixepoch())` }).where(
|
|
2334
|
-
// Drizzle doesn't have inArray for D1; use raw SQL for batch
|
|
2335
|
-
sql`${events.id} IN (${sql.join(
|
|
2336
|
-
ids.map((id) => sql`${id}`),
|
|
2337
|
-
sql`, `
|
|
2338
|
-
)})`
|
|
2339
|
-
);
|
|
2340
|
-
await cache.del("events:list");
|
|
2341
|
-
await cache.del("events:etag");
|
|
2342
|
-
console.log(
|
|
2343
|
-
`[batch-release] Published ${toPublish.length} events:`,
|
|
2344
|
-
toPublish.map((e) => e.slug).join(", ")
|
|
2345
|
-
);
|
|
2346
|
-
}
|
|
2347
|
-
var LOCK_KEY = "cron:reconcile-slots:lock";
|
|
2348
|
-
var LOCK_TTL = 300;
|
|
2349
|
-
async function reconcileSlots(env) {
|
|
2350
|
-
const db = createDb(env.DB);
|
|
2351
|
-
const cache = new CacheService(env.KV);
|
|
2352
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2353
|
-
const slots = new SlotsService(db, cache);
|
|
2354
|
-
const lock = await cache.get(LOCK_KEY);
|
|
2355
|
-
if (lock) {
|
|
2356
|
-
console.log("[reconcile-slots] Lock held, skipping.");
|
|
2357
|
-
return;
|
|
2358
|
-
}
|
|
2359
|
-
await cache.set(LOCK_KEY, "1", LOCK_TTL);
|
|
2360
|
-
try {
|
|
2361
|
-
const publishedEvents = await db.query.events.findMany({
|
|
2362
|
-
where: eq(events.status, "published"),
|
|
2363
|
-
columns: { id: true, slug: true, gformsId: true, registeredSlots: true }
|
|
2364
|
-
});
|
|
2365
|
-
const eventsWithForms = publishedEvents.filter((e) => e.gformsId);
|
|
2366
|
-
let corrected = 0;
|
|
2367
|
-
for (const event of eventsWithForms) {
|
|
2368
|
-
try {
|
|
2369
|
-
const googleCount = await gforms.getExactResponseCount(event.gformsId);
|
|
2370
|
-
const localCount = event.registeredSlots;
|
|
2371
|
-
if (googleCount !== localCount) {
|
|
2372
|
-
console.warn(
|
|
2373
|
-
`[reconcile-slots] Drift on "${event.slug}": local=${localCount}, google=${googleCount}`
|
|
2374
|
-
);
|
|
2375
|
-
await slots.correctCount(event.slug, googleCount);
|
|
2376
|
-
corrected++;
|
|
2377
|
-
}
|
|
2378
|
-
} catch (err) {
|
|
2379
|
-
console.error(`[reconcile-slots] Error checking "${event.slug}":`, err);
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
console.log(
|
|
2383
|
-
`[reconcile-slots] Checked ${eventsWithForms.length} events, corrected ${corrected}.`
|
|
2384
|
-
);
|
|
2385
|
-
} finally {
|
|
2386
|
-
await cache.del(LOCK_KEY);
|
|
2387
|
-
}
|
|
2388
|
-
}
|
|
2389
|
-
function parseStartTimestamp(dateTime, startTime) {
|
|
2390
|
-
if (!dateTime) return null;
|
|
2391
|
-
const combined = startTime ? `${dateTime} ${startTime}` : dateTime;
|
|
2392
|
-
const ms = Date.parse(combined);
|
|
2393
|
-
return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
|
|
2394
|
-
}
|
|
2395
|
-
async function reminderEmails(env) {
|
|
2396
|
-
if (!env.EMAIL_QUEUE) {
|
|
2397
|
-
console.warn(
|
|
2398
|
-
"[reminder-emails] EMAIL_QUEUE binding not configured, skipping."
|
|
2399
|
-
);
|
|
2400
|
-
return;
|
|
2401
|
-
}
|
|
2402
|
-
const hasSes = !!(env.SES_REGION && env.SES_ACCESS_KEY_ID && env.SES_SECRET_ACCESS_KEY);
|
|
2403
|
-
const hasResend = !!env.RESEND_API_KEY;
|
|
2404
|
-
if (!hasSes && !hasResend) {
|
|
2405
|
-
console.warn(
|
|
2406
|
-
"[reminder-emails] No email providers configured. Skipping reminders."
|
|
2407
|
-
);
|
|
2408
|
-
return;
|
|
2409
|
-
}
|
|
2410
|
-
const db = createDb(env.DB);
|
|
2411
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2412
|
-
const candidates24h = await db.query.events.findMany({
|
|
2413
|
-
where: and(
|
|
2414
|
-
eq(events.status, "published"),
|
|
2415
|
-
eq(events.reminder24hSent, false)
|
|
2416
|
-
),
|
|
2417
|
-
columns: {
|
|
2418
|
-
id: true,
|
|
2419
|
-
dateTime: true,
|
|
2420
|
-
startTime: true
|
|
2421
|
-
}
|
|
2422
|
-
});
|
|
2423
|
-
for (const event of candidates24h) {
|
|
2424
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2425
|
-
if (!startsAt) continue;
|
|
2426
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2427
|
-
if (hoursUntil <= 25 && hoursUntil >= 23) {
|
|
2428
|
-
await env.EMAIL_QUEUE.send({
|
|
2429
|
-
type: "send_reminder_email",
|
|
2430
|
-
payload: { eventId: event.id, hoursBeforeEvent: 24 }
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
const candidates1h = await db.query.events.findMany({
|
|
2435
|
-
where: and(
|
|
2436
|
-
eq(events.status, "published"),
|
|
2437
|
-
eq(events.reminder1hSent, false)
|
|
2438
|
-
),
|
|
2439
|
-
columns: {
|
|
2440
|
-
id: true,
|
|
2441
|
-
dateTime: true,
|
|
2442
|
-
startTime: true
|
|
2443
|
-
}
|
|
2444
|
-
});
|
|
2445
|
-
for (const event of candidates1h) {
|
|
2446
|
-
const startsAt = parseStartTimestamp(event.dateTime, event.startTime);
|
|
2447
|
-
if (!startsAt) continue;
|
|
2448
|
-
const hoursUntil = (startsAt - now) / 3600;
|
|
2449
|
-
if (hoursUntil <= 1.5 && hoursUntil >= 0) {
|
|
2450
|
-
await env.EMAIL_QUEUE.send({
|
|
2451
|
-
type: "send_reminder_email",
|
|
2452
|
-
payload: { eventId: event.id, hoursBeforeEvent: 1 }
|
|
2453
|
-
});
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
var RENEWAL_WINDOW = 86400;
|
|
2458
|
-
async function renewWatches(env) {
|
|
2459
|
-
const db = createDb(env.DB);
|
|
2460
|
-
const gforms = new GFormsService(env.GFORMS_SERVICE_ACCOUNT_JSON);
|
|
2461
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
2462
|
-
const threshold = now + RENEWAL_WINDOW;
|
|
2463
|
-
const expiring = await db.query.events.findMany({
|
|
2464
|
-
where: and(
|
|
2465
|
-
eq(events.status, "published"),
|
|
2466
|
-
lte(events.watchExpiresAt, threshold)
|
|
2467
|
-
),
|
|
2468
|
-
columns: { id: true, slug: true, gformsId: true, watchId: true, watchExpiresAt: true }
|
|
2469
|
-
});
|
|
2470
|
-
const watchEvents = expiring.filter((e) => e.gformsId && e.watchId);
|
|
2471
|
-
let renewed = 0;
|
|
2472
|
-
for (const event of watchEvents) {
|
|
2473
|
-
try {
|
|
2474
|
-
const result = await gforms.renewWatch(event.gformsId, event.watchId);
|
|
2475
|
-
const newExpiry = Math.floor(new Date(result.expireTime).getTime() / 1e3);
|
|
2476
|
-
await db.update(events).set({ watchExpiresAt: newExpiry }).where(eq(events.id, event.id));
|
|
2477
|
-
renewed++;
|
|
2478
|
-
console.log(`[renew-watches] Renewed Watch for "${event.slug}", expires ${result.expireTime}`);
|
|
2479
|
-
} catch (err) {
|
|
2480
|
-
console.error(`[renew-watches] Failed to renew Watch for "${event.slug}":`, err);
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
console.log(`[renew-watches] Renewed ${renewed}/${watchEvents.length} watches.`);
|
|
2484
|
-
}
|
|
2485
2606
|
|
|
2486
2607
|
// src/db/migrate.ts
|
|
2487
2608
|
var PATCH_STATEMENTS = [
|