@orion-studios/cms 0.5.4 → 0.5.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.
@@ -3,12 +3,14 @@ import {
3
3
  } from "../chunk-AYV6KYDP.js";
4
4
  import {
5
5
  createMemoryRateLimitStore,
6
+ getAutoReplyEmailFields,
6
7
  isOriginAllowed,
7
- processSubmission
8
- } from "../chunk-VPUODCNH.js";
8
+ processSubmission,
9
+ resolveAutoReplyEmailField
10
+ } from "../chunk-CFZP7674.js";
9
11
 
10
12
  // src/server/routes.ts
11
- import { createHash as createHash2 } from "crypto";
13
+ import { createHash as createHash2, createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
12
14
 
13
15
  // src/analytics/aggregate.ts
14
16
  var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
@@ -38,7 +40,7 @@ function normalizeSource(utmSource, referrer) {
38
40
  if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
39
41
  return host;
40
42
  }
41
- var isConversion = (event) => event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit");
43
+ var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
42
44
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
43
45
  function computeKpis(events) {
44
46
  const sessions = /* @__PURE__ */ new Set();
@@ -63,10 +65,12 @@ function computeKpis(events) {
63
65
  if (event.type === "click" && event.name === "call") calls += 1;
64
66
  if (event.type === "click" && event.name === "email") emails += 1;
65
67
  if (event.type === "click" && event.name === "portal") portalClicks += 1;
66
- if (event.type === "form" && event.name.endsWith(":submit")) formSubmits += 1;
68
+ if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
69
+ formSubmits += 1;
70
+ }
67
71
  if (isConversion(event) && event.session_key) converting.add(event.session_key);
68
72
  }
69
- const conversions = calls + formSubmits + emails;
73
+ const conversions = events.filter(isConversion).length;
70
74
  const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
71
75
  return {
72
76
  visitors: visitors.size,
@@ -173,7 +177,7 @@ function aggregateAnalytics(events, previousEvents, range) {
173
177
  const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
174
178
  if (stage === "view") entry.views += 1;
175
179
  if (stage === "start") entry.starts += 1;
176
- if (stage === "submit") entry.submits += 1;
180
+ if (stage === "submit" && event.server_verified === true) entry.submits += 1;
177
181
  forms.set(slug, entry);
178
182
  }
179
183
  const notFound = /* @__PURE__ */ new Map();
@@ -338,12 +342,16 @@ ${rendered}
338
342
  }
339
343
  return lines.join("\n");
340
344
  }
