@orion-studios/cms 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -348,7 +348,6 @@ function FormRenderer({
348
348
  return;
349
349
  }
350
350
  setState("success");
351
- funnel("submit");
352
351
  onSuccess?.();
353
352
  } catch {
354
353
  setState("error");
@@ -241,19 +241,19 @@ function createMemoryRateLimitStore(options) {
241
241
  const windowMs = options?.windowMs ?? 6e4;
242
242
  const hits = /* @__PURE__ */ new Map();
243
243
  return {
244
- isLimited(key, now) {
245
- const windowStart = now - windowMs;
246
- const entries = (hits.get(key) || []).filter((timestamp) => timestamp > windowStart);
247
- entries.push(now);
248
- hits.set(key, entries);
244
+ isLimited(key, now, cost = 1) {
245
+ const normalizedCost = Number.isSafeInteger(cost) && cost > 0 ? cost : max + 1;
246
+ const existing = hits.get(key);
247
+ const entry = !existing || now - existing.windowStart >= windowMs ? { windowStart: now, count: normalizedCost } : { ...existing, count: existing.count + normalizedCost };
248
+ hits.set(key, entry);
249
249
  if (hits.size > 1e4) {
250
- for (const [entryKey, timestamps] of hits) {
251
- if (timestamps.every((timestamp) => timestamp <= windowStart)) {
250
+ for (const [entryKey, candidate] of hits) {
251
+ if (now - candidate.windowStart >= windowMs) {
252
252
  hits.delete(entryKey);
253
253
  }
254
254
  }
255
255
  }
256
- return entries.length > max;
256
+ return entry.count > max;
257
257
  }
258
258
  };
259
259
  }
@@ -1,4 +1,4 @@
1
- export { F as FormConfig, a as FormFieldConfig, b as FormNotifyConfig, H as HONEYPOT_FIELD_NAME, P as ProcessSubmissionArgs, c as ProcessSubmissionResult, R as RateLimitStore, d as createMemoryRateLimitStore, p as processSubmission } from '../submission-BKdBedOe.js';
1
+ export { F as FormConfig, a as FormFieldConfig, b as FormNotifyConfig, H as HONEYPOT_FIELD_NAME, P as ProcessSubmissionArgs, c as ProcessSubmissionResult, R as RateLimitStore, d as createMemoryRateLimitStore, p as processSubmission } from '../submission-D_x2qNJl.js';
2
2
 
3
3
  /**
4
4
  * Shared, isomorphic form validation and normalization.
@@ -16,7 +16,7 @@ import {
16
16
  validatePhoneUS,
17
17
  validateRequired,
18
18
  validateUrl
19
- } from "../chunk-VPUODCNH.js";
19
+ } from "../chunk-LQTXMLKZ.js";
20
20
  export {
21
21
  FORM_FIELD_TYPES,
22
22
  HONEYPOT_FIELD_NAME,
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
  import {
4
4
  FormRenderer
5
- } from "../chunk-WQDHEQDE.js";
5
+ } from "../chunk-2BCEB26C.js";
6
6
  export {
7
7
  FormRenderer
8
8
  };
@@ -1,6 +1,6 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
2
  import { BlockRegistry, PageLayout } from '../blocks/index.js';
3
- import { F as FormConfig, R as RateLimitStore } from '../submission-BKdBedOe.js';
3
+ import { F as FormConfig, R as RateLimitStore } from '../submission-D_x2qNJl.js';
4
4
  export { CONTENT_CACHE_TAG } from '../content/index.js';
5
5
  import 'react';
6
6
  import 'zod';
@@ -69,6 +69,16 @@ type CmsRoutesOptions = {
69
69
  maxUploadBytes?: number;
70
70
  /** Days of raw analytics events to keep (default 90). */
71
71
  analyticsRetentionDays?: number;
72
+ /** Optional stricter public analytics budgets. Values cannot raise defaults. */
73
+ analyticsLimits?: Partial<{
74
+ requestsPerMinute: number;
75
+ siteRequestsPerMinute: number;
76
+ siteRequestsPerDay: number;
77
+ clientEventsPerMinute: number;
78
+ clientEventsPerDay: number;
79
+ siteEventsPerMinute: number;
80
+ siteEventsPerDay: number;
81
+ }>;
72
82
  /**
73
83
  * Dev-only: uploads are stored as data URLs instead of Supabase Storage so
74
84
  * the memory backend can serve them. Never enable in production.
@@ -92,6 +102,7 @@ declare function createDurableRateLimitStore(getClient: () => SupabaseClient, op
92
102
  max?: number;
93
103
  windowMs?: number;
94
104
  bucket?: string;
105
+ failClosed?: boolean;
95
106
  }): RateLimitStore;
96
107
  declare function createCmsRoutes(options: CmsRoutesOptions): {
97
108
  GET: (request: Request, context: RouteContext) => Promise<Response>;
@@ -123,8 +134,9 @@ declare function getPreviewPage(client: SupabaseClient, token: string, secret: s
123
134
  * out. Isomorphic and dependency-free so the same code serves the Supabase
124
135
  * and memory backends and is trivially testable.
125
136
  *
126
- * Conversion = a call tap, a form submission, or an email click. A session
127
- * "converted" when it contains at least one conversion event.
137
+ * Call and email taps are useful interaction counts, but browser events are
138
+ * forgeable. A conversion requires a server-verified event, currently a form
139
+ * submission recorded after the authoritative lead write succeeds.
128
140
  */
129
141
  type StoredEvent = {
130
142
  id?: number | string;
@@ -139,6 +151,7 @@ type StoredEvent = {
139
151
  region: string;
140
152
  city: string;
141
153
  meta: Record<string, unknown> | null;
154
+ server_verified?: boolean;
142
155
  created_at: string;
143
156
  };
144
157
  type AnalyticsKpis = {
@@ -1,11 +1,11 @@
1
- import {
2
- CONTENT_CACHE_TAG
3
- } from "../chunk-AYV6KYDP.js";
4
1
  import {
5
2
  createMemoryRateLimitStore,
6
3
  isOriginAllowed,
7
4
  processSubmission
8
- } from "../chunk-VPUODCNH.js";
5
+ } from "../chunk-LQTXMLKZ.js";
6
+ import {
7
+ CONTENT_CACHE_TAG
8
+ } from "../chunk-AYV6KYDP.js";
9
9
 
10
10
  // src/server/routes.ts
11
11
  import { createHash as createHash2 } from "crypto";
@@ -38,7 +38,7 @@ function normalizeSource(utmSource, referrer) {
38
38
  if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
39
39
  return host;
40
40
  }
41
- var isConversion = (event) => event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit");
41
+ var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
42
42
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
43
43
  function computeKpis(events) {
44
44
  const sessions = /* @__PURE__ */ new Set();
@@ -63,10 +63,12 @@ function computeKpis(events) {
63
63
  if (event.type === "click" && event.name === "call") calls += 1;
64
64
  if (event.type === "click" && event.name === "email") emails += 1;
65
65
  if (event.type === "click" && event.name === "portal") portalClicks += 1;
66
- if (event.type === "form" && event.name.endsWith(":submit")) formSubmits += 1;
66
+ if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
67
+ formSubmits += 1;
68
+ }
67
69
  if (isConversion(event) && event.session_key) converting.add(event.session_key);
68
70
  }
69
- const conversions = calls + formSubmits + emails;
71
+ const conversions = events.filter(isConversion).length;
70
72
  const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
71
73
  return {
72
74
  visitors: visitors.size,
@@ -173,7 +175,7 @@ function aggregateAnalytics(events, previousEvents, range) {
173
175
  const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
174
176
  if (stage === "view") entry.views += 1;
175
177
  if (stage === "start") entry.starts += 1;
176
- if (stage === "submit") entry.submits += 1;
178
+ if (stage === "submit" && event.server_verified === true) entry.submits += 1;
177
179
  forms.set(slug, entry);
178
180
  }
179
181
  const notFound = /* @__PURE__ */ new Map();
@@ -506,6 +508,11 @@ async function resolveUser(request, client = getServiceClient()) {
506
508
 
507
509
  // src/server/sync.ts
508
510
  var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
511
+ var publicFormConfig = (value) => {
512
+ const config = isRecord(value) ? { ...value } : {};
513
+ delete config.notify;
514
+ return config;
515
+ };
509
516
  var mimeByExtension = {
510
517
  avif: "image/avif",
511
518
  gif: "image/gif",
@@ -629,7 +636,7 @@ async function runContentSync(client, registry, input) {
629
636
  {
630
637
  slug: form.slug,
631
638
  title: typeof form.title === "string" ? form.title : form.slug,
632
- config: isRecord(form.config) ? form.config : {},
639
+ config: publicFormConfig(form.config),
633
640
  success_message: typeof form.successMessage === "string" ? form.successMessage : "",
634
641
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
635
642
  },
@@ -666,15 +673,29 @@ var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
666
673
  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
674
  var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
668
675
  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
- ]);
676
+ var UPLOAD_RULES = {
677
+ "image/jpeg": {
678
+ extensions: ["jpg", "jpeg"],
679
+ decodedFormat: "jpeg"
680
+ },
681
+ "image/png": {
682
+ extensions: ["png"],
683
+ decodedFormat: "png"
684
+ },
685
+ "image/webp": {
686
+ extensions: ["webp"],
687
+ decodedFormat: "webp"
688
+ },
689
+ "image/gif": {
690
+ extensions: ["gif"],
691
+ decodedFormat: "gif"
692
+ },
693
+ "image/avif": {
694
+ extensions: ["avif"],
695
+ decodedFormat: "heif"
696
+ }
697
+ };
698
+ var uploadExtension = (filename) => filename.split(".").pop()?.toLowerCase() || "";
678
699
  async function readJson(request) {
679
700
  try {
680
701
  const parsed = await request.json();
@@ -735,22 +756,36 @@ async function readJsonLimited(request, limits) {
735
756
  return parsed;
736
757
  }
737
758
  var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
759
+ var requestPagePath = (request) => {
760
+ const referrer = request.headers.get("referer");
761
+ if (!referrer) return "";
762
+ try {
763
+ const page = new URL(referrer);
764
+ if (page.origin !== new URL(request.url).origin) return "";
765
+ return PATH_PATTERN.test(page.pathname) ? page.pathname : "";
766
+ } catch {
767
+ return "";
768
+ }
769
+ };
738
770
  function createDurableRateLimitStore(getClient, options) {
739
771
  const max = options?.max ?? 5;
740
772
  const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
741
773
  const bucket = options?.bucket ?? "default";
774
+ const failClosed = options?.failClosed === true;
742
775
  return {
743
- async isLimited(key) {
776
+ async isLimited(key, _now, cost = 1) {
744
777
  try {
745
- const { data, error } = await getClient().rpc("cms_rate_limit_consume", {
778
+ const args = {
746
779
  p_key: `${bucket}:${key}`,
747
780
  p_max: max,
748
781
  p_window_seconds: windowSeconds
749
- });
750
- if (error) return false;
751
- return data === false;
782
+ };
783
+ if (cost !== 1) args.p_cost = cost;
784
+ const { data, error } = await getClient().rpc("cms_rate_limit_consume", args);
785
+ if (error) return failClosed;
786
+ return failClosed ? data !== true : data === false;
752
787
  } catch {
753
- return false;
788
+ return failClosed;
754
789
  }
755
790
  }
756
791
  };
@@ -766,7 +801,48 @@ function createCmsRoutes(options) {
766
801
  if (hostedMemoryMode) return "";
767
802
  return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
768
803
  };
769
- const eventsRateLimit = options.memoryMode ? createMemoryRateLimitStore({ max: 120, windowMs: 6e4 }) : createDurableRateLimitStore(db, { max: 120, windowMs: 6e4, bucket: "events" });
804
+ const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
805
+ const boundedAnalyticsMax = (name, fallback) => {
806
+ const candidate = options.analyticsLimits?.[name];
807
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
808
+ };
809
+ const analyticsLimits = {
810
+ siteRequestsPerMinute: analyticsLimit(
811
+ boundedAnalyticsMax("siteRequestsPerMinute", 300),
812
+ 6e4,
813
+ "events-site-request-minute"
814
+ ),
815
+ siteRequestsPerDay: analyticsLimit(
816
+ boundedAnalyticsMax("siteRequestsPerDay", 2e4),
817
+ 864e5,
818
+ "events-site-request-day"
819
+ ),
820
+ requestsPerMinute: analyticsLimit(
821
+ boundedAnalyticsMax("requestsPerMinute", 60),
822
+ 6e4,
823
+ "events-request-minute"
824
+ ),
825
+ clientEventsPerMinute: analyticsLimit(
826
+ boundedAnalyticsMax("clientEventsPerMinute", 120),
827
+ 6e4,
828
+ "events-client-minute"
829
+ ),
830
+ clientEventsPerDay: analyticsLimit(
831
+ boundedAnalyticsMax("clientEventsPerDay", 1e3),
832
+ 864e5,
833
+ "events-client-day"
834
+ ),
835
+ siteEventsPerMinute: analyticsLimit(
836
+ boundedAnalyticsMax("siteEventsPerMinute", 2e3),
837
+ 6e4,
838
+ "events-site-minute"
839
+ ),
840
+ siteEventsPerDay: analyticsLimit(
841
+ boundedAnalyticsMax("siteEventsPerDay", 5e4),
842
+ 864e5,
843
+ "events-site-day"
844
+ )
845
+ };
770
846
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
771
847
  const requestSessionKey = (request) => {
772
848
  const secret = previewSecret() || "orion-analytics";
@@ -1158,15 +1234,56 @@ function createCmsRoutes(options) {
1158
1234
  if (error) return errors.badRequest(error.message);
1159
1235
  return json({ media: data });
1160
1236
  };
1161
- const validateUpload = (file) => {
1237
+ const prepareUpload = async (file, target) => {
1162
1238
  if (file.size > maxUploadBytes) {
1163
1239
  return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1164
1240
  }
1165
1241
  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.";
1242
+ const rule = UPLOAD_RULES[type];
1243
+ if (!rule) {
1244
+ return "Unsupported file type. Allowed: JPEG, PNG, WebP, GIF, and AVIF images.";
1245
+ }
1246
+ const extension = uploadExtension(file.name);
1247
+ if (!rule.extensions.includes(extension)) {
1248
+ return "File extension does not match its declared type.";
1249
+ }
1250
+ if (target) {
1251
+ const knownTargetType = Boolean(UPLOAD_RULES[target.mimeType]);
1252
+ const pathMatches = target.path.startsWith("data:") || rule.extensions.includes(uploadExtension(target.path));
1253
+ if (knownTargetType && target.mimeType !== type || !knownTargetType && !pathMatches) {
1254
+ return "Replacement files must use the same format as the stored file.";
1255
+ }
1256
+ }
1257
+ try {
1258
+ const { default: sharp } = await import("sharp");
1259
+ const input = Buffer.from(await file.arrayBuffer());
1260
+ const image = sharp(input, {
1261
+ animated: type === "image/gif",
1262
+ failOn: "error",
1263
+ limitInputPixels: 4e7
1264
+ });
1265
+ const metadata = await image.metadata();
1266
+ const width = metadata.width || 0;
1267
+ const height = metadata.height || 0;
1268
+ const pages = metadata.pages || 1;
1269
+ if (metadata.format !== rule.decodedFormat || width < 1 || height < 1 || width * height * pages > 4e7) {
1270
+ return "File contents do not match its declared type.";
1271
+ }
1272
+ 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();
1273
+ if (normalized.byteLength > maxUploadBytes) {
1274
+ return `Normalized file is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
1275
+ }
1276
+ return {
1277
+ file: new File([Uint8Array.from(normalized)], file.name, {
1278
+ type,
1279
+ lastModified: file.lastModified
1280
+ }),
1281
+ width,
1282
+ height
1283
+ };
1284
+ } catch {
1285
+ return "File contents do not match its declared type.";
1168
1286
  }
1169
- return null;
1170
1287
  };
1171
1288
  const uploadMedia = async (request) => {
1172
1289
  const auth = await guard(request, "media.upload");
@@ -1174,35 +1291,32 @@ function createCmsRoutes(options) {
1174
1291
  const form = await request.formData().catch(() => null);
1175
1292
  const file = form?.get("file");
1176
1293
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1177
- const invalid = validateUpload(file);
1178
- if (invalid) return errors.badRequest(invalid);
1294
+ const prepared = await prepareUpload(file);
1295
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1296
+ const normalizedFile = prepared.file;
1179
1297
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
1180
1298
  let storagePath = `${Date.now().toString(36)}-${safeName}`;
1181
1299
  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);
1300
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1301
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1187
1302
  }
1188
- const uploadedToStorage = !options.memoryMode;
1189
- const width = Number(form.get("width")) || null;
1190
- const height = Number(form.get("height")) || null;
1191
1303
  const { data, error } = await db().from("cms_media").insert({
1192
1304
  storage_path: storagePath,
1193
1305
  filename: safeName,
1194
1306
  alt: String(form.get("alt") || ""),
1195
1307
  caption: String(form.get("caption") || ""),
1196
- mime_type: file.type || "",
1197
- width,
1198
- height,
1199
- filesize: file.size
1308
+ mime_type: normalizedFile.type,
1309
+ width: prepared.width,
1310
+ height: prepared.height,
1311
+ filesize: normalizedFile.size
1200
1312
  }).select("*").single();
1201
- if (error) {
1202
- if (uploadedToStorage) {
1203
- await db().storage.from("media").remove([storagePath]).catch(() => void 0);
1313
+ if (error) return errors.badRequest(error.message);
1314
+ if (!options.memoryMode) {
1315
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, { contentType: normalizedFile.type });
1316
+ if (storageError) {
1317
+ await db().from("cms_media").delete().eq("id", data.id);
1318
+ return errors.badRequest(storageError.message);
1204
1319
  }
1205
- return errors.badRequest(error.message);
1206
1320
  }
1207
1321
  await logActivity(auth.user, "media.upload", safeName);
1208
1322
  return json({ media: data }, 201);
@@ -1248,27 +1362,29 @@ function createCmsRoutes(options) {
1248
1362
  const form = await request.formData().catch(() => null);
1249
1363
  const file = form?.get("file");
1250
1364
  if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
1251
- const invalid = validateUpload(file);
1252
- if (invalid) return errors.badRequest(invalid);
1365
+ const prepared = await prepareUpload(file, {
1366
+ path: String(doc.storage_path),
1367
+ mimeType: String(doc.mime_type || "")
1368
+ });
1369
+ if (typeof prepared === "string") return errors.badRequest(prepared);
1370
+ const normalizedFile = prepared.file;
1253
1371
  let storagePath = String(doc.storage_path);
1254
1372
  if (options.memoryMode) {
1255
- const buffer = Buffer.from(await file.arrayBuffer());
1256
- storagePath = `data:${file.type || "application/octet-stream"};base64,${buffer.toString("base64")}`;
1373
+ const buffer = Buffer.from(await normalizedFile.arrayBuffer());
1374
+ storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
1257
1375
  } else {
1258
- const { error: storageError } = await db().storage.from("media").upload(storagePath, file, {
1259
- contentType: file.type || "application/octet-stream",
1376
+ const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, {
1377
+ contentType: normalizedFile.type,
1260
1378
  upsert: true
1261
1379
  });
1262
1380
  if (storageError) return errors.badRequest(storageError.message);
1263
1381
  }
1264
- const width = Number(form.get("width")) || null;
1265
- const height = Number(form.get("height")) || null;
1266
1382
  const { data, error } = await db().from("cms_media").update({
1267
1383
  storage_path: storagePath,
1268
- mime_type: file.type || "",
1269
- filesize: file.size,
1270
- width,
1271
- height,
1384
+ mime_type: normalizedFile.type,
1385
+ filesize: normalizedFile.size,
1386
+ width: prepared.width,
1387
+ height: prepared.height,
1272
1388
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1273
1389
  }).eq("id", id).select("*").single();
1274
1390
  if (error) return errors.badRequest(error.message);
@@ -1286,11 +1402,17 @@ function createCmsRoutes(options) {
1286
1402
  if (usage.length > 0 && !force) {
1287
1403
  return errors.conflict("This file is used on pages.", { usage });
1288
1404
  }
1405
+ const storagePath = String(doc.storage_path);
1406
+ if (!storagePath.startsWith("data:") && !storagePath.startsWith("/")) {
1407
+ try {
1408
+ const { error: storageError } = await db().storage.from("media").remove([storagePath]);
1409
+ if (storageError) return errors.badRequest(storageError.message);
1410
+ } catch (error2) {
1411
+ return errors.badRequest(error2 instanceof Error ? error2.message : "Unable to remove stored file.");
1412
+ }
1413
+ }
1289
1414
  const { error } = await db().from("cms_media").delete().eq("id", id);
1290
1415
  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
1416
  await logActivity(auth.user, "media.delete", String(doc.filename));
1295
1417
  return json({ success: true });
1296
1418
  };
@@ -1520,6 +1642,23 @@ function createCmsRoutes(options) {
1520
1642
  session_key: requestSessionKey(request)
1521
1643
  }).select("id").single();
1522
1644
  if (error) return errors.badRequest(error.message);
1645
+ try {
1646
+ const userAgent = request.headers.get("user-agent") || "";
1647
+ await db().from("cms_events").insert({
1648
+ session_key: requestSessionKey(request),
1649
+ visitor_key: "",
1650
+ type: "form",
1651
+ name: `${form.slug}:submit`,
1652
+ path: requestPagePath(request),
1653
+ referrer: "",
1654
+ utm: {},
1655
+ device: deviceFrom(userAgent),
1656
+ ...geoFrom(request),
1657
+ meta: {},
1658
+ server_verified: true
1659
+ });
1660
+ } catch {
1661
+ }
1523
1662
  if (sendEmail) {
1524
1663
  await notifySubmission({
1525
1664
  sendEmail,
@@ -1571,11 +1710,17 @@ function createCmsRoutes(options) {
1571
1710
  if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
1572
1711
  const userAgent = request.headers.get("user-agent") || "";
1573
1712
  if (isBotRequest(userAgent)) return json({ success: true });
1713
+ const now = Date.now();
1714
+ if (await analyticsLimits.siteRequestsPerMinute.isLimited("all", now) || await analyticsLimits.siteRequestsPerDay.isLimited("all", now)) {
1715
+ return json({ success: true });
1716
+ }
1574
1717
  const key = hashedClientKey(clientKey(request));
1575
- if (await eventsRateLimit.isLimited(key, Date.now())) return json({ success: true });
1718
+ if (await analyticsLimits.requestsPerMinute.isLimited(key, now)) {
1719
+ return json({ success: true });
1720
+ }
1576
1721
  const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
1577
1722
  if (!body) return json({ success: true });
1578
- const { rows } = parseEventBatch(body, {
1723
+ const parsed = parseEventBatch(body, {
1579
1724
  sessionKey: requestSessionKey(request),
1580
1725
  visitorKey: visitorKeyFor(
1581
1726
  body?.visitorId,
@@ -1584,9 +1729,25 @@ function createCmsRoutes(options) {
1584
1729
  device: deviceFrom(userAgent),
1585
1730
  ...geoFrom(request)
1586
1731
  });
1732
+ const rows = parsed.rows.filter(
1733
+ (row) => !(row.type === "form" && row.name.endsWith(":submit"))
1734
+ );
1587
1735
  if (rows.length > 0) {
1736
+ const limitChecks = [
1737
+ [analyticsLimits.clientEventsPerMinute, key],
1738
+ [analyticsLimits.clientEventsPerDay, key],
1739
+ [analyticsLimits.siteEventsPerMinute, "all"],
1740
+ [analyticsLimits.siteEventsPerDay, "all"]
1741
+ ];
1742
+ for (const [limit, limitKey] of limitChecks) {
1743
+ if (await limit.isLimited(limitKey, now, rows.length)) {
1744
+ return json({ success: true });
1745
+ }
1746
+ }
1588
1747
  try {
1589
- await db().from("cms_events").insert(rows);
1748
+ await db().from("cms_events").insert(
1749
+ rows.map((row) => ({ ...row, server_verified: false }))
1750
+ );
1590
1751
  } catch {
1591
1752
  }
1592
1753
  }
@@ -1596,7 +1757,7 @@ function createCmsRoutes(options) {
1596
1757
  const all = [];
1597
1758
  let cursor = 0;
1598
1759
  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);
1760
+ 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
1761
  if (error || !data || data.length === 0) break;
1601
1762
  all.push(...data);
1602
1763
  cursor = Number(data[data.length - 1].id);
@@ -1623,8 +1784,10 @@ function createCmsRoutes(options) {
1623
1784
  };
1624
1785
  const pruneEvents = async () => {
1625
1786
  const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
1787
+ const limitCutoff = new Date(Date.now() - 2 * 864e5).toISOString();
1626
1788
  try {
1627
1789
  await db().from("cms_events").delete().lt("created_at", cutoff);
1790
+ await db().rpc("cms_prune_rate_limits", { p_before: limitCutoff });
1628
1791
  } catch {
1629
1792
  }
1630
1793
  };
@@ -2262,7 +2425,11 @@ function runRpc(store, fn, args = {}) {
2262
2425
  const limits = store.table("cms_rate_limits");
2263
2426
  const key = String(args.p_key);
2264
2427
  const max = Number(args.p_max);
2428
+ const cost = args.p_cost === void 0 ? 1 : Number(args.p_cost);
2265
2429
  const windowMs = Number(args.p_window_seconds) * 1e3;
2430
+ if (!Number.isSafeInteger(cost) || cost < 1 || cost > max) {
2431
+ return { data: false, error: null };
2432
+ }
2266
2433
  const nowMs = Date.now();
2267
2434
  let row = limits.find((r) => r.key === key);
2268
2435
  if (!row) {
@@ -2271,12 +2438,22 @@ function runRpc(store, fn, args = {}) {
2271
2438
  }
2272
2439
  if (nowMs - Number(row.window_start) >= windowMs) {
2273
2440
  row.window_start = nowMs;
2274
- row.count = 1;
2441
+ row.count = cost;
2275
2442
  } else {
2276
- row.count = Number(row.count) + 1;
2443
+ row.count = Number(row.count) + cost;
2277
2444
  }
2278
2445
  return { data: Number(row.count) <= max, error: null };
2279
2446
  }
2447
+ case "cms_prune_rate_limits": {
2448
+ const before = Date.parse(String(args.p_before));
2449
+ const limits = store.table("cms_rate_limits");
2450
+ const kept = limits.filter((row) => {
2451
+ const started = typeof row.window_start === "number" ? row.window_start : Date.parse(String(row.window_start));
2452
+ return !Number.isFinite(before) || !Number.isFinite(started) || started >= before;
2453
+ });
2454
+ store.setTable("cms_rate_limits", kept);
2455
+ return { data: limits.length - kept.length, error: null };
2456
+ }
2280
2457
  case "cms_publish_due_pages": {
2281
2458
  const nowMs = Date.now();
2282
2459
  const published = [];
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  FORM_FIELD_TYPES,
5
5
  FormRenderer
6
- } from "../chunk-WQDHEQDE.js";
6
+ } from "../chunk-2BCEB26C.js";
7
7
 
8
8
  // src/studio/Studio.tsx
9
9
  import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
@@ -3379,7 +3379,7 @@ function MediaView({
3379
3379
  /* @__PURE__ */ jsx11(
3380
3380
  "input",
3381
3381
  {
3382
- accept: "image/*,application/pdf",
3382
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3383
3383
  hidden: true,
3384
3384
  onChange: async (event) => {
3385
3385
  const file = event.currentTarget.files?.[0];
@@ -3493,12 +3493,12 @@ function MediaDetail({
3493
3493
  children: saving ? "Saving\u2026" : "Save details"
3494
3494
  }
3495
3495
  ),
3496
- canReplace ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3496
+ canReplace && media.mime_type.startsWith("image/") ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3497
3497
  replacing ? "Replacing\u2026" : "Replace file",
3498
3498
  /* @__PURE__ */ jsx11(
3499
3499
  "input",
3500
3500
  {
3501
- accept: media.mime_type.startsWith("image/") ? "image/*" : "application/pdf",
3501
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3502
3502
  hidden: true,
3503
3503
  onChange: async (event) => {
3504
3504
  const file = event.currentTarget.files?.[0];
@@ -33,7 +33,7 @@ type FormConfig = {
33
33
  notify?: FormNotifyConfig;
34
34
  };
35
35
  type RateLimitStore = {
36
- isLimited(key: string, now: number): boolean | Promise<boolean>;
36
+ isLimited(key: string, now: number, cost?: number): boolean | Promise<boolean>;
37
37
  };
38
38
  declare function createMemoryRateLimitStore(options?: {
39
39
  max?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,6 +56,7 @@
56
56
  "dependencies": {
57
57
  "@supabase/ssr": "^0.7.0",
58
58
  "@supabase/supabase-js": "^2.58.0",
59
+ "sharp": "^0.35.3",
59
60
  "zod": "^3.25.0"
60
61
  },
61
62
  "peerDependencies": {
package/sql/bootstrap.sql CHANGED
@@ -244,7 +244,10 @@ create table if not exists cms_forms (
244
244
  -- public form renderer needs the field list), notify addresses are not.
245
245
  alter table cms_forms add column if not exists notify jsonb not null default '{}'::jsonb;
246
246
  update cms_forms
247
- set notify = coalesce(config->'notify', '{}'::jsonb),
247
+ set notify = case
248
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
249
+ else coalesce(config->'notify', '{}'::jsonb)
250
+ end,
248
251
  config = config - 'notify'
249
252
  where config ? 'notify';
250
253
 
@@ -301,8 +304,10 @@ create table if not exists cms_events (
301
304
  region text not null default '',
302
305
  city text not null default '',
303
306
  meta jsonb not null default '{}'::jsonb,
307
+ server_verified boolean not null default false,
304
308
  created_at timestamptz not null default now()
305
309
  );
310
+ alter table cms_events add column if not exists server_verified boolean not null default false;
306
311
  create index if not exists cms_events_day_idx on cms_events (created_at desc);
307
312
  create index if not exists cms_events_session_idx on cms_events (session_key, created_at);
308
313
  create index if not exists cms_events_visitor_idx on cms_events (visitor_key, created_at)
@@ -700,7 +705,8 @@ $$;
700
705
  create or replace function cms_rate_limit_consume(
701
706
  p_key text,
702
707
  p_max integer,
703
- p_window_seconds integer
708
+ p_window_seconds integer,
709
+ p_cost integer
704
710
  ) returns boolean
705
711
  language plpgsql
706
712
  security definer
@@ -709,13 +715,23 @@ as $$
709
715
  declare
710
716
  v_count integer;
711
717
  begin
718
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
719
+ or p_max < 1 or p_max > 1000000
720
+ or p_window_seconds < 1 or p_window_seconds > 604800
721
+ or p_cost < 1 or p_cost > p_max then
722
+ return false;
723
+ end if;
724
+
712
725
  insert into cms_rate_limits (key, window_start, count)
713
- values (p_key, now(), 1)
726
+ values (p_key, now(), p_cost)
714
727
  on conflict (key) do update
715
728
  set count = case
716
729
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
717
- then 1
718
- else cms_rate_limits.count + 1
730
+ then p_cost
731
+ else least(
732
+ (p_max + 1)::bigint,
733
+ cms_rate_limits.count::bigint + p_cost::bigint
734
+ )::integer
719
735
  end,
720
736
  window_start = case
721
737
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
@@ -728,6 +744,35 @@ begin
728
744
  end;
729
745
  $$;
730
746
 
747
+ create or replace function cms_rate_limit_consume(
748
+ p_key text,
749
+ p_max integer,
750
+ p_window_seconds integer
751
+ ) returns boolean
752
+ language sql
753
+ security definer
754
+ set search_path = public
755
+ as $$
756
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
757
+ $$;
758
+
759
+ create or replace function cms_prune_rate_limits(
760
+ p_before timestamptz
761
+ ) returns bigint
762
+ language plpgsql
763
+ security definer
764
+ set search_path = public
765
+ as $$
766
+ declare
767
+ v_deleted bigint;
768
+ begin
769
+ delete from cms_rate_limits
770
+ where window_start < p_before;
771
+ get diagnostics v_deleted = row_count;
772
+ return v_deleted;
773
+ end;
774
+ $$;
775
+
731
776
  create or replace function cms_update_global(
732
777
  p_key text,
733
778
  p_data jsonb,
@@ -789,8 +834,6 @@ create policy cms_globals_public_read on cms_globals
789
834
  for select using (true);
790
835
 
791
836
  drop policy if exists cms_media_public_read on cms_media;
792
- create policy cms_media_public_read on cms_media
793
- for select using (true);
794
837
 
795
838
  drop policy if exists cms_forms_public_read on cms_forms;
796
839
  create policy cms_forms_public_read on cms_forms
@@ -830,6 +873,8 @@ declare
830
873
  'cms_sync_page(text, text, text, jsonb, jsonb)',
831
874
  'cms_publish_due_pages()',
832
875
  'cms_rate_limit_consume(text, integer, integer)',
876
+ 'cms_rate_limit_consume(text, integer, integer, integer)',
877
+ 'cms_prune_rate_limits(timestamptz)',
833
878
  'cms_update_global(text, jsonb, uuid)'
834
879
  ];
835
880
  begin
@@ -853,6 +898,9 @@ end $fn_lockdown$;
853
898
  -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
854
899
  -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
855
900
  revoke update, delete on cms_page_versions from public;
901
+ revoke select on cms_forms from public;
902
+ revoke select (notify) on cms_forms from public;
903
+ revoke select on cms_media from public;
856
904
  do $grants$
857
905
  begin
858
906
  if exists (select 1 from pg_roles where rolname = 'service_role') then
@@ -868,22 +916,26 @@ begin
868
916
  -- cms_pages and cms_forms get column-level read grants. Anonymous readers
869
917
  -- see published page state only and never form notification addresses.
870
918
  if exists (select 1 from pg_roles where rolname = 'anon') then
871
- grant select on cms_globals, cms_media, cms_redirects to anon;
919
+ grant select on cms_globals, cms_redirects to anon;
920
+ revoke select on cms_media from anon;
872
921
  revoke select on cms_pages from anon;
873
922
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
874
923
  on cms_pages to anon;
875
924
  revoke all on cms_page_versions from anon;
876
925
  revoke select on cms_forms from anon;
926
+ revoke select (notify) on cms_forms from anon;
877
927
  grant select (id, slug, title, config, success_message, created_at, updated_at)
878
928
  on cms_forms to anon;
879
929
  end if;
880
930
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
881
- grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
931
+ grant select on cms_globals, cms_profiles, cms_redirects to authenticated;
932
+ revoke select on cms_media from authenticated;
882
933
  revoke select on cms_pages from authenticated;
883
934
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
884
935
  on cms_pages to authenticated;
885
936
  revoke all on cms_page_versions from authenticated;
886
937
  revoke select on cms_forms from authenticated;
938
+ revoke select (notify) on cms_forms from authenticated;
887
939
  grant select (id, slug, title, config, success_message, created_at, updated_at)
888
940
  on cms_forms to authenticated;
889
941
  end if;
@@ -0,0 +1,41 @@
1
+ -- SEC-014: move legacy notification settings out of public form config.
2
+
3
+ alter table cms_forms
4
+ add column if not exists notify jsonb not null default '{}'::jsonb;
5
+
6
+ -- Current private settings remain authoritative when both representations
7
+ -- exist. Older rows without private settings inherit the legacy value.
8
+ update cms_forms
9
+ set notify = case
10
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
11
+ else coalesce(config->'notify', '{}'::jsonb)
12
+ end,
13
+ config = config - 'notify'
14
+ where config ? 'notify';
15
+
16
+ -- RLS limits rows, while column grants keep notification settings out of the
17
+ -- public form definition returned by the Supabase Data API.
18
+ revoke select on cms_forms from public;
19
+ revoke select (notify) on cms_forms from public;
20
+
21
+ do $form_grants$
22
+ begin
23
+ if exists (select 1 from pg_roles where rolname = 'anon') then
24
+ revoke select on cms_forms from anon;
25
+ revoke select (notify) on cms_forms from anon;
26
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
27
+ on cms_forms to anon;
28
+ end if;
29
+
30
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
31
+ revoke select on cms_forms from authenticated;
32
+ revoke select (notify) on cms_forms from authenticated;
33
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
34
+ on cms_forms to authenticated;
35
+ end if;
36
+
37
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
38
+ grant select, insert, update, delete on cms_forms to service_role;
39
+ end if;
40
+ end
41
+ $form_grants$;
@@ -0,0 +1,101 @@
1
+ -- Bound public analytics storage by accepted event count and separate
2
+ -- server-verified conversions from forgeable browser observations.
3
+
4
+ alter table cms_events
5
+ add column if not exists server_verified boolean not null default false;
6
+
7
+ create or replace function cms_rate_limit_consume(
8
+ p_key text,
9
+ p_max integer,
10
+ p_window_seconds integer,
11
+ p_cost integer
12
+ ) returns boolean
13
+ language plpgsql
14
+ security definer
15
+ set search_path = public
16
+ as $$
17
+ declare
18
+ v_count integer;
19
+ begin
20
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
21
+ or p_max < 1 or p_max > 1000000
22
+ or p_window_seconds < 1 or p_window_seconds > 604800
23
+ or p_cost < 1 or p_cost > p_max then
24
+ return false;
25
+ end if;
26
+
27
+ insert into cms_rate_limits (key, window_start, count)
28
+ values (p_key, now(), p_cost)
29
+ on conflict (key) do update
30
+ set count = case
31
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
32
+ then p_cost
33
+ else least(
34
+ (p_max + 1)::bigint,
35
+ cms_rate_limits.count::bigint + p_cost::bigint
36
+ )::integer
37
+ end,
38
+ window_start = case
39
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
40
+ then now()
41
+ else cms_rate_limits.window_start
42
+ end
43
+ returning count into v_count;
44
+
45
+ return v_count <= p_max;
46
+ end;
47
+ $$;
48
+
49
+ -- Preserve the existing form-submission caller while new analytics callers
50
+ -- use the weighted overload.
51
+ create or replace function cms_rate_limit_consume(
52
+ p_key text,
53
+ p_max integer,
54
+ p_window_seconds integer
55
+ ) returns boolean
56
+ language sql
57
+ security definer
58
+ set search_path = public
59
+ as $$
60
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
61
+ $$;
62
+
63
+ create or replace function cms_prune_rate_limits(
64
+ p_before timestamptz
65
+ ) returns bigint
66
+ language plpgsql
67
+ security definer
68
+ set search_path = public
69
+ as $$
70
+ declare
71
+ v_deleted bigint;
72
+ begin
73
+ delete from cms_rate_limits
74
+ where window_start < p_before;
75
+ get diagnostics v_deleted = row_count;
76
+ return v_deleted;
77
+ end;
78
+ $$;
79
+
80
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from public;
81
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from public;
82
+ revoke execute on function cms_prune_rate_limits(timestamptz) from public;
83
+
84
+ do $lockdown$
85
+ begin
86
+ if exists (select 1 from pg_roles where rolname = 'anon') then
87
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from anon;
88
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from anon;
89
+ revoke execute on function cms_prune_rate_limits(timestamptz) from anon;
90
+ end if;
91
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
92
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from authenticated;
93
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from authenticated;
94
+ revoke execute on function cms_prune_rate_limits(timestamptz) from authenticated;
95
+ end if;
96
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
97
+ grant execute on function cms_rate_limit_consume(text, integer, integer) to service_role;
98
+ grant execute on function cms_rate_limit_consume(text, integer, integer, integer) to service_role;
99
+ grant execute on function cms_prune_rate_limits(timestamptz) to service_role;
100
+ end if;
101
+ end $lockdown$;
@@ -0,0 +1,21 @@
1
+ -- SEC-016: keep unused and draft media metadata out of public enumeration.
2
+ -- Published layouts already carry the storage paths needed by public pages.
3
+
4
+ drop policy if exists cms_media_public_read on cms_media;
5
+ revoke select on cms_media from public;
6
+
7
+ do $media_grants$
8
+ begin
9
+ if exists (select 1 from pg_roles where rolname = 'anon') then
10
+ revoke select on cms_media from anon;
11
+ end if;
12
+
13
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
14
+ revoke select on cms_media from authenticated;
15
+ end if;
16
+
17
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
18
+ grant select, insert, update, delete on cms_media to service_role;
19
+ end if;
20
+ end
21
+ $media_grants$;