@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.
- package/dist/{chunk-WQDHEQDE.js → chunk-2BCEB26C.js} +0 -1
- package/dist/{chunk-VPUODCNH.js → chunk-LQTXMLKZ.js} +8 -8
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +1 -1
- package/dist/forms/react.js +1 -1
- package/dist/server/index.d.ts +16 -3
- package/dist/server/index.js +245 -68
- package/dist/studio/index.js +4 -4
- package/dist/{submission-BKdBedOe.d.ts → submission-D_x2qNJl.d.ts} +1 -1
- package/package.json +2 -1
- package/sql/bootstrap.sql +61 -9
- package/sql/migrations/20260827164127_sec_014_form_notify_privacy.sql +41 -0
- package/sql/migrations/20260827172120_sec_015_analytics_ingest_limits.sql +101 -0
- package/sql/migrations/20260827232146_sec_016_media_privacy.sql +21 -0
|
@@ -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
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
hits.set(key,
|
|
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,
|
|
251
|
-
if (
|
|
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
|
|
256
|
+
return entry.count > max;
|
|
257
257
|
}
|
|
258
258
|
};
|
|
259
259
|
}
|
package/dist/forms/index.d.ts
CHANGED
|
@@ -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-
|
|
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.
|
package/dist/forms/index.js
CHANGED
package/dist/forms/react.js
CHANGED
package/dist/server/index.d.ts
CHANGED
|
@@ -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-
|
|
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
|
-
*
|
|
127
|
-
*
|
|
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 = {
|
package/dist/server/index.js
CHANGED
|
@@ -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-
|
|
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"))
|
|
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 =
|
|
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:
|
|
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
|
|
670
|
-
"image/jpeg"
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
"image/
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
778
|
+
const args = {
|
|
746
779
|
p_key: `${bucket}:${key}`,
|
|
747
780
|
p_max: max,
|
|
748
781
|
p_window_seconds: windowSeconds
|
|
749
|
-
}
|
|
750
|
-
if (
|
|
751
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
|
1178
|
-
if (
|
|
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
|
|
1183
|
-
storagePath = `data:${
|
|
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:
|
|
1197
|
-
width,
|
|
1198
|
-
height,
|
|
1199
|
-
filesize:
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
|
1252
|
-
|
|
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
|
|
1256
|
-
storagePath = `data:${
|
|
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,
|
|
1259
|
-
contentType:
|
|
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:
|
|
1269
|
-
filesize:
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
2441
|
+
row.count = cost;
|
|
2275
2442
|
} else {
|
|
2276
|
-
row.count = Number(row.count) +
|
|
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 = [];
|
package/dist/studio/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
FORM_FIELD_TYPES,
|
|
5
5
|
FormRenderer
|
|
6
|
-
} from "../chunk-
|
|
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
|
|
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:
|
|
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.
|
|
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 =
|
|
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(),
|
|
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
|
|
718
|
-
else
|
|
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,
|
|
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,
|
|
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$;
|