345
+ function resolveSubmitterEmail(config, data) {
346
+ const fieldName = resolveAutoReplyEmailField(config);
347
+ if (!fieldName) return void 0;
348
+ const value = data[fieldName];
349
+ return isEmail(value) ? value.trim().toLowerCase() : void 0;
350
+ }
341
351
  async function notifySubmission(args) {
342
352
  const notify = args.config.notify || {};
343
353
  const recipients = (notify.emails || []).filter(isEmail);
344
- const submitterEmail = Object.entries(args.data).find(
345
- ([key, value]) => key.toLowerCase().includes("email") && isEmail(value)
346
- )?.[1];
354
+ const submitterEmail = resolveSubmitterEmail(args.config, args.data);
347
355
  const subject = (notify.subject || "New {form} submission").replace(
348
356
  /\{form\}/g,
349
357
  args.formTitle || "form"
@@ -360,7 +368,7 @@ async function notifySubmission(args) {
360
368
  console.error("[orion-cms] submission notification failed:", error);
361
369
  }
362
370
  }
363
- if (notify.autoReply && submitterEmail) {
371
+ if (notify.autoReply === true && submitterEmail && args.autoReplyAllowed === true) {
364
372
  try {
365
373
  await args.sendEmail({
366
374
  to: [submitterEmail],
@@ -429,12 +437,15 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
429
437
  var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
430
438
  var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
431
439
  var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
440
+ var PREVIEW_SESSION_COOKIE = "orion_preview_session";
432
441
  function createPreviewToken(pageId, secret, ttlMs = PREVIEW_TOKEN_TTL_MS) {
433
442
  const payload = encode(`${pageId}|${Date.now() + ttlMs}`);
434
443
  return `${payload}.${sign(payload, secret)}`;
435
444
  }
436
445
  function verifyPreviewToken(token, secret) {
437
- const [payload, signature] = token.split(".");
446
+ const segments = token.split(".");
447
+ if (segments.length !== 2) return null;
448
+ const [payload, signature] = segments;
438
449
  if (!payload || !signature) return null;
439
450
  const expected = sign(payload, secret);
440
451
  const expectedBuffer = Buffer.from(expected);
@@ -506,6 +517,11 @@ async function resolveUser(request, client = getServiceClient()) {
506
517
 
507
518
  // src/server/sync.ts
508
519
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
520
+ var publicFormConfig = (value) => {
521
+ const config = isRecord(value) ? { ...value } : {};
522
+ delete config.notify;
523
+ return config;
524
+ };
509
525
  var mimeByExtension = {
510
526
  avif: "image/avif",
511
527
  gif: "image/gif",
@@ -629,7 +645,7 @@ async function runContentSync(client, registry, input) {
629
645
  {
630
646
  slug: form.slug,
631
647
  title: typeof form.title === "string" ? form.title : form.slug,
632
- config: isRecord(form.config) ? form.config : {},
648
+ config: publicFormConfig(form.config),
633
649
  success_message: typeof form.successMessage === "string" ? form.successMessage : "",
634
650
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
635
651
  },
@@ -642,7 +658,6 @@ async function runContentSync(client, registry, input) {
642
658
  }
643
659
 
644
660
  // src/server/routes.ts
645
- import { timingSafeEqual as timingSafeEqual2 } from "crypto";
646
661
  var tokenEquals = (candidate, secret) => {
647
662
  if (!candidate || !secret) return false;
648
663
  const a = Buffer.from(candidate);
@@ -666,15 +681,29 @@ var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
666
681
  var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
667
682
  var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
668
683
  var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
669
- var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
670
- "image/jpeg",
671
- "image/png",
672
- "image/webp",
673
- "image/gif",
674
- "image/avif",
675
- "image/svg+xml",
676
- "application/pdf"
677
- ]);
684
+ var UPLOAD_RULES = {
685
+ "image/jpeg": {
686
+ extensions: ["jpg", "jpeg"],
687
+ decodedFormat: "jpeg"
688
+ },
689
+ "image/png": {
690
+ extensions: ["png"],
691
+ decodedFormat: "png"
692
+ },
693
+ "image/webp": {
694
+ extensions: ["webp"],
695
+ decodedFormat: "webp"
696
+ },
697
+ "image/gif": {
698
+ extensions: ["gif"],
699
+ decodedFormat: "gif"
700
+ },
701
+ "image/avif": {
702
+ extensions: ["avif"],
703
+ decodedFormat: "heif"
704
+ }
705
+ };
706
+ var uploadExtension = (filename) => filename.split(".").pop()?.toLowerCase() || "";
678
707
  async function readJson(request) {
679
708
  try {
680
709
  const parsed = await request.json();
@@ -735,22 +764,36 @@ async function readJsonLimited(request, limits) {
735
764
  return parsed;
736
765
  }
737
766
  var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
767
+ var requestPagePath = (request) => {
768
+ const referrer = request.headers.get("referer");
769
+ if (!referrer) return "";
770
+ try {
771
+ const page = new URL(referrer);
772
+ if (page.origin !== new URL(request.url).origin) return "";
773
+ return PATH_PATTERN.test(page.pathname) ? page.pathname : "";
774
+ } catch {
775
+ return "";
776
+ }
777
+ };
738
778
  function createDurableRateLimitStore(getClient, options) {
739
779
  const max = options?.max ?? 5;
740
780
  const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
741
781
  const bucket = options?.bucket ?? "default";
782
+ const failClosed = options?.failClosed === true;
742
783
  return {
743
- async isLimited(key) {
784
+ async isLimited(key, _now, cost = 1) {
744
785
  try {
745
- const { data, error } = await getClient().rpc("cms_rate_limit_consume", {
786
+ const args = {
746
787
  p_key: `${bucket}:${key}`,
747
788
  p_max: max,
748
789
  p_window_seconds: windowSeconds
749
- });
750
- if (error) return false;
751
- return data === false;
790
+ };
791
+ if (cost !== 1) args.p_cost = cost;
792
+ const { data, error } = await getClient().rpc("cms_rate_limit_consume", args);
793
+ if (error) return failClosed;
794
+ return failClosed ? data !== true : data === false;
752
795
  } catch {
753
- return false;
796
+ return failClosed;
754
797
  }
755
798
  }
756
799
  };
@@ -766,7 +809,86 @@ function createCmsRoutes(options) {
766
809
  if (hostedMemoryMode) return "";
767
810
  return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
768
811
  };
769
- const eventsRateLimit = options.memoryMode ? createMemoryRateLimitStore({ max: 120, windowMs: 6e4 }) : createDurableRateLimitStore(db, { max: 120, windowMs: 6e4, bucket: "events" });
812
+ const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
813
+ const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
814
+ const boundedAutoReplyMax = (name, fallback) => {
815
+ const candidate = options.autoReplyLimits?.[name];
816
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
817
+ };
818
+ const autoReplyLimits = {
819
+ recipientPerHour: autoReplyLimit(
820
+ boundedAutoReplyMax("recipientPerHour", 3),
821
+ 36e5,
822
+ "auto-reply-recipient-hour"
823
+ ),
824
+ sitePerMinute: autoReplyLimit(
825
+ boundedAutoReplyMax("sitePerMinute", 10),
826
+ 6e4,
827
+ "auto-reply-site-minute"
828
+ ),
829
+ sitePerDay: autoReplyLimit(
830
+ boundedAutoReplyMax("sitePerDay", 200),
831
+ 864e5,
832
+ "auto-reply-site-day"
833
+ )
834
+ };
835
+ const autoReplyRecipientKey = (email) => createHmac3(
836
+ "sha256",
837
+ previewSecret() || syncToken || "orion-memory-auto-reply-secret"
838
+ ).update(`recipient:${email}`).digest("hex").slice(0, 24);
839
+ const maySendAutoReply = async (config, data) => {
840
+ if (config.notify?.autoReply !== true) return false;
841
+ const recipient = resolveSubmitterEmail(config, data);
842
+ if (!recipient) return false;
843
+ const now = Date.now();
844
+ if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
845
+ return false;
846
+ }
847
+ if (await autoReplyLimits.sitePerMinute.isLimited("all", now)) return false;
848
+ if (await autoReplyLimits.sitePerDay.isLimited("all", now)) return false;
849
+ return true;
850
+ };
851
+ const boundedAnalyticsMax = (name, fallback) => {
852
+ const candidate = options.analyticsLimits?.[name];
853
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
854
+ };
855
+ const analyticsLimits = {
856
+ siteRequestsPerMinute: analyticsLimit(
857
+ boundedAnalyticsMax("siteRequestsPerMinute", 300),
858
+ 6e4,
859
+ "events-site-request-minute"
860
+ ),
861
+ siteRequestsPerDay: analyticsLimit(
862
+ boundedAnalyticsMax("siteRequestsPerDay", 2e4),
863
+ 864e5,
864
+ "events-site-request-day"
865
+ ),
866
+ requestsPerMinute: analyticsLimit(
867
+ boundedAnalyticsMax("requestsPerMinute", 60),
868
+ 6e4,
869
+ "events-request-minute"
870
+ ),
871
+ clientEventsPerMinute: analyticsLimit(
872
+ boundedAnalyticsMax("clientEventsPerMinute", 120),
873
+ 6e4,
874
+ "events-client-minute"
875
+ ),
876
+ clientEventsPerDay: analyticsLimit(
877
+ boundedAnalyticsMax("clientEventsPerDay", 1e3),
878
+ 864e5,
879
+ "events-client-day"
880
+ ),
881
+ siteEventsPerMinute: analyticsLimit(
882
+ boundedAnalyticsMax("siteEventsPerMinute", 2e3),
883
+ 6e4,
884
+ "events-site-minute"
885
+ ),
886
+ siteEventsPerDay: analyticsLimit(
887
+ boundedAnalyticsMax("siteEventsPerDay", 5e4),
888
+ 864e5,
889
+ "events-site-day"
890
+ )
891
+ };
770
892
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
771
893
  const requestSessionKey = (request) => {
772
894
  const secret = previewSecret() || "orion-analytics";
@@ -1050,27 +1172,16 @@ function createCmsRoutes(options) {
1050
1172
  const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
1051
1173
  if (!data) return errors.notFound();
1052
1174
  const token = createPreviewToken(id, secret);
1053
- return json({ token, path: data.path, url: `${data.path}?preview=${encodeURIComponent(token)}` });
1054
- };
1055
- const previewPage = async (request) => {
1056
- if (hostedMemoryMode) return errors.notFound();
1057
- const secret = previewSecret();
1058
- if (!secret) return errors.notFound();
1059
- const token = new URL(request.url).searchParams.get("token") || "";
1060
- const pageId = verifyPreviewToken(token, secret);
1061
- if (!pageId) return errors.forbidden();
1062
- const { data } = await db().from("cms_pages").select("id, slug, path, title, seo, draft_layout").eq("id", pageId).maybeSingle();
1063
- if (!data) return errors.notFound();
1064
- return json({
1065
- page: {
1066
- id: data.id,
1067
- slug: data.slug,
1068
- path: data.path,
1069
- title: data.title,
1070
- seo: data.seo ?? {},
1071
- layout: data.draft_layout ?? []
1072
- }
1073
- });
1175
+ const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
1176
+ const response = json({ path: data.path, url: previewUrl });
1177
+ const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
1178
+ response.headers.set("cache-control", "private, no-store");
1179
+ response.headers.set("referrer-policy", "no-referrer");
1180
+ response.headers.append(
1181
+ "set-cookie",
1182
+ `${PREVIEW_SESSION_COOKIE}=${token}; Path=${previewUrl}; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(PREVIEW_TOKEN_TTL_MS / 1e3)}${secure ? "; Secure" : ""}`
1183
+ );
1184
+ return response;
1074
1185
  };
1075
1186
  const listVersions = async (request, id) => {
1076
1187
  const auth = await guard(request, "pages.restore");
@@ -1158,15 +1269,56 @@ function createCmsRoutes(options) {
1158
1269
  if (error) return errors.badRequest(error.message);
1159
1270
  return json({ media: data });
1160
1271
  };
1161
- const validateUpload = (file) => {
1272
+ const prepareUpload = async (file, target) => {
1162
1273
  if (file.size > maxUploadBytes) {
1163
1274
  return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1164
1275
  }
1165
1276
  const type = file.type || "";
1166
- if (!ALLOWED_UPLOAD_TYPES.has(type)) {
1167
- return "Unsupported file type. Allowed: images (JPEG, PNG, WebP, GIF, AVIF, SVG) and PDF.";
1277
+ const rule = UPLOAD_RULES[type];
1278
+ if (!rule) {
1279
+ return "Unsupported file type. Allowed: JPEG, PNG, WebP, GIF, and AVIF images.";
1280
+ }
1281
+ const extension = uploadExtension(file.name);
1282
+ if (!rule.extensions.includes(extension)) {
1283
+ return "File extension does not match its declared type.";
1284
+ }
1285
+ if (target) {
1286
+ const knownTargetType = Boolean(UPLOAD_RULES[target.mimeType]);
1287
+ const pathMatches = target.path.startsWith("data:") || rule.extensions.includes(uploadExtension(target.path));
1288
+ if (knownTargetType && target.mimeType !== type || !knownTargetType && !pathMatches) {
1289
+ return "Replacement files must use the same format as the stored file.";
1290
+ }
1291
+ }
1292
+ try {
1293
+ const { default: sharp } = await import("sharp");
1294
+ const input = Buffer.from(await file.arrayBuffer());
1295
+ const image = sharp(input, {
1296
+ animated: type === "image/gif",
1297
+ failOn: "error",
1298
+ limitInputPixels: 4e7
1299
+ });
1300
+ const metadata = await image.metadata();
1301
+ const width = metadata.width || 0;
1302
+ const height = metadata.height || 0;
1303
+ const pages = metadata.pages || 1;
1304
+ if (metadata.format !== rule.decodedFormat || width < 1 || height < 1 || width * height * pages > 4e7) {
1305
+ return "File contents do not match its declared type.";
1306
+ }
1307
+ const normalized = type === "image/jpeg" ? await image.rotate().jpeg({ quality: 90 }).toBuffer() : type === "image/png" ? await image.rotate().png().toBuffer() : type === "image/webp" ? await image.rotate().webp({ quality: 90 }).toBuffer() : type === "image/gif" ? await image.rotate().gif().toBuffer() : await image.rotate().avif({ quality: 60 }).toBuffer();
1308
+ if (normalized.byteLength > maxUploadBytes) {
1309
+ return `Normalized file is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1310
+ }
1311
+ return {
1312
+ file: new File([Uint8Array.from(normalized)], file.name, {
1313
+ type,
1314
+ lastModified: file.lastModified
1315
+ }),
1316
+ width,
1317
+ height
1318
+ };
1319
+ } catch {
1320
+ return "File contents do not match its declared type.";
1168
1321
  }
1169
- return null;
1170
1322
  };
1171
1323
  const uploadMedia = async (request) => {
1172
1324
  const auth = await guard(request, "media.upload");
@@ -1174,35 +1326,32 @@ function createCmsRoutes(options) {
1174
1326
  const form = await request.formData().catch(() => null);
1175
1327
  const file = form?.get("file");
1176
1328
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1177
- const invalid = validateUpload(file);
1178
- if (invalid) return errors.badRequest(invalid);
1329
+ const prepared = await prepareUpload(file);
1330
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1331
+ const normalizedFile = prepared.file;
1179
1332
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1180
1333
  let storagePath = `${Date.now().toString(36)}-${safeName}`;
1181
1334
  if (options.memoryMode) {
1182
- const buffer = Buffer.from(await file.arrayBuffer());
1183
- storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1184
- } else {
1185
- const { error: storageError } = await db().storage.from("media").upload(storagePath, file, { contentType: file.type || "application/octet-stream" });
1186
- if (storageError) return errors.badRequest(storageError.message);
1335
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1336
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1187
1337
  }
1188
- const uploadedToStorage = !options.memoryMode;
1189
- const width = Number(form.get("width")) || null;
1190
- const height = Number(form.get("height")) || null;
1191
1338
  const { data, error } = await db().from("cms_media").insert({
1192
1339
  storage_path: storagePath,
1193
1340
  filename: safeName,
1194
1341
  alt: String(form.get("alt") || ""),
1195
1342
  caption: String(form.get("caption") || ""),
1196
- mime_type: file.type || "",
1197
- width,
1198
- height,
1199
- filesize: file.size
1343
+ mime_type: normalizedFile.type,
1344
+ width: prepared.width,
1345
+ height: prepared.height,
1346
+ filesize: normalizedFile.size
1200
1347
  }).select("*").single();
1201
- if (error) {
1202
- if (uploadedToStorage) {
1203
- await db().storage.from("media").remove([storagePath]).catch(() => void 0);
1348
+ if (error) return errors.badRequest(error.message);
1349
+ if (!options.memoryMode) {
1350
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, { contentType: normalizedFile.type });
1351
+ if (storageError) {
1352
+ await db().from("cms_media").delete().eq("id", data.id);
1353
+ return errors.badRequest(storageError.message);
1204
1354
  }
1205
- return errors.badRequest(error.message);
1206
1355
  }
1207
1356
  await logActivity(auth.user, "media.upload", safeName);
1208
1357
  return json({ media: data }, 201);
@@ -1248,27 +1397,29 @@ function createCmsRoutes(options) {
1248
1397
  const form = await request.formData().catch(() => null);
1249
1398
  const file = form?.get("file");
1250
1399
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1251
- const invalid = validateUpload(file);
1252
- if (invalid) return errors.badRequest(invalid);
1400
+ const prepared = await prepareUpload(file, {
1401
+ path: String(doc.storage_path),
1402
+ mimeType: String(doc.mime_type || "")
1403
+ });
1404
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1405
+ const normalizedFile = prepared.file;
1253
1406
  let storagePath = String(doc.storage_path);
1254
1407
  if (options.memoryMode) {
1255
- const buffer = Buffer.from(await file.arrayBuffer());
1256
- storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1408
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1409
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1257
1410
  } else {
1258
- const { error: storageError } = await db().storage.from("media").upload(storagePath, file, {
1259
- contentType: file.type || "application/octet-stream",
1411
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, {
1412
+ contentType: normalizedFile.type,
1260
1413
  upsert: true
1261
1414
  });
1262
1415
  if (storageError) return errors.badRequest(storageError.message);
1263
1416
  }
1264
- const width = Number(form.get("width")) || null;
1265
- const height = Number(form.get("height")) || null;
1266
1417
  const { data, error } = await db().from("cms_media").update({
1267
1418
  storage_path: storagePath,
1268
- mime_type: file.type || "",
1269
- filesize: file.size,
1270
- width,
1271
- height,
1419
+ mime_type: normalizedFile.type,
1420
+ filesize: normalizedFile.size,
1421
+ width: prepared.width,
1422
+ height: prepared.height,
1272
1423
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1273
1424
  }).eq("id", id).select("*").single();
1274
1425
  if (error) return errors.badRequest(error.message);
@@ -1286,11 +1437,17 @@ function createCmsRoutes(options) {
1286
1437
  if (usage.length > 0 && !force) {
1287
1438
  return errors.conflict("This file is used on pages.", { usage });
1288
1439
  }
1440
+ const storagePath = String(doc.storage_path);
1441
+ if (!storagePath.startsWith("data:") && !storagePath.startsWith("/")) {
1442
+ try {
1443
+ const { error: storageError } = await db().storage.from("media").remove([storagePath]);
1444
+ if (storageError) return errors.badRequest(storageError.message);
1445
+ } catch (error2) {
1446
+ return errors.badRequest(error2 instanceof Error ? error2.message : "Unable to remove stored file.");
1447
+ }
1448
+ }
1289
1449
  const { error } = await db().from("cms_media").delete().eq("id", id);
1290
1450
  if (error) return errors.badRequest(error.message);
1291
- if (!String(doc.storage_path).startsWith("data:")) {
1292
- await db().storage.from("media").remove([doc.storage_path]).catch(() => void 0);
1293
- }
1294
1451
  await logActivity(auth.user, "media.delete", String(doc.filename));
1295
1452
  return json({ success: true });
1296
1453
  };
@@ -1356,8 +1513,32 @@ function createCmsRoutes(options) {
1356
1513
  const body = await readJson(request);
1357
1514
  if (!body) return errors.badRequest("Invalid body.");
1358
1515
  const config = isRecord2(body.config) ? { ...body.config } : {};
1359
- const notify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
1516
+ if (body.notify !== void 0 && !isRecord2(body.notify)) {
1517
+ return errors.badRequest("Notification settings must be an object.");
1518
+ }
1519
+ if (body.notify === void 0 && config.notify !== void 0 && !isRecord2(config.notify)) {
1520
+ return errors.badRequest("Notification settings must be an object.");
1521
+ }
1522
+ const rawNotify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
1360
1523
  delete config.notify;
1524
+ if (rawNotify.autoReply !== void 0 && typeof rawNotify.autoReply !== "boolean") {
1525
+ return errors.badRequest("autoReply must be a boolean.");
1526
+ }
1527
+ if (rawNotify.autoReplyEmailField !== void 0 && (typeof rawNotify.autoReplyEmailField !== "string" || !rawNotify.autoReplyEmailField.trim())) {
1528
+ return errors.badRequest("autoReplyEmailField must name a declared email field.");
1529
+ }
1530
+ const emailFields = getAutoReplyEmailFields(config);
1531
+ const selectedEmailField = typeof rawNotify.autoReplyEmailField === "string" ? rawNotify.autoReplyEmailField.trim() : "";
1532
+ if (selectedEmailField && !emailFields.includes(selectedEmailField)) {
1533
+ return errors.badRequest("autoReplyEmailField must name a declared email field.");
1534
+ }
1535
+ if (rawNotify.autoReply === true && emailFields.length !== 1 && !selectedEmailField) {
1536
+ return errors.badRequest("Auto-reply requires one selected declared email field.");
1537
+ }
1538
+ const notify = {
1539
+ ...rawNotify,
1540
+ ...selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
1541
+ };
1361
1542
  const { data, error } = await db().from("cms_forms").upsert(
1362
1543
  {
1363
1544
  slug,
@@ -1420,7 +1601,7 @@ function createCmsRoutes(options) {
1420
1601
  const csvEscape = (value) => {
1421
1602
  let text = Array.isArray(value) ? value.join("; ") : typeof value === "string" ? value : value === null || value === void 0 ? "" : JSON.stringify(value);
1422
1603
  if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
1423
- return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1604
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1424
1605
  };
1425
1606
  const exportSubmissions = async (request) => {
1426
1607
  const auth = await guard(request, "submissions.read");
@@ -1520,18 +1701,36 @@ function createCmsRoutes(options) {
1520
1701
  session_key: requestSessionKey(request)
1521
1702
  }).select("id").single();
1522
1703
  if (error) return errors.badRequest(error.message);
1704
+ try {
1705
+ const userAgent = request.headers.get("user-agent") || "";
1706
+ await db().from("cms_events").insert({
1707
+ session_key: requestSessionKey(request),
1708
+ visitor_key: "",
1709
+ type: "form",
1710
+ name: `${form.slug}:submit`,
1711
+ path: requestPagePath(request),
1712
+ referrer: "",
1713
+ utm: {},
1714
+ device: deviceFrom(userAgent),
1715
+ ...geoFrom(request),
1716
+ meta: {},
1717
+ server_verified: true
1718
+ });
1719
+ } catch {
1720
+ }
1523
1721
  if (sendEmail) {
1722
+ const notificationConfig = {
1723
+ ...form.config || {},
1724
+ ...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
1725
+ };
1524
1726
  await notifySubmission({
1525
1727
  sendEmail,
1526
1728
  formTitle: String(form.title || form.slug),
1527
- // Notify settings live in their own column; merge for legacy configs.
1528
- config: {
1529
- ...form.config || {},
1530
- ...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
1531
- },
1729
+ config: notificationConfig,
1532
1730
  successMessage: String(form.success_message || ""),
1533
1731
  data: result.normalizedData,
1534
- siteName: options.siteName
1732
+ siteName: options.siteName,
1733
+ autoReplyAllowed: await maySendAutoReply(notificationConfig, result.normalizedData)
1535
1734
  });
1536
1735
  }
1537
1736
  return json({ success: true, id: created.id });
@@ -1571,11 +1770,17 @@ function createCmsRoutes(options) {
1571
1770
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
1572
1771
  const userAgent = request.headers.get("user-agent") || "";
1573
1772
  if (isBotRequest(userAgent)) return json({ success: true });
1773
+ const now = Date.now();
1774
+ if (await analyticsLimits.siteRequestsPerMinute.isLimited("all", now) || await analyticsLimits.siteRequestsPerDay.isLimited("all", now)) {
1775
+ return json({ success: true });
1776
+ }
1574
1777
  const key = hashedClientKey(clientKey(request));
1575
- if (await eventsRateLimit.isLimited(key, Date.now())) return json({ success: true });
1778
+ if (await analyticsLimits.requestsPerMinute.isLimited(key, now)) {
1779
+ return json({ success: true });
1780
+ }
1576
1781
  const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
1577
1782
  if (!body) return json({ success: true });
1578
- const { rows } = parseEventBatch(body, {
1783
+ const parsed = parseEventBatch(body, {
1579
1784
  sessionKey: requestSessionKey(request),
1580
1785
  visitorKey: visitorKeyFor(
1581
1786
  body?.visitorId,
@@ -1584,9 +1789,25 @@ function createCmsRoutes(options) {
1584
1789
  device: deviceFrom(userAgent),
1585
1790
  ...geoFrom(request)
1586
1791
  });
1792
+ const rows = parsed.rows.filter(
1793
+ (row) => !(row.type === "form" && row.name.endsWith(":submit"))
1794
+ );
1587
1795
  if (rows.length > 0) {
1796
+ const limitChecks = [
1797
+ [analyticsLimits.clientEventsPerMinute, key],
1798
+ [analyticsLimits.clientEventsPerDay, key],
1799
+ [analyticsLimits.siteEventsPerMinute, "all"],
1800
+ [analyticsLimits.siteEventsPerDay, "all"]
1801
+ ];
1802
+ for (const [limit, limitKey] of limitChecks) {
1803
+ if (await limit.isLimited(limitKey, now, rows.length)) {
1804
+ return json({ success: true });
1805
+ }
1806
+ }
1588
1807
  try {
1589
- await db().from("cms_events").insert(rows);
1808
+ await db().from("cms_events").insert(
1809
+ rows.map((row) => ({ ...row, server_verified: false }))
1810
+ );
1590
1811
  } catch {
1591
1812
  }
1592
1813
  }
@@ -1596,7 +1817,7 @@ function createCmsRoutes(options) {
1596
1817
  const all = [];
1597
1818
  let cursor = 0;
1598
1819
  for (let page = 0; page < 60; page += 1) {
1599
- const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
1820
+ const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, server_verified, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
1600
1821
  if (error || !data || data.length === 0) break;
1601
1822
  all.push(...data);
1602
1823
  cursor = Number(data[data.length - 1].id);
@@ -1623,8 +1844,10 @@ function createCmsRoutes(options) {
1623
1844
  };
1624
1845
  const pruneEvents = async () => {
1625
1846
  const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
1847
+ const limitCutoff = new Date(Date.now() - 2 * 864e5).toISOString();
1626
1848
  try {
1627
1849
  await db().from("cms_events").delete().lt("created_at", cutoff);
1850
+ await db().rpc("cms_prune_rate_limits", { p_before: limitCutoff });
1628
1851
  } catch {
1629
1852
  }
1630
1853
  };
@@ -1872,7 +2095,6 @@ function createCmsRoutes(options) {
1872
2095
  return listVersions(request, second);
1873
2096
  }
1874
2097
  }
1875
- if (head === "preview" && !second && method === "GET") return previewPage(request);
1876
2098
  if (head === "versions" && second) {
1877
2099
  if (third === "restore" && method === "POST") return restoreVersion(request, second);
1878
2100
  if (!third && method === "GET") return getVersion(request, second);
@@ -2262,7 +2484,11 @@ function runRpc(store, fn, args = {}) {
2262
2484
  const limits = store.table("cms_rate_limits");
2263
2485
  const key = String(args.p_key);
2264
2486
  const max = Number(args.p_max);
2487
+ const cost = args.p_cost === void 0 ? 1 : Number(args.p_cost);
2265
2488
  const windowMs = Number(args.p_window_seconds) * 1e3;
2489
+ if (!Number.isSafeInteger(cost) || cost < 1 || cost > max) {
2490
+ return { data: false, error: null };
2491
+ }
2266
2492
  const nowMs = Date.now();
2267
2493
  let row = limits.find((r) => r.key === key);
2268
2494
  if (!row) {
@@ -2271,12 +2497,22 @@ function runRpc(store, fn, args = {}) {
2271
2497
  }
2272
2498
  if (nowMs - Number(row.window_start) >= windowMs) {
2273
2499
  row.window_start = nowMs;
2274
- row.count = 1;
2500
+ row.count = cost;
2275
2501
  } else {
2276
- row.count = Number(row.count) + 1;
2502
+ row.count = Number(row.count) + cost;
2277
2503
  }
2278
2504
  return { data: Number(row.count) <= max, error: null };
2279
2505
  }
2506
+ case "cms_prune_rate_limits": {
2507
+ const before = Date.parse(String(args.p_before));
2508
+ const limits = store.table("cms_rate_limits");
2509
+ const kept = limits.filter((row) => {
2510
+ const started = typeof row.window_start === "number" ? row.window_start : Date.parse(String(row.window_start));
2511
+ return !Number.isFinite(before) || !Number.isFinite(started) || started >= before;
2512
+ });
2513
+ store.setTable("cms_rate_limits", kept);
2514
+ return { data: limits.length - kept.length, error: null };
2515
+ }
2280
2516
  case "cms_publish_due_pages": {
2281
2517
  const nowMs = Date.now();
2282
2518
  const published = [];
@@ -2409,6 +2645,7 @@ function getMemoryCms() {
2409
2645
  export {
2410
2646
  CONTENT_CACHE_TAG,
2411
2647
  MEMORY_DEV_TOKEN,
2648
+ PREVIEW_SESSION_COOKIE,
2412
2649
  PREVIEW_TOKEN_TTL_MS,
2413
2650
  aggregateAnalytics,
2414
2651
  can,