@orion-studios/cms 0.5.3 → 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 +298 -96
- package/dist/studio/index.js +18 -12
- 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();
|
|
@@ -389,7 +391,7 @@ var MIN_ROLE = {
|
|
|
389
391
|
"pages.delete": "admin",
|
|
390
392
|
"pages.restore": "editor",
|
|
391
393
|
"globals.read": "content",
|
|
392
|
-
"globals.write": "
|
|
394
|
+
"globals.write": "editor",
|
|
393
395
|
"media.read": "content",
|
|
394
396
|
"media.upload": "content",
|
|
395
397
|
"media.update": "content",
|
|
@@ -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
|
},
|
|
@@ -663,16 +670,32 @@ var errors = {
|
|
|
663
670
|
var isRecord2 = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
664
671
|
var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
665
672
|
var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
|
|
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;
|
|
674
|
+
var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
|
|
666
675
|
var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
|
667
|
-
var
|
|
668
|
-
"image/jpeg"
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
"image/
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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() || "";
|
|
676
699
|
async function readJson(request) {
|
|
677
700
|
try {
|
|
678
701
|
const parsed = await request.json();
|
|
@@ -733,22 +756,36 @@ async function readJsonLimited(request, limits) {
|
|
|
733
756
|
return parsed;
|
|
734
757
|
}
|
|
735
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
|
+
};
|
|
736
770
|
function createDurableRateLimitStore(getClient, options) {
|
|
737
771
|
const max = options?.max ?? 5;
|
|
738
772
|
const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
|
|
739
773
|
const bucket = options?.bucket ?? "default";
|
|
774
|
+
const failClosed = options?.failClosed === true;
|
|
740
775
|
return {
|
|
741
|
-
async isLimited(key) {
|
|
776
|
+
async isLimited(key, _now, cost = 1) {
|
|
742
777
|
try {
|
|
743
|
-
const
|
|
778
|
+
const args = {
|
|
744
779
|
p_key: `${bucket}:${key}`,
|
|
745
780
|
p_max: max,
|
|
746
781
|
p_window_seconds: windowSeconds
|
|
747
|
-
}
|
|
748
|
-
if (
|
|
749
|
-
|
|
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;
|
|
750
787
|
} catch {
|
|
751
|
-
return
|
|
788
|
+
return failClosed;
|
|
752
789
|
}
|
|
753
790
|
}
|
|
754
791
|
};
|
|
@@ -764,7 +801,48 @@ function createCmsRoutes(options) {
|
|
|
764
801
|
if (hostedMemoryMode) return "";
|
|
765
802
|
return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
|
|
766
803
|
};
|
|
767
|
-
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
|
+
};
|
|
768
846
|
const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
|
|
769
847
|
const requestSessionKey = (request) => {
|
|
770
848
|
const secret = previewSecret() || "orion-analytics";
|
|
@@ -1156,15 +1234,56 @@ function createCmsRoutes(options) {
|
|
|
1156
1234
|
if (error) return errors.badRequest(error.message);
|
|
1157
1235
|
return json({ media: data });
|
|
1158
1236
|
};
|
|
1159
|
-
const
|
|
1237
|
+
const prepareUpload = async (file, target) => {
|
|
1160
1238
|
if (file.size > maxUploadBytes) {
|
|
1161
1239
|
return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
|
|
1162
1240
|
}
|
|
1163
1241
|
const type = file.type || "";
|
|
1164
|
-
|
|
1165
|
-
|
|
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.";
|
|
1166
1286
|
}
|
|
1167
|
-
return null;
|
|
1168
1287
|
};
|
|
1169
1288
|
const uploadMedia = async (request) => {
|
|
1170
1289
|
const auth = await guard(request, "media.upload");
|
|
@@ -1172,35 +1291,32 @@ function createCmsRoutes(options) {
|
|
|
1172
1291
|
const form = await request.formData().catch(() => null);
|
|
1173
1292
|
const file = form?.get("file");
|
|
1174
1293
|
if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
|
|
1175
|
-
const
|
|
1176
|
-
if (
|
|
1294
|
+
const prepared = await prepareUpload(file);
|
|
1295
|
+
if (typeof prepared === "string") return errors.badRequest(prepared);
|
|
1296
|
+
const normalizedFile = prepared.file;
|
|
1177
1297
|
const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
1178
1298
|
let storagePath = `${Date.now().toString(36)}-${safeName}`;
|
|
1179
1299
|
if (options.memoryMode) {
|
|
1180
|
-
const buffer = Buffer.from(await
|
|
1181
|
-
storagePath = `data:${
|
|
1182
|
-
} else {
|
|
1183
|
-
const { error: storageError } = await db().storage.from("media").upload(storagePath, file, { contentType: file.type || "application/octet-stream" });
|
|
1184
|
-
if (storageError) return errors.badRequest(storageError.message);
|
|
1300
|
+
const buffer = Buffer.from(await normalizedFile.arrayBuffer());
|
|
1301
|
+
storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
|
|
1185
1302
|
}
|
|
1186
|
-
const uploadedToStorage = !options.memoryMode;
|
|
1187
|
-
const width = Number(form.get("width")) || null;
|
|
1188
|
-
const height = Number(form.get("height")) || null;
|
|
1189
1303
|
const { data, error } = await db().from("cms_media").insert({
|
|
1190
1304
|
storage_path: storagePath,
|
|
1191
1305
|
filename: safeName,
|
|
1192
1306
|
alt: String(form.get("alt") || ""),
|
|
1193
1307
|
caption: String(form.get("caption") || ""),
|
|
1194
|
-
mime_type:
|
|
1195
|
-
width,
|
|
1196
|
-
height,
|
|
1197
|
-
filesize:
|
|
1308
|
+
mime_type: normalizedFile.type,
|
|
1309
|
+
width: prepared.width,
|
|
1310
|
+
height: prepared.height,
|
|
1311
|
+
filesize: normalizedFile.size
|
|
1198
1312
|
}).select("*").single();
|
|
1199
|
-
if (error)
|
|
1200
|
-
|
|
1201
|
-
|
|
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);
|
|
1202
1319
|
}
|
|
1203
|
-
return errors.badRequest(error.message);
|
|
1204
1320
|
}
|
|
1205
1321
|
await logActivity(auth.user, "media.upload", safeName);
|
|
1206
1322
|
return json({ media: data }, 201);
|
|
@@ -1246,27 +1362,29 @@ function createCmsRoutes(options) {
|
|
|
1246
1362
|
const form = await request.formData().catch(() => null);
|
|
1247
1363
|
const file = form?.get("file");
|
|
1248
1364
|
if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
|
|
1249
|
-
const
|
|
1250
|
-
|
|
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;
|
|
1251
1371
|
let storagePath = String(doc.storage_path);
|
|
1252
1372
|
if (options.memoryMode) {
|
|
1253
|
-
const buffer = Buffer.from(await
|
|
1254
|
-
storagePath = `data:${
|
|
1373
|
+
const buffer = Buffer.from(await normalizedFile.arrayBuffer());
|
|
1374
|
+
storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
|
|
1255
1375
|
} else {
|
|
1256
|
-
const { error: storageError } = await db().storage.from("media").upload(storagePath,
|
|
1257
|
-
contentType:
|
|
1376
|
+
const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, {
|
|
1377
|
+
contentType: normalizedFile.type,
|
|
1258
1378
|
upsert: true
|
|
1259
1379
|
});
|
|
1260
1380
|
if (storageError) return errors.badRequest(storageError.message);
|
|
1261
1381
|
}
|
|
1262
|
-
const width = Number(form.get("width")) || null;
|
|
1263
|
-
const height = Number(form.get("height")) || null;
|
|
1264
1382
|
const { data, error } = await db().from("cms_media").update({
|
|
1265
1383
|
storage_path: storagePath,
|
|
1266
|
-
mime_type:
|
|
1267
|
-
filesize:
|
|
1268
|
-
width,
|
|
1269
|
-
height,
|
|
1384
|
+
mime_type: normalizedFile.type,
|
|
1385
|
+
filesize: normalizedFile.size,
|
|
1386
|
+
width: prepared.width,
|
|
1387
|
+
height: prepared.height,
|
|
1270
1388
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1271
1389
|
}).eq("id", id).select("*").single();
|
|
1272
1390
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1284,11 +1402,17 @@ function createCmsRoutes(options) {
|
|
|
1284
1402
|
if (usage.length > 0 && !force) {
|
|
1285
1403
|
return errors.conflict("This file is used on pages.", { usage });
|
|
1286
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
|
+
}
|
|
1287
1414
|
const { error } = await db().from("cms_media").delete().eq("id", id);
|
|
1288
1415
|
if (error) return errors.badRequest(error.message);
|
|
1289
|
-
if (!String(doc.storage_path).startsWith("data:")) {
|
|
1290
|
-
await db().storage.from("media").remove([doc.storage_path]).catch(() => void 0);
|
|
1291
|
-
}
|
|
1292
1416
|
await logActivity(auth.user, "media.delete", String(doc.filename));
|
|
1293
1417
|
return json({ success: true });
|
|
1294
1418
|
};
|
|
@@ -1518,6 +1642,23 @@ function createCmsRoutes(options) {
|
|
|
1518
1642
|
session_key: requestSessionKey(request)
|
|
1519
1643
|
}).select("id").single();
|
|
1520
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
|
+
}
|
|
1521
1662
|
if (sendEmail) {
|
|
1522
1663
|
await notifySubmission({
|
|
1523
1664
|
sendEmail,
|
|
@@ -1569,11 +1710,17 @@ function createCmsRoutes(options) {
|
|
|
1569
1710
|
if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
|
|
1570
1711
|
const userAgent = request.headers.get("user-agent") || "";
|
|
1571
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
|
+
}
|
|
1572
1717
|
const key = hashedClientKey(clientKey(request));
|
|
1573
|
-
if (await
|
|
1718
|
+
if (await analyticsLimits.requestsPerMinute.isLimited(key, now)) {
|
|
1719
|
+
return json({ success: true });
|
|
1720
|
+
}
|
|
1574
1721
|
const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
|
|
1575
1722
|
if (!body) return json({ success: true });
|
|
1576
|
-
const
|
|
1723
|
+
const parsed = parseEventBatch(body, {
|
|
1577
1724
|
sessionKey: requestSessionKey(request),
|
|
1578
1725
|
visitorKey: visitorKeyFor(
|
|
1579
1726
|
body?.visitorId,
|
|
@@ -1582,9 +1729,25 @@ function createCmsRoutes(options) {
|
|
|
1582
1729
|
device: deviceFrom(userAgent),
|
|
1583
1730
|
...geoFrom(request)
|
|
1584
1731
|
});
|
|
1732
|
+
const rows = parsed.rows.filter(
|
|
1733
|
+
(row) => !(row.type === "form" && row.name.endsWith(":submit"))
|
|
1734
|
+
);
|
|
1585
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
|
+
}
|
|
1586
1747
|
try {
|
|
1587
|
-
await db().from("cms_events").insert(
|
|
1748
|
+
await db().from("cms_events").insert(
|
|
1749
|
+
rows.map((row) => ({ ...row, server_verified: false }))
|
|
1750
|
+
);
|
|
1588
1751
|
} catch {
|
|
1589
1752
|
}
|
|
1590
1753
|
}
|
|
@@ -1594,7 +1757,7 @@ function createCmsRoutes(options) {
|
|
|
1594
1757
|
const all = [];
|
|
1595
1758
|
let cursor = 0;
|
|
1596
1759
|
for (let page = 0; page < 60; page += 1) {
|
|
1597
|
-
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);
|
|
1598
1761
|
if (error || !data || data.length === 0) break;
|
|
1599
1762
|
all.push(...data);
|
|
1600
1763
|
cursor = Number(data[data.length - 1].id);
|
|
@@ -1621,8 +1784,10 @@ function createCmsRoutes(options) {
|
|
|
1621
1784
|
};
|
|
1622
1785
|
const pruneEvents = async () => {
|
|
1623
1786
|
const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
|
|
1787
|
+
const limitCutoff = new Date(Date.now() - 2 * 864e5).toISOString();
|
|
1624
1788
|
try {
|
|
1625
1789
|
await db().from("cms_events").delete().lt("created_at", cutoff);
|
|
1790
|
+
await db().rpc("cms_prune_rate_limits", { p_before: limitCutoff });
|
|
1626
1791
|
} catch {
|
|
1627
1792
|
}
|
|
1628
1793
|
};
|
|
@@ -1706,23 +1871,30 @@ function createCmsRoutes(options) {
|
|
|
1706
1871
|
const listUsers = async (request) => {
|
|
1707
1872
|
const auth = await guard(request, "users.manage");
|
|
1708
1873
|
if (auth instanceof Response) return auth;
|
|
1709
|
-
const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
|
|
1710
|
-
if (authError) return errors.badRequest(authError.message);
|
|
1711
1874
|
const { data: profiles, error: profileError } = await db().from("cms_profiles").select("user_id, role, name");
|
|
1712
1875
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1876
|
+
const profileById = /* @__PURE__ */ new Map();
|
|
1877
|
+
for (const profile of profiles || []) {
|
|
1878
|
+
const userId = typeof profile.user_id === "string" ? canonicalizeUserId(profile.user_id) : null;
|
|
1879
|
+
if (!userId || !isCmsRole(profile.role)) continue;
|
|
1880
|
+
profileById.set(userId, {
|
|
1881
|
+
role: profile.role,
|
|
1882
|
+
name: typeof profile.name === "string" ? profile.name : ""
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
const { data: authData, error: authError } = await db().auth.admin.listUsers({ perPage: 1e3 });
|
|
1886
|
+
if (authError) return errors.badRequest(authError.message);
|
|
1887
|
+
const users = authData.users.flatMap((user) => {
|
|
1888
|
+
const canonicalUserId = canonicalizeUserId(user.id);
|
|
1889
|
+
const profile = canonicalUserId ? profileById.get(canonicalUserId) : void 0;
|
|
1890
|
+
return profile ? [{
|
|
1719
1891
|
id: user.id,
|
|
1720
1892
|
email: user.email ?? null,
|
|
1721
|
-
name: profile
|
|
1722
|
-
role: profile
|
|
1893
|
+
name: profile.name,
|
|
1894
|
+
role: profile.role,
|
|
1723
1895
|
created_at: user.created_at ?? null,
|
|
1724
1896
|
last_sign_in_at: user.last_sign_in_at ?? null
|
|
1725
|
-
};
|
|
1897
|
+
}] : [];
|
|
1726
1898
|
});
|
|
1727
1899
|
return json({ users, assignableRoles: assignableRoles(auth.user.role) });
|
|
1728
1900
|
};
|
|
@@ -1757,20 +1929,31 @@ function createCmsRoutes(options) {
|
|
|
1757
1929
|
await logActivity(auth.user, "user.create", email);
|
|
1758
1930
|
return json({ user: { id: userId, email, name, role } }, 201);
|
|
1759
1931
|
};
|
|
1760
|
-
const
|
|
1761
|
-
const { data } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
|
|
1762
|
-
return
|
|
1932
|
+
const loadMembership = async (userId) => {
|
|
1933
|
+
const { data, error } = await db().from("cms_profiles").select("role").eq("user_id", userId).maybeSingle();
|
|
1934
|
+
if (error) return { status: "query-error", message: error.message };
|
|
1935
|
+
if (!data) return { status: "missing" };
|
|
1936
|
+
if (!isCmsRole(data.role)) return { status: "invalid-role" };
|
|
1937
|
+
return { status: "valid", role: data.role };
|
|
1938
|
+
};
|
|
1939
|
+
const membershipError = (membership) => {
|
|
1940
|
+
if (membership.status === "missing") return errors.notFound();
|
|
1941
|
+
if (membership.status === "invalid-role") return errors.badRequest("Invalid CMS profile role.");
|
|
1942
|
+
return errors.badRequest(membership.message);
|
|
1763
1943
|
};
|
|
1764
1944
|
const updateUser = async (request, userId) => {
|
|
1765
1945
|
const auth = await guard(request, "users.manage");
|
|
1766
1946
|
if (auth instanceof Response) return auth;
|
|
1947
|
+
const canonicalUserId = canonicalizeUserId(userId);
|
|
1948
|
+
if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
|
|
1767
1949
|
const body = await readJson(request);
|
|
1768
1950
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1769
|
-
const
|
|
1770
|
-
if (
|
|
1951
|
+
const membership = await loadMembership(canonicalUserId);
|
|
1952
|
+
if (membership.status !== "valid") return membershipError(membership);
|
|
1953
|
+
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
1771
1954
|
if (body.role !== void 0) {
|
|
1772
1955
|
if (!isCmsRole(body.role)) return errors.badRequest("Invalid role.");
|
|
1773
|
-
if (
|
|
1956
|
+
if (canonicalUserId === auth.user.id && body.role !== auth.user.role) {
|
|
1774
1957
|
return errors.badRequest("You can't change your own role.");
|
|
1775
1958
|
}
|
|
1776
1959
|
if (!outranksOrEqual(auth.user.role, body.role)) {
|
|
@@ -1779,32 +1962,37 @@ function createCmsRoutes(options) {
|
|
|
1779
1962
|
}
|
|
1780
1963
|
if (typeof body.password === "string") {
|
|
1781
1964
|
if (body.password.length < 8) return errors.badRequest("Password must be at least 8 characters.");
|
|
1782
|
-
const { error: passwordError } = await db().auth.admin.updateUserById(
|
|
1965
|
+
const { error: passwordError } = await db().auth.admin.updateUserById(canonicalUserId, {
|
|
1783
1966
|
password: body.password
|
|
1784
1967
|
});
|
|
1785
1968
|
if (passwordError) return errors.badRequest(passwordError.message);
|
|
1786
1969
|
}
|
|
1787
1970
|
if (body.role !== void 0 || typeof body.name === "string") {
|
|
1788
|
-
const patch = {
|
|
1971
|
+
const patch = {};
|
|
1789
1972
|
if (body.role !== void 0) patch.role = body.role;
|
|
1790
1973
|
if (typeof body.name === "string") patch.name = body.name.trim();
|
|
1791
|
-
const { error: profileError } = await db().from("cms_profiles").
|
|
1974
|
+
const { error: profileError } = await db().from("cms_profiles").update(patch).eq("user_id", canonicalUserId);
|
|
1792
1975
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1793
1976
|
}
|
|
1794
|
-
await logActivity(auth.user, "user.update",
|
|
1977
|
+
await logActivity(auth.user, "user.update", canonicalUserId);
|
|
1795
1978
|
return json({ success: true });
|
|
1796
1979
|
};
|
|
1797
1980
|
const deleteUser = async (request, userId) => {
|
|
1798
1981
|
const auth = await guard(request, "users.manage");
|
|
1799
1982
|
if (auth instanceof Response) return auth;
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1983
|
+
const canonicalUserId = canonicalizeUserId(userId);
|
|
1984
|
+
if (!canonicalUserId) return errors.badRequest("Invalid user ID.");
|
|
1985
|
+
const membership = await loadMembership(canonicalUserId);
|
|
1986
|
+
if (membership.status !== "valid") return membershipError(membership);
|
|
1987
|
+
if (canonicalUserId === auth.user.id) {
|
|
1988
|
+
return errors.badRequest("You can't remove your own account.");
|
|
1989
|
+
}
|
|
1990
|
+
if (!outranksOrEqual(auth.user.role, membership.role)) return errors.forbidden();
|
|
1991
|
+
const { error: authError } = await db().auth.admin.deleteUser(canonicalUserId);
|
|
1804
1992
|
if (authError) return errors.badRequest(authError.message);
|
|
1805
|
-
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id",
|
|
1993
|
+
const { error: profileError } = await db().from("cms_profiles").delete().eq("user_id", canonicalUserId);
|
|
1806
1994
|
if (profileError) return errors.badRequest(profileError.message);
|
|
1807
|
-
await logActivity(auth.user, "user.delete",
|
|
1995
|
+
await logActivity(auth.user, "user.delete", canonicalUserId);
|
|
1808
1996
|
return json({ success: true });
|
|
1809
1997
|
};
|
|
1810
1998
|
const runSync = async (request) => {
|
|
@@ -2237,7 +2425,11 @@ function runRpc(store, fn, args = {}) {
|
|
|
2237
2425
|
const limits = store.table("cms_rate_limits");
|
|
2238
2426
|
const key = String(args.p_key);
|
|
2239
2427
|
const max = Number(args.p_max);
|
|
2428
|
+
const cost = args.p_cost === void 0 ? 1 : Number(args.p_cost);
|
|
2240
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
|
+
}
|
|
2241
2433
|
const nowMs = Date.now();
|
|
2242
2434
|
let row = limits.find((r) => r.key === key);
|
|
2243
2435
|
if (!row) {
|
|
@@ -2246,12 +2438,22 @@ function runRpc(store, fn, args = {}) {
|
|
|
2246
2438
|
}
|
|
2247
2439
|
if (nowMs - Number(row.window_start) >= windowMs) {
|
|
2248
2440
|
row.window_start = nowMs;
|
|
2249
|
-
row.count =
|
|
2441
|
+
row.count = cost;
|
|
2250
2442
|
} else {
|
|
2251
|
-
row.count = Number(row.count) +
|
|
2443
|
+
row.count = Number(row.count) + cost;
|
|
2252
2444
|
}
|
|
2253
2445
|
return { data: Number(row.count) <= max, error: null };
|
|
2254
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
|
+
}
|
|
2255
2457
|
case "cms_publish_due_pages": {
|
|
2256
2458
|
const nowMs = Date.now();
|
|
2257
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";
|
|
@@ -149,7 +149,7 @@ var MIN_ROLE = {
|
|
|
149
149
|
"pages.delete": "admin",
|
|
150
150
|
"pages.restore": "editor",
|
|
151
151
|
"globals.read": "content",
|
|
152
|
-
"globals.write": "
|
|
152
|
+
"globals.write": "editor",
|
|
153
153
|
"media.read": "content",
|
|
154
154
|
"media.upload": "content",
|
|
155
155
|
"media.update": "content",
|
|
@@ -179,13 +179,14 @@ var SECTION_ACTION = {
|
|
|
179
179
|
forms: "forms.read",
|
|
180
180
|
submissions: "submissions.read",
|
|
181
181
|
analytics: "analytics.read",
|
|
182
|
-
globals: "globals.
|
|
182
|
+
globals: "globals.write",
|
|
183
183
|
redirects: "redirects.manage",
|
|
184
184
|
users: "users.manage"
|
|
185
185
|
};
|
|
186
186
|
var userForRole = (role) => ({ id: "", email: null, name: "", role });
|
|
187
187
|
var studioCan = (role, action) => can(userForRole(role), action);
|
|
188
188
|
var canViewStudioSection = (role, section) => studioCan(role, SECTION_ACTION[section]);
|
|
189
|
+
var resolveStudioSection = (role, section) => role && canViewStudioSection(role, section) ? section : "dashboard";
|
|
189
190
|
var studioAccess = (role) => ({
|
|
190
191
|
changePageStructure: studioCan(role, "pages.changeStructure"),
|
|
191
192
|
publishPages: studioCan(role, "pages.publish"),
|
|
@@ -3378,7 +3379,7 @@ function MediaView({
|
|
|
3378
3379
|
/* @__PURE__ */ jsx11(
|
|
3379
3380
|
"input",
|
|
3380
3381
|
{
|
|
3381
|
-
accept: "image
|
|
3382
|
+
accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
|
|
3382
3383
|
hidden: true,
|
|
3383
3384
|
onChange: async (event) => {
|
|
3384
3385
|
const file = event.currentTarget.files?.[0];
|
|
@@ -3492,12 +3493,12 @@ function MediaDetail({
|
|
|
3492
3493
|
children: saving ? "Saving\u2026" : "Save details"
|
|
3493
3494
|
}
|
|
3494
3495
|
),
|
|
3495
|
-
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: [
|
|
3496
3497
|
replacing ? "Replacing\u2026" : "Replace file",
|
|
3497
3498
|
/* @__PURE__ */ jsx11(
|
|
3498
3499
|
"input",
|
|
3499
3500
|
{
|
|
3500
|
-
accept:
|
|
3501
|
+
accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
|
|
3501
3502
|
hidden: true,
|
|
3502
3503
|
onChange: async (event) => {
|
|
3503
3504
|
const file = event.currentTarget.files?.[0];
|
|
@@ -3728,11 +3729,15 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3728
3729
|
useEffect11(() => {
|
|
3729
3730
|
if (!session) {
|
|
3730
3731
|
setRole(null);
|
|
3732
|
+
setSection("dashboard");
|
|
3733
|
+
setOpenPageId(null);
|
|
3734
|
+
setSubmissionsFormId(null);
|
|
3731
3735
|
return;
|
|
3732
3736
|
}
|
|
3733
3737
|
api.me().then(
|
|
3734
3738
|
({ user }) => {
|
|
3735
3739
|
setRole(user.role);
|
|
3740
|
+
setSection((currentSection) => resolveStudioSection(user.role, currentSection));
|
|
3736
3741
|
refreshMedia();
|
|
3737
3742
|
refreshUnread();
|
|
3738
3743
|
refreshPages();
|
|
@@ -3784,6 +3789,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3784
3789
|
if (!role) return /* @__PURE__ */ jsx12("div", { className: "ost-root ost-loading", children: "Loading Studio\u2026" });
|
|
3785
3790
|
const access = studioAccess(role);
|
|
3786
3791
|
const nav = NAV.filter((item) => canViewStudioSection(role, item.key));
|
|
3792
|
+
const selectedSection = resolveStudioSection(role, section);
|
|
3787
3793
|
const goTo = (key) => {
|
|
3788
3794
|
setSection(key);
|
|
3789
3795
|
setOpenPageId(null);
|
|
@@ -3806,7 +3812,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3806
3812
|
nav.map((item) => /* @__PURE__ */ jsxs12(
|
|
3807
3813
|
"button",
|
|
3808
3814
|
{
|
|
3809
|
-
className: `ost-nav-item${
|
|
3815
|
+
className: `ost-nav-item${selectedSection === item.key && !openPageId ? " is-active" : ""}`,
|
|
3810
3816
|
onClick: () => goTo(item.key),
|
|
3811
3817
|
type: "button",
|
|
3812
3818
|
children: [
|
|
@@ -3841,7 +3847,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3841
3847
|
role
|
|
3842
3848
|
},
|
|
3843
3849
|
openPageId
|
|
3844
|
-
) :
|
|
3850
|
+
) : selectedSection === "pages" ? /* @__PURE__ */ jsx12(PagesView, { api, canCreate: access.createPages, onOpen: openPage }) : selectedSection === "media" ? /* @__PURE__ */ jsx12(
|
|
3845
3851
|
MediaView,
|
|
3846
3852
|
{
|
|
3847
3853
|
api,
|
|
@@ -3852,7 +3858,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3852
3858
|
onChanged: refreshMedia,
|
|
3853
3859
|
onUpload: uploadMedia
|
|
3854
3860
|
}
|
|
3855
|
-
) :
|
|
3861
|
+
) : selectedSection === "forms" ? /* @__PURE__ */ jsx12(
|
|
3856
3862
|
FormsView,
|
|
3857
3863
|
{
|
|
3858
3864
|
api,
|
|
@@ -3864,7 +3870,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3864
3870
|
setSection("submissions");
|
|
3865
3871
|
}
|
|
3866
3872
|
}
|
|
3867
|
-
) :
|
|
3873
|
+
) : selectedSection === "submissions" && access.readSubmissions ? /* @__PURE__ */ jsx12(
|
|
3868
3874
|
SubmissionsView,
|
|
3869
3875
|
{
|
|
3870
3876
|
api,
|
|
@@ -3873,7 +3879,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3873
3879
|
onUnreadChanged: refreshUnread
|
|
3874
3880
|
},
|
|
3875
3881
|
submissionsFormId || "all"
|
|
3876
|
-
) :
|
|
3882
|
+
) : selectedSection === "analytics" && access.readAnalytics ? /* @__PURE__ */ jsx12(AnalyticsView, { api }) : selectedSection === "globals" ? /* @__PURE__ */ jsx12(
|
|
3877
3883
|
GlobalsView,
|
|
3878
3884
|
{
|
|
3879
3885
|
api,
|
|
@@ -3883,7 +3889,7 @@ function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
|
|
|
3883
3889
|
onUploadMedia: uploadMedia,
|
|
3884
3890
|
pages
|
|
3885
3891
|
}
|
|
3886
|
-
) :
|
|
3892
|
+
) : selectedSection === "redirects" && access.manageRedirects ? /* @__PURE__ */ jsx12(RedirectsView, { api }) : selectedSection === "users" && access.manageUsers ? /* @__PURE__ */ jsx12(UsersView, { api, meId: session.user.id }) : /* @__PURE__ */ jsx12(
|
|
3887
3893
|
DashboardView,
|
|
3888
3894
|
{
|
|
3889
3895
|
api,
|
|
@@ -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$;
|