@orion-studios/cms 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-VPUODCNH.js → chunk-CFZP7674.js} +29 -8
- package/dist/{chunk-WQDHEQDE.js → chunk-ULE565KD.js} +27 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +5 -1
- package/dist/forms/react.d.ts +1 -1
- package/dist/forms/react.js +1 -1
- package/dist/server/index.d.ts +26 -4
- package/dist/server/index.js +340 -103
- package/dist/studio/index.d.ts +1 -2
- package/dist/studio/index.js +42 -8
- package/dist/{submission-BKdBedOe.d.ts → submission-CGz0lElf.d.ts} +11 -2
- package/dist/{submission-CzrfXu17.d.ts → submission-CKZgx1h7.d.ts} +2 -0
- 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
package/dist/studio/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ComponentType, InputHTMLAttributes } from 'react';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { SupabaseClient, Session } from '@supabase/supabase-js';
|
|
5
|
-
import { F as FormConfig, a as FormNotifyConfig } from '../submission-
|
|
5
|
+
import { F as FormConfig, a as FormNotifyConfig } from '../submission-CKZgx1h7.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Editor field derivation: turns a block's Zod schema into a default editor
|
|
@@ -395,7 +395,6 @@ declare function createStudioApi(options: {
|
|
|
395
395
|
success: true;
|
|
396
396
|
}>;
|
|
397
397
|
previewToken: (id: string) => Promise<{
|
|
398
|
-
token: string;
|
|
399
398
|
path: string;
|
|
400
399
|
url: string;
|
|
401
400
|
}>;
|
package/dist/studio/index.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
import {
|
|
4
4
|
FORM_FIELD_TYPES,
|
|
5
|
-
FormRenderer
|
|
6
|
-
|
|
5
|
+
FormRenderer,
|
|
6
|
+
getAutoReplyEmailFields
|
|
7
|
+
} from "../chunk-ULE565KD.js";
|
|
7
8
|
|
|
8
9
|
// src/studio/Studio.tsx
|
|
9
10
|
import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
|
|
@@ -988,6 +989,7 @@ function FormEditor({
|
|
|
988
989
|
const [notifyEmails, setNotifyEmails] = useState5("");
|
|
989
990
|
const [notifySubject, setNotifySubject] = useState5("");
|
|
990
991
|
const [autoReply, setAutoReply] = useState5(false);
|
|
992
|
+
const [autoReplyEmailField, setAutoReplyEmailField] = useState5("");
|
|
991
993
|
const [dirty, setDirty] = useState5(false);
|
|
992
994
|
const [message, setMessage] = useState5("");
|
|
993
995
|
const [error, setError] = useState5("");
|
|
@@ -1005,6 +1007,10 @@ function FormEditor({
|
|
|
1005
1007
|
setNotifyEmails((notify.emails || []).join(", "));
|
|
1006
1008
|
setNotifySubject(notify.subject || "");
|
|
1007
1009
|
setAutoReply(notify.autoReply === true);
|
|
1010
|
+
const emailFields = getAutoReplyEmailFields(loadedConfig);
|
|
1011
|
+
setAutoReplyEmailField(
|
|
1012
|
+
notify.autoReplyEmailField && emailFields.includes(notify.autoReplyEmailField) ? notify.autoReplyEmailField : emailFields.length === 1 ? emailFields[0] : ""
|
|
1013
|
+
);
|
|
1008
1014
|
}, (e) => setError(e.message));
|
|
1009
1015
|
}, [api, slug]);
|
|
1010
1016
|
const touch = () => {
|
|
@@ -1027,6 +1033,12 @@ function FormEditor({
|
|
|
1027
1033
|
const save = async () => {
|
|
1028
1034
|
setError("");
|
|
1029
1035
|
const emails = notifyEmails.split(/[,\s]+/).map((entry) => entry.trim()).filter(Boolean);
|
|
1036
|
+
const emailFields = getAutoReplyEmailFields(config);
|
|
1037
|
+
const selectedEmailField = emailFields.includes(autoReplyEmailField) ? autoReplyEmailField : emailFields.length === 1 ? emailFields[0] : "";
|
|
1038
|
+
if (autoReply && !selectedEmailField) {
|
|
1039
|
+
setError("Choose one declared email field before enabling auto-reply.");
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1030
1042
|
try {
|
|
1031
1043
|
const { form: saved } = await api.updateForm(slug, {
|
|
1032
1044
|
title,
|
|
@@ -1034,7 +1046,8 @@ function FormEditor({
|
|
|
1034
1046
|
notify: {
|
|
1035
1047
|
emails,
|
|
1036
1048
|
...notifySubject.trim() ? { subject: notifySubject.trim() } : {},
|
|
1037
|
-
autoReply
|
|
1049
|
+
autoReply,
|
|
1050
|
+
...autoReply && selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
|
|
1038
1051
|
},
|
|
1039
1052
|
successMessage
|
|
1040
1053
|
});
|
|
@@ -1048,6 +1061,8 @@ function FormEditor({
|
|
|
1048
1061
|
if (!form && !error) return /* @__PURE__ */ jsx5("div", { className: "ost-loading", children: "Loading form\u2026" });
|
|
1049
1062
|
if (error && !form) return /* @__PURE__ */ jsx5("div", { className: "ost-error", children: error });
|
|
1050
1063
|
const steps = config.steps || [];
|
|
1064
|
+
const autoReplyEmailFields = getAutoReplyEmailFields(config);
|
|
1065
|
+
const selectedAutoReplyEmailField = autoReplyEmailFields.includes(autoReplyEmailField) ? autoReplyEmailField : autoReplyEmailFields.length === 1 ? autoReplyEmailFields[0] : "";
|
|
1051
1066
|
return /* @__PURE__ */ jsxs5("div", { className: "ost-view ost-view-wide", children: [
|
|
1052
1067
|
/* @__PURE__ */ jsxs5("header", { className: "ost-view-header", children: [
|
|
1053
1068
|
/* @__PURE__ */ jsxs5("div", { className: "ost-row", children: [
|
|
@@ -1212,7 +1227,26 @@ function FormEditor({
|
|
|
1212
1227
|
),
|
|
1213
1228
|
"Auto-reply to the submitter with the success message"
|
|
1214
1229
|
] }),
|
|
1215
|
-
/* @__PURE__ */
|
|
1230
|
+
autoReply ? /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
|
|
1231
|
+
"Auto-reply email field",
|
|
1232
|
+
/* @__PURE__ */ jsxs5(
|
|
1233
|
+
"select",
|
|
1234
|
+
{
|
|
1235
|
+
className: "ost-input",
|
|
1236
|
+
disabled: !canWrite || autoReplyEmailFields.length === 0,
|
|
1237
|
+
onChange: (event) => {
|
|
1238
|
+
setAutoReplyEmailField(event.target.value);
|
|
1239
|
+
touch();
|
|
1240
|
+
},
|
|
1241
|
+
value: selectedAutoReplyEmailField,
|
|
1242
|
+
children: [
|
|
1243
|
+
autoReplyEmailFields.length !== 1 ? /* @__PURE__ */ jsx5("option", { value: "", children: "Choose an email field" }) : null,
|
|
1244
|
+
autoReplyEmailFields.map((fieldName) => /* @__PURE__ */ jsx5("option", { value: fieldName, children: fieldName }, fieldName))
|
|
1245
|
+
]
|
|
1246
|
+
}
|
|
1247
|
+
)
|
|
1248
|
+
] }) : null,
|
|
1249
|
+
/* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "Auto-reply requires a declared email field and configured email sending (RESEND_API_KEY)." })
|
|
1216
1250
|
] })
|
|
1217
1251
|
] }),
|
|
1218
1252
|
/* @__PURE__ */ jsxs5("div", { className: "ost-form-preview", children: [
|
|
@@ -2225,7 +2259,7 @@ function PageEditor({
|
|
|
2225
2259
|
setBusy("preview");
|
|
2226
2260
|
try {
|
|
2227
2261
|
const { url } = await api.previewToken(pageId);
|
|
2228
|
-
window.open(url, "_blank", "noopener");
|
|
2262
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
2229
2263
|
} catch (previewError) {
|
|
2230
2264
|
setError(previewError instanceof Error ? previewError.message : "Preview failed.");
|
|
2231
2265
|
} finally {
|
|
@@ -3379,7 +3413,7 @@ function MediaView({
|
|
|
3379
3413
|
/* @__PURE__ */ jsx11(
|
|
3380
3414
|
"input",
|
|
3381
3415
|
{
|
|
3382
|
-
accept: "image
|
|
3416
|
+
accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
|
|
3383
3417
|
hidden: true,
|
|
3384
3418
|
onChange: async (event) => {
|
|
3385
3419
|
const file = event.currentTarget.files?.[0];
|
|
@@ -3493,12 +3527,12 @@ function MediaDetail({
|
|
|
3493
3527
|
children: saving ? "Saving\u2026" : "Save details"
|
|
3494
3528
|
}
|
|
3495
3529
|
),
|
|
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: [
|
|
3530
|
+
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
3531
|
replacing ? "Replacing\u2026" : "Replace file",
|
|
3498
3532
|
/* @__PURE__ */ jsx11(
|
|
3499
3533
|
"input",
|
|
3500
3534
|
{
|
|
3501
|
-
accept:
|
|
3535
|
+
accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
|
|
3502
3536
|
hidden: true,
|
|
3503
3537
|
onChange: async (event) => {
|
|
3504
3538
|
const file = event.currentTarget.files?.[0];
|
|
@@ -24,6 +24,8 @@ type FormNotifyConfig = {
|
|
|
24
24
|
subject?: string;
|
|
25
25
|
/** Send the success message back to the submitter's email field. */
|
|
26
26
|
autoReply?: boolean;
|
|
27
|
+
/** Declared email field used for staff reply-to and auto-replies. */
|
|
28
|
+
autoReplyEmailField?: string;
|
|
27
29
|
};
|
|
28
30
|
type FormConfig = {
|
|
29
31
|
steps?: Array<{
|
|
@@ -33,12 +35,19 @@ type FormConfig = {
|
|
|
33
35
|
notify?: FormNotifyConfig;
|
|
34
36
|
};
|
|
35
37
|
type RateLimitStore = {
|
|
36
|
-
isLimited(key: string, now: number): boolean | Promise<boolean>;
|
|
38
|
+
isLimited(key: string, now: number, cost?: number): boolean | Promise<boolean>;
|
|
37
39
|
};
|
|
38
40
|
declare function createMemoryRateLimitStore(options?: {
|
|
39
41
|
max?: number;
|
|
40
42
|
windowMs?: number;
|
|
41
43
|
}): RateLimitStore;
|
|
44
|
+
/**
|
|
45
|
+
* Returns unambiguous declared email fields in form order. A repeated name is
|
|
46
|
+
* eligible only when every declaration for that name is an email field.
|
|
47
|
+
*/
|
|
48
|
+
declare function getAutoReplyEmailFields(config: FormConfig): string[];
|
|
49
|
+
/** Resolves the one declared field that may control submission email delivery. */
|
|
50
|
+
declare function resolveAutoReplyEmailField(config: FormConfig): string | null;
|
|
42
51
|
type ProcessSubmissionArgs = {
|
|
43
52
|
config: FormConfig;
|
|
44
53
|
data: Record<string, unknown>;
|
|
@@ -58,4 +67,4 @@ type ProcessSubmissionResult = {
|
|
|
58
67
|
};
|
|
59
68
|
declare function processSubmission(args: ProcessSubmissionArgs): ProcessSubmissionResult;
|
|
60
69
|
|
|
61
|
-
export { type FormConfig as F, HONEYPOT_FIELD_NAME as H, type ProcessSubmissionArgs as P, type RateLimitStore as R, type FormFieldConfig as a, type FormNotifyConfig as b, type ProcessSubmissionResult as c, createMemoryRateLimitStore as d, processSubmission as p };
|
|
70
|
+
export { type FormConfig as F, HONEYPOT_FIELD_NAME as H, type ProcessSubmissionArgs as P, type RateLimitStore as R, type FormFieldConfig as a, type FormNotifyConfig as b, type ProcessSubmissionResult as c, createMemoryRateLimitStore as d, getAutoReplyEmailFields as g, processSubmission as p, resolveAutoReplyEmailField as r };
|
|
@@ -18,6 +18,8 @@ type FormNotifyConfig = {
|
|
|
18
18
|
subject?: string;
|
|
19
19
|
/** Send the success message back to the submitter's email field. */
|
|
20
20
|
autoReply?: boolean;
|
|
21
|
+
/** Declared email field used for staff reply-to and auto-replies. */
|
|
22
|
+
autoReplyEmailField?: string;
|
|
21
23
|
};
|
|
22
24
|
type FormConfig = {
|
|
23
25
|
steps?: Array<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orion-studios/cms",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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$;
|