@orion-studios/cms 0.5.1 → 0.5.3
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/README.md +24 -1
- package/dist/{chunk-HVJCF2IZ.js → chunk-AYV6KYDP.js} +3 -3
- package/dist/content/index.js +1 -1
- package/dist/forms/react.d.ts +2 -2
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +174 -42
- package/dist/studio/index.d.ts +14 -5
- package/dist/studio/index.js +149 -67
- package/package.json +1 -1
- package/sql/bootstrap.sql +492 -60
- package/sql/migrations/20260820033957_sec_003_exact_version_publication.sql +575 -0
- package/sql/migrations/20260820170000_sec_005_reject_published_version_schedule.sql +88 -0
package/sql/bootstrap.sql
CHANGED
|
@@ -35,13 +35,19 @@ create table if not exists cms_pages (
|
|
|
35
35
|
title text not null default '',
|
|
36
36
|
seo jsonb not null default '{}'::jsonb,
|
|
37
37
|
draft_layout jsonb not null default '[]'::jsonb,
|
|
38
|
+
published_title text,
|
|
39
|
+
published_seo jsonb,
|
|
38
40
|
published_layout jsonb,
|
|
41
|
+
draft_version_id bigint,
|
|
42
|
+
published_version_id bigint,
|
|
43
|
+
scheduled_version_id bigint,
|
|
39
44
|
status text not null default 'draft' check (status in ('draft', 'published')),
|
|
40
45
|
-- true once edited in the Studio; content-as-code sync then leaves the layout alone
|
|
41
46
|
builder_owned boolean not null default false,
|
|
42
47
|
created_at timestamptz not null default now(),
|
|
43
48
|
updated_at timestamptz not null default now(),
|
|
44
|
-
published_at timestamptz
|
|
49
|
+
published_at timestamptz,
|
|
50
|
+
publish_at timestamptz
|
|
45
51
|
);
|
|
46
52
|
create index if not exists cms_pages_status_idx on cms_pages (status);
|
|
47
53
|
|
|
@@ -58,6 +64,151 @@ create table if not exists cms_page_versions (
|
|
|
58
64
|
create index if not exists cms_page_versions_page_idx
|
|
59
65
|
on cms_page_versions (page_id, created_at desc);
|
|
60
66
|
|
|
67
|
+
-- Additive publication columns for existing databases.
|
|
68
|
+
alter table cms_pages add column if not exists published_title text;
|
|
69
|
+
alter table cms_pages add column if not exists published_seo jsonb;
|
|
70
|
+
alter table cms_pages add column if not exists draft_version_id bigint;
|
|
71
|
+
alter table cms_pages add column if not exists published_version_id bigint;
|
|
72
|
+
alter table cms_pages add column if not exists scheduled_version_id bigint;
|
|
73
|
+
alter table cms_pages add column if not exists publish_at timestamptz;
|
|
74
|
+
|
|
75
|
+
-- Recover the already-public metadata from matching publication history.
|
|
76
|
+
update cms_pages
|
|
77
|
+
set published_layout = '[]'::jsonb
|
|
78
|
+
where status = 'published' and published_layout is null;
|
|
79
|
+
|
|
80
|
+
with published_history as (
|
|
81
|
+
select distinct on (p.id)
|
|
82
|
+
p.id as page_id,
|
|
83
|
+
v.id as version_id,
|
|
84
|
+
v.title,
|
|
85
|
+
v.seo
|
|
86
|
+
from cms_pages p
|
|
87
|
+
join cms_page_versions v
|
|
88
|
+
on v.page_id = p.id
|
|
89
|
+
and v.kind in ('publish', 'sync')
|
|
90
|
+
and v.layout is not distinct from p.published_layout
|
|
91
|
+
where p.status = 'published'
|
|
92
|
+
order by p.id, v.created_at desc, v.id desc
|
|
93
|
+
)
|
|
94
|
+
update cms_pages p
|
|
95
|
+
set published_title = h.title,
|
|
96
|
+
published_seo = h.seo,
|
|
97
|
+
published_version_id = h.version_id
|
|
98
|
+
from published_history h
|
|
99
|
+
where p.id = h.page_id
|
|
100
|
+
and p.published_version_id is null;
|
|
101
|
+
|
|
102
|
+
with inserted as (
|
|
103
|
+
insert into cms_page_versions (page_id, kind, title, seo, layout)
|
|
104
|
+
select id, 'publish', title, seo, published_layout
|
|
105
|
+
from cms_pages
|
|
106
|
+
where status = 'published'
|
|
107
|
+
and published_version_id is null
|
|
108
|
+
returning id, page_id, title, seo
|
|
109
|
+
)
|
|
110
|
+
update cms_pages p
|
|
111
|
+
set published_title = i.title,
|
|
112
|
+
published_seo = i.seo,
|
|
113
|
+
published_version_id = i.id
|
|
114
|
+
from inserted i
|
|
115
|
+
where p.id = i.page_id;
|
|
116
|
+
|
|
117
|
+
with draft_history as (
|
|
118
|
+
select distinct on (p.id)
|
|
119
|
+
p.id as page_id,
|
|
120
|
+
v.id as version_id
|
|
121
|
+
from cms_pages p
|
|
122
|
+
join cms_page_versions v
|
|
123
|
+
on v.page_id = p.id
|
|
124
|
+
and v.title is not distinct from p.title
|
|
125
|
+
and v.seo is not distinct from p.seo
|
|
126
|
+
and v.layout is not distinct from p.draft_layout
|
|
127
|
+
order by p.id, v.created_at desc, v.id desc
|
|
128
|
+
)
|
|
129
|
+
update cms_pages p
|
|
130
|
+
set draft_version_id = h.version_id
|
|
131
|
+
from draft_history h
|
|
132
|
+
where p.id = h.page_id
|
|
133
|
+
and p.draft_version_id is null;
|
|
134
|
+
|
|
135
|
+
with inserted as (
|
|
136
|
+
insert into cms_page_versions (page_id, kind, title, seo, layout)
|
|
137
|
+
select id, 'draft', title, seo, draft_layout
|
|
138
|
+
from cms_pages
|
|
139
|
+
where draft_version_id is null
|
|
140
|
+
returning id, page_id
|
|
141
|
+
)
|
|
142
|
+
update cms_pages p
|
|
143
|
+
set draft_version_id = i.id
|
|
144
|
+
from inserted i
|
|
145
|
+
where p.id = i.page_id;
|
|
146
|
+
|
|
147
|
+
-- A legacy timestamp cannot prove which draft was reviewed. Clear the
|
|
148
|
+
-- incomplete schedule so an editor must explicitly bind an exact version.
|
|
149
|
+
update cms_pages
|
|
150
|
+
set publish_at = null,
|
|
151
|
+
scheduled_version_id = null
|
|
152
|
+
where publish_at is not null
|
|
153
|
+
and scheduled_version_id is null;
|
|
154
|
+
|
|
155
|
+
-- A published version is not a future publication candidate. Clear invalid
|
|
156
|
+
-- schedules left by versions of the scheduling function that omitted this
|
|
157
|
+
-- guard, so repeated bootstrap runs repair existing databases fail closed.
|
|
158
|
+
update cms_pages
|
|
159
|
+
set publish_at = null,
|
|
160
|
+
scheduled_version_id = null,
|
|
161
|
+
updated_at = now()
|
|
162
|
+
where scheduled_version_id is not null
|
|
163
|
+
and scheduled_version_id = published_version_id;
|
|
164
|
+
|
|
165
|
+
create unique index if not exists cms_page_versions_page_id_id_idx
|
|
166
|
+
on cms_page_versions (page_id, id);
|
|
167
|
+
create index if not exists cms_pages_scheduled_publish_due_idx
|
|
168
|
+
on cms_pages (publish_at)
|
|
169
|
+
where publish_at is not null and scheduled_version_id is not null;
|
|
170
|
+
|
|
171
|
+
do $publication_constraints$
|
|
172
|
+
begin
|
|
173
|
+
if not exists (select 1 from pg_constraint where conname = 'cms_pages_draft_version_fk' and conrelid = 'cms_pages'::regclass) then
|
|
174
|
+
alter table cms_pages
|
|
175
|
+
add constraint cms_pages_draft_version_fk
|
|
176
|
+
foreign key (id, draft_version_id)
|
|
177
|
+
references cms_page_versions (page_id, id);
|
|
178
|
+
end if;
|
|
179
|
+
if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_version_fk' and conrelid = 'cms_pages'::regclass) then
|
|
180
|
+
alter table cms_pages
|
|
181
|
+
add constraint cms_pages_published_version_fk
|
|
182
|
+
foreign key (id, published_version_id)
|
|
183
|
+
references cms_page_versions (page_id, id);
|
|
184
|
+
end if;
|
|
185
|
+
if not exists (select 1 from pg_constraint where conname = 'cms_pages_scheduled_version_fk' and conrelid = 'cms_pages'::regclass) then
|
|
186
|
+
alter table cms_pages
|
|
187
|
+
add constraint cms_pages_scheduled_version_fk
|
|
188
|
+
foreign key (id, scheduled_version_id)
|
|
189
|
+
references cms_page_versions (page_id, id);
|
|
190
|
+
end if;
|
|
191
|
+
if not exists (select 1 from pg_constraint where conname = 'cms_pages_schedule_version_pair' and conrelid = 'cms_pages'::regclass) then
|
|
192
|
+
alter table cms_pages
|
|
193
|
+
add constraint cms_pages_schedule_version_pair
|
|
194
|
+
check ((publish_at is null) = (scheduled_version_id is null));
|
|
195
|
+
end if;
|
|
196
|
+
if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_state_complete' and conrelid = 'cms_pages'::regclass) then
|
|
197
|
+
alter table cms_pages
|
|
198
|
+
add constraint cms_pages_published_state_complete
|
|
199
|
+
check (
|
|
200
|
+
status <> 'published'
|
|
201
|
+
or (
|
|
202
|
+
published_title is not null
|
|
203
|
+
and published_seo is not null
|
|
204
|
+
and published_layout is not null
|
|
205
|
+
and published_version_id is not null
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
|
+
end if;
|
|
209
|
+
end
|
|
210
|
+
$publication_constraints$;
|
|
211
|
+
|
|
61
212
|
create table if not exists cms_globals (
|
|
62
213
|
key text primary key,
|
|
63
214
|
label text not null default '',
|
|
@@ -172,6 +323,13 @@ create table if not exists cms_activity (
|
|
|
172
323
|
);
|
|
173
324
|
create index if not exists cms_activity_created_idx on cms_activity (created_at desc);
|
|
174
325
|
|
|
326
|
+
create table if not exists cms_rate_limits (
|
|
327
|
+
key text primary key,
|
|
328
|
+
window_start timestamptz not null default now(),
|
|
329
|
+
count integer not null default 0
|
|
330
|
+
);
|
|
331
|
+
create index if not exists cms_rate_limits_window_idx on cms_rate_limits (window_start);
|
|
332
|
+
|
|
175
333
|
-- 2) Functions (atomic write paths, called via RPC with service role) --------
|
|
176
334
|
|
|
177
335
|
create or replace function cms_save_page_draft(
|
|
@@ -187,36 +345,51 @@ security definer
|
|
|
187
345
|
set search_path = public
|
|
188
346
|
as $$
|
|
189
347
|
declare
|
|
348
|
+
current_page cms_pages;
|
|
190
349
|
result cms_pages;
|
|
191
|
-
|
|
350
|
+
version_id bigint;
|
|
351
|
+
next_title text;
|
|
352
|
+
next_seo jsonb;
|
|
353
|
+
next_layout jsonb;
|
|
192
354
|
begin
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
seo = coalesce(p_seo, seo),
|
|
196
|
-
draft_layout = coalesce(p_layout, draft_layout),
|
|
197
|
-
builder_owned = builder_owned or p_mark_builder_owned,
|
|
198
|
-
updated_at = now()
|
|
355
|
+
select * into current_page
|
|
356
|
+
from cms_pages
|
|
199
357
|
where id = p_page_id
|
|
200
|
-
|
|
358
|
+
for update;
|
|
201
359
|
|
|
202
|
-
if
|
|
360
|
+
if current_page.id is null then
|
|
203
361
|
raise exception 'page % not found', p_page_id;
|
|
204
362
|
end if;
|
|
205
363
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
364
|
+
next_title := coalesce(p_title, current_page.title);
|
|
365
|
+
next_seo := coalesce(p_seo, current_page.seo);
|
|
366
|
+
next_layout := coalesce(p_layout, current_page.draft_layout);
|
|
367
|
+
|
|
368
|
+
select id into version_id
|
|
369
|
+
from cms_page_versions
|
|
370
|
+
where page_id = p_page_id
|
|
371
|
+
and title is not distinct from next_title
|
|
372
|
+
and seo is not distinct from next_seo
|
|
373
|
+
and layout is not distinct from next_layout
|
|
374
|
+
order by created_at desc, id desc
|
|
375
|
+
limit 1;
|
|
211
376
|
|
|
212
|
-
if
|
|
213
|
-
or latest.title is distinct from result.title
|
|
214
|
-
or latest.seo is distinct from result.seo
|
|
215
|
-
or latest.layout is distinct from result.draft_layout then
|
|
377
|
+
if version_id is null then
|
|
216
378
|
insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
|
|
217
|
-
values (p_page_id, 'draft',
|
|
379
|
+
values (p_page_id, 'draft', next_title, next_seo, next_layout, p_actor)
|
|
380
|
+
returning id into version_id;
|
|
218
381
|
end if;
|
|
219
382
|
|
|
383
|
+
update cms_pages
|
|
384
|
+
set title = next_title,
|
|
385
|
+
seo = next_seo,
|
|
386
|
+
draft_layout = next_layout,
|
|
387
|
+
draft_version_id = version_id,
|
|
388
|
+
builder_owned = builder_owned or p_mark_builder_owned,
|
|
389
|
+
updated_at = now()
|
|
390
|
+
where id = p_page_id
|
|
391
|
+
returning * into result;
|
|
392
|
+
|
|
220
393
|
return result;
|
|
221
394
|
end;
|
|
222
395
|
$$;
|
|
@@ -229,24 +402,65 @@ language plpgsql
|
|
|
229
402
|
security definer
|
|
230
403
|
set search_path = public
|
|
231
404
|
as $$
|
|
405
|
+
begin
|
|
406
|
+
raise exception 'Page version conflict: expected version is required';
|
|
407
|
+
end;
|
|
408
|
+
$$;
|
|
409
|
+
|
|
410
|
+
create or replace function cms_publish_page(
|
|
411
|
+
p_page_id uuid,
|
|
412
|
+
p_expected_version_id bigint,
|
|
413
|
+
p_actor uuid
|
|
414
|
+
) returns cms_pages
|
|
415
|
+
language plpgsql
|
|
416
|
+
security definer
|
|
417
|
+
set search_path = public
|
|
418
|
+
as $$
|
|
232
419
|
declare
|
|
420
|
+
current_page cms_pages;
|
|
421
|
+
reviewed cms_page_versions;
|
|
233
422
|
result cms_pages;
|
|
234
423
|
begin
|
|
424
|
+
select * into current_page
|
|
425
|
+
from cms_pages
|
|
426
|
+
where id = p_page_id
|
|
427
|
+
for update;
|
|
428
|
+
|
|
429
|
+
if current_page.id is null then
|
|
430
|
+
raise exception 'page % not found', p_page_id;
|
|
431
|
+
end if;
|
|
432
|
+
|
|
433
|
+
select * into reviewed
|
|
434
|
+
from cms_page_versions
|
|
435
|
+
where id = p_expected_version_id
|
|
436
|
+
and page_id = p_page_id;
|
|
437
|
+
|
|
438
|
+
if reviewed.id is null
|
|
439
|
+
or current_page.draft_version_id is distinct from p_expected_version_id
|
|
440
|
+
or reviewed.title is distinct from current_page.title
|
|
441
|
+
or reviewed.seo is distinct from current_page.seo
|
|
442
|
+
or reviewed.layout is distinct from current_page.draft_layout then
|
|
443
|
+
raise exception 'Page version conflict: expected draft is missing or stale';
|
|
444
|
+
end if;
|
|
445
|
+
|
|
446
|
+
if current_page.status = 'published'
|
|
447
|
+
and current_page.published_version_id = p_expected_version_id then
|
|
448
|
+
raise exception 'Page version conflict: expected draft is already published';
|
|
449
|
+
end if;
|
|
450
|
+
|
|
235
451
|
update cms_pages
|
|
236
|
-
set
|
|
452
|
+
set published_title = reviewed.title,
|
|
453
|
+
published_seo = reviewed.seo,
|
|
454
|
+
published_layout = reviewed.layout,
|
|
455
|
+
published_version_id = reviewed.id,
|
|
237
456
|
status = 'published',
|
|
238
457
|
published_at = now(),
|
|
458
|
+
publish_at = null,
|
|
459
|
+
scheduled_version_id = null,
|
|
239
460
|
updated_at = now()
|
|
240
461
|
where id = p_page_id
|
|
241
462
|
returning * into result;
|
|
242
463
|
|
|
243
|
-
if result.id is null then
|
|
244
|
-
raise exception 'page % not found', p_page_id;
|
|
245
|
-
end if;
|
|
246
|
-
|
|
247
|
-
insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
|
|
248
|
-
values (p_page_id, 'publish', result.title, result.seo, result.published_layout, p_actor);
|
|
249
|
-
|
|
250
464
|
return result;
|
|
251
465
|
end;
|
|
252
466
|
$$;
|
|
@@ -262,24 +476,29 @@ as $$
|
|
|
262
476
|
declare
|
|
263
477
|
v cms_page_versions;
|
|
264
478
|
result cms_pages;
|
|
479
|
+
restored_version_id bigint;
|
|
265
480
|
begin
|
|
266
481
|
select * into v from cms_page_versions where id = p_version_id;
|
|
267
482
|
if v.id is null then
|
|
268
483
|
raise exception 'version % not found', p_version_id;
|
|
269
484
|
end if;
|
|
270
485
|
|
|
486
|
+
perform 1 from cms_pages where id = v.page_id for update;
|
|
487
|
+
|
|
488
|
+
insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
|
|
489
|
+
values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor)
|
|
490
|
+
returning id into restored_version_id;
|
|
491
|
+
|
|
271
492
|
update cms_pages
|
|
272
493
|
set draft_layout = v.layout,
|
|
273
494
|
title = v.title,
|
|
274
495
|
seo = v.seo,
|
|
496
|
+
draft_version_id = restored_version_id,
|
|
275
497
|
builder_owned = true,
|
|
276
498
|
updated_at = now()
|
|
277
499
|
where id = v.page_id
|
|
278
500
|
returning * into result;
|
|
279
501
|
|
|
280
|
-
insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
|
|
281
|
-
values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor);
|
|
282
|
-
|
|
283
502
|
return result;
|
|
284
503
|
end;
|
|
285
504
|
$$;
|
|
@@ -299,36 +518,238 @@ set search_path = public
|
|
|
299
518
|
as $$
|
|
300
519
|
declare
|
|
301
520
|
result cms_pages;
|
|
302
|
-
|
|
521
|
+
version_id bigint;
|
|
522
|
+
is_new boolean := false;
|
|
303
523
|
begin
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
updated_at = case when cms_pages.builder_owned then cms_pages.updated_at else now() end
|
|
524
|
+
insert into cms_pages (
|
|
525
|
+
slug, path, title, seo, draft_layout,
|
|
526
|
+
published_title, published_seo, published_layout,
|
|
527
|
+
status, published_at
|
|
528
|
+
)
|
|
529
|
+
values (
|
|
530
|
+
p_slug, p_path, p_title, p_seo, p_layout,
|
|
531
|
+
p_title, p_seo, p_layout,
|
|
532
|
+
'draft', null
|
|
533
|
+
)
|
|
534
|
+
on conflict (slug) do nothing
|
|
316
535
|
returning * into result;
|
|
317
536
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
537
|
+
if result.id is not null then
|
|
538
|
+
is_new := true;
|
|
539
|
+
else
|
|
540
|
+
select * into result
|
|
541
|
+
from cms_pages
|
|
542
|
+
where slug = p_slug
|
|
543
|
+
for update;
|
|
544
|
+
|
|
545
|
+
-- Builder-owned pages belong to the Studio. Sync only ensures they exist.
|
|
546
|
+
if not result.builder_owned then
|
|
547
|
+
update cms_pages
|
|
548
|
+
set path = p_path,
|
|
549
|
+
title = p_title,
|
|
550
|
+
seo = p_seo,
|
|
551
|
+
draft_layout = p_layout,
|
|
552
|
+
published_title = p_title,
|
|
553
|
+
published_seo = p_seo,
|
|
554
|
+
published_layout = p_layout,
|
|
555
|
+
updated_at = now()
|
|
556
|
+
where id = result.id
|
|
557
|
+
returning * into result;
|
|
558
|
+
end if;
|
|
559
|
+
end if;
|
|
323
560
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
561
|
+
select id into version_id
|
|
562
|
+
from cms_page_versions
|
|
563
|
+
where page_id = result.id
|
|
564
|
+
and title is not distinct from result.title
|
|
565
|
+
and seo is not distinct from result.seo
|
|
566
|
+
and layout is not distinct from result.draft_layout
|
|
567
|
+
order by created_at desc, id desc
|
|
568
|
+
limit 1;
|
|
569
|
+
|
|
570
|
+
if version_id is null then
|
|
328
571
|
insert into cms_page_versions (page_id, kind, title, seo, layout)
|
|
329
|
-
values (result.id, 'sync', result.title, result.seo, result.draft_layout)
|
|
572
|
+
values (result.id, 'sync', result.title, result.seo, result.draft_layout)
|
|
573
|
+
returning id into version_id;
|
|
574
|
+
end if;
|
|
575
|
+
|
|
576
|
+
if not result.builder_owned then
|
|
577
|
+
update cms_pages
|
|
578
|
+
set draft_version_id = version_id,
|
|
579
|
+
published_version_id = version_id,
|
|
580
|
+
status = case when is_new then 'published' else status end,
|
|
581
|
+
published_at = case when is_new then now() else published_at end
|
|
582
|
+
where id = result.id
|
|
583
|
+
returning * into result;
|
|
584
|
+
end if;
|
|
585
|
+
|
|
586
|
+
return result;
|
|
587
|
+
end;
|
|
588
|
+
$$;
|
|
589
|
+
|
|
590
|
+
create or replace function cms_schedule_page_publish(
|
|
591
|
+
p_page_id uuid,
|
|
592
|
+
p_expected_version_id bigint,
|
|
593
|
+
p_publish_at timestamptz,
|
|
594
|
+
p_actor uuid
|
|
595
|
+
) returns cms_pages
|
|
596
|
+
language plpgsql
|
|
597
|
+
security definer
|
|
598
|
+
set search_path = public
|
|
599
|
+
as $$
|
|
600
|
+
declare
|
|
601
|
+
current_page cms_pages;
|
|
602
|
+
reviewed cms_page_versions;
|
|
603
|
+
result cms_pages;
|
|
604
|
+
begin
|
|
605
|
+
select * into current_page
|
|
606
|
+
from cms_pages
|
|
607
|
+
where id = p_page_id
|
|
608
|
+
for update;
|
|
609
|
+
|
|
610
|
+
if current_page.id is null then
|
|
611
|
+
raise exception 'page % not found', p_page_id;
|
|
612
|
+
end if;
|
|
613
|
+
|
|
614
|
+
if p_publish_at is null then
|
|
615
|
+
update cms_pages
|
|
616
|
+
set publish_at = null,
|
|
617
|
+
scheduled_version_id = null,
|
|
618
|
+
updated_at = now()
|
|
619
|
+
where id = p_page_id
|
|
620
|
+
returning * into result;
|
|
621
|
+
return result;
|
|
622
|
+
end if;
|
|
623
|
+
|
|
624
|
+
select * into reviewed
|
|
625
|
+
from cms_page_versions
|
|
626
|
+
where id = p_expected_version_id
|
|
627
|
+
and page_id = p_page_id;
|
|
628
|
+
|
|
629
|
+
if reviewed.id is null
|
|
630
|
+
or current_page.draft_version_id is distinct from p_expected_version_id
|
|
631
|
+
or reviewed.title is distinct from current_page.title
|
|
632
|
+
or reviewed.seo is distinct from current_page.seo
|
|
633
|
+
or reviewed.layout is distinct from current_page.draft_layout then
|
|
634
|
+
raise exception 'Page version conflict: expected draft is missing or stale';
|
|
635
|
+
end if;
|
|
636
|
+
|
|
637
|
+
if current_page.published_version_id = p_expected_version_id then
|
|
638
|
+
raise exception 'Page version conflict: expected draft is already published';
|
|
330
639
|
end if;
|
|
331
640
|
|
|
641
|
+
update cms_pages
|
|
642
|
+
set publish_at = p_publish_at,
|
|
643
|
+
scheduled_version_id = reviewed.id,
|
|
644
|
+
updated_at = now()
|
|
645
|
+
where id = p_page_id
|
|
646
|
+
returning * into result;
|
|
647
|
+
|
|
648
|
+
return result;
|
|
649
|
+
end;
|
|
650
|
+
$$;
|
|
651
|
+
|
|
652
|
+
create or replace function cms_publish_due_pages()
|
|
653
|
+
returns setof text
|
|
654
|
+
language plpgsql
|
|
655
|
+
security definer
|
|
656
|
+
set search_path = public
|
|
657
|
+
as $$
|
|
658
|
+
declare
|
|
659
|
+
due record;
|
|
660
|
+
begin
|
|
661
|
+
for due in
|
|
662
|
+
select
|
|
663
|
+
p.id,
|
|
664
|
+
p.path,
|
|
665
|
+
v.id as version_id,
|
|
666
|
+
v.title,
|
|
667
|
+
v.seo,
|
|
668
|
+
v.layout
|
|
669
|
+
from cms_pages p
|
|
670
|
+
join cms_page_versions v
|
|
671
|
+
on v.page_id = p.id
|
|
672
|
+
and v.id = p.scheduled_version_id
|
|
673
|
+
where p.publish_at is not null
|
|
674
|
+
and p.publish_at <= now()
|
|
675
|
+
and p.scheduled_version_id is not null
|
|
676
|
+
order by p.publish_at
|
|
677
|
+
for update of p skip locked
|
|
678
|
+
loop
|
|
679
|
+
update cms_pages
|
|
680
|
+
set published_title = due.title,
|
|
681
|
+
published_seo = due.seo,
|
|
682
|
+
published_layout = due.layout,
|
|
683
|
+
published_version_id = due.version_id,
|
|
684
|
+
status = 'published',
|
|
685
|
+
published_at = now(),
|
|
686
|
+
publish_at = null,
|
|
687
|
+
scheduled_version_id = null,
|
|
688
|
+
updated_at = now()
|
|
689
|
+
where id = due.id;
|
|
690
|
+
|
|
691
|
+
insert into cms_activity (action, subject)
|
|
692
|
+
values ('page.publish.scheduled', due.path);
|
|
693
|
+
|
|
694
|
+
return next due.path;
|
|
695
|
+
end loop;
|
|
696
|
+
return;
|
|
697
|
+
end;
|
|
698
|
+
$$;
|
|
699
|
+
|
|
700
|
+
create or replace function cms_rate_limit_consume(
|
|
701
|
+
p_key text,
|
|
702
|
+
p_max integer,
|
|
703
|
+
p_window_seconds integer
|
|
704
|
+
) returns boolean
|
|
705
|
+
language plpgsql
|
|
706
|
+
security definer
|
|
707
|
+
set search_path = public
|
|
708
|
+
as $$
|
|
709
|
+
declare
|
|
710
|
+
v_count integer;
|
|
711
|
+
begin
|
|
712
|
+
insert into cms_rate_limits (key, window_start, count)
|
|
713
|
+
values (p_key, now(), 1)
|
|
714
|
+
on conflict (key) do update
|
|
715
|
+
set count = case
|
|
716
|
+
when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
|
|
717
|
+
then 1
|
|
718
|
+
else cms_rate_limits.count + 1
|
|
719
|
+
end,
|
|
720
|
+
window_start = case
|
|
721
|
+
when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
|
|
722
|
+
then now()
|
|
723
|
+
else cms_rate_limits.window_start
|
|
724
|
+
end
|
|
725
|
+
returning count into v_count;
|
|
726
|
+
|
|
727
|
+
return v_count <= p_max;
|
|
728
|
+
end;
|
|
729
|
+
$$;
|
|
730
|
+
|
|
731
|
+
create or replace function cms_update_global(
|
|
732
|
+
p_key text,
|
|
733
|
+
p_data jsonb,
|
|
734
|
+
p_actor uuid default null
|
|
735
|
+
) returns cms_globals
|
|
736
|
+
language plpgsql
|
|
737
|
+
security definer
|
|
738
|
+
set search_path = public
|
|
739
|
+
as $$
|
|
740
|
+
declare
|
|
741
|
+
result cms_globals;
|
|
742
|
+
begin
|
|
743
|
+
insert into cms_globals (key, data, updated_at)
|
|
744
|
+
values (p_key, p_data, now())
|
|
745
|
+
on conflict (key) do update
|
|
746
|
+
set data = excluded.data,
|
|
747
|
+
updated_at = now()
|
|
748
|
+
returning * into result;
|
|
749
|
+
|
|
750
|
+
insert into cms_global_versions (key, data, created_by)
|
|
751
|
+
values (p_key, p_data, p_actor);
|
|
752
|
+
|
|
332
753
|
return result;
|
|
333
754
|
end;
|
|
334
755
|
$$;
|
|
@@ -349,6 +770,7 @@ alter table cms_global_versions enable row level security;
|
|
|
349
770
|
alter table cms_redirects enable row level security;
|
|
350
771
|
alter table cms_activity enable row level security;
|
|
351
772
|
alter table cms_events enable row level security;
|
|
773
|
+
alter table cms_rate_limits enable row level security;
|
|
352
774
|
-- cms_events: no anon policies or grants — ingest and reads go through the
|
|
353
775
|
-- API layer only.
|
|
354
776
|
|
|
@@ -402,8 +824,13 @@ declare
|
|
|
402
824
|
fns text[] := array[
|
|
403
825
|
'cms_save_page_draft(uuid, text, jsonb, jsonb, uuid, boolean)',
|
|
404
826
|
'cms_publish_page(uuid, uuid)',
|
|
827
|
+
'cms_publish_page(uuid, bigint, uuid)',
|
|
828
|
+
'cms_schedule_page_publish(uuid, bigint, timestamptz, uuid)',
|
|
405
829
|
'cms_restore_page_version(bigint, uuid)',
|
|
406
|
-
'cms_sync_page(text, text, text, jsonb, jsonb)'
|
|
830
|
+
'cms_sync_page(text, text, text, jsonb, jsonb)',
|
|
831
|
+
'cms_publish_due_pages()',
|
|
832
|
+
'cms_rate_limit_consume(text, integer, integer)',
|
|
833
|
+
'cms_update_global(text, jsonb, uuid)'
|
|
407
834
|
];
|
|
408
835
|
begin
|
|
409
836
|
foreach fn in array fns loop
|
|
@@ -425,23 +852,27 @@ end $fn_lockdown$;
|
|
|
425
852
|
-- privileges only cover tables created by `postgres`; when bootstrap runs as a
|
|
426
853
|
-- site-specific role, the PostgREST roles get nothing — so grant explicitly.
|
|
427
854
|
-- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
|
|
855
|
+
revoke update, delete on cms_page_versions from public;
|
|
428
856
|
do $grants$
|
|
429
857
|
begin
|
|
430
858
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
431
859
|
grant select, insert, update, delete on
|
|
432
|
-
cms_settings, cms_profiles, cms_pages,
|
|
860
|
+
cms_settings, cms_profiles, cms_pages,
|
|
433
861
|
cms_globals, cms_media, cms_forms, cms_form_submissions,
|
|
434
862
|
cms_global_versions, cms_redirects, cms_activity, cms_events
|
|
435
863
|
to service_role;
|
|
864
|
+
revoke update, delete on cms_page_versions from service_role;
|
|
865
|
+
grant select, insert on cms_page_versions to service_role;
|
|
436
866
|
end if;
|
|
437
867
|
|
|
438
|
-
-- cms_pages and cms_forms get
|
|
439
|
-
--
|
|
868
|
+
-- cms_pages and cms_forms get column-level read grants. Anonymous readers
|
|
869
|
+
-- see published page state only and never form notification addresses.
|
|
440
870
|
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
441
871
|
grant select on cms_globals, cms_media, cms_redirects to anon;
|
|
442
872
|
revoke select on cms_pages from anon;
|
|
443
|
-
grant select (id, slug, path,
|
|
873
|
+
grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
|
|
444
874
|
on cms_pages to anon;
|
|
875
|
+
revoke all on cms_page_versions from anon;
|
|
445
876
|
revoke select on cms_forms from anon;
|
|
446
877
|
grant select (id, slug, title, config, success_message, created_at, updated_at)
|
|
447
878
|
on cms_forms to anon;
|
|
@@ -449,8 +880,9 @@ begin
|
|
|
449
880
|
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
450
881
|
grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
|
|
451
882
|
revoke select on cms_pages from authenticated;
|
|
452
|
-
grant select (id, slug, path,
|
|
883
|
+
grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
|
|
453
884
|
on cms_pages to authenticated;
|
|
885
|
+
revoke all on cms_page_versions from authenticated;
|
|
454
886
|
revoke select on cms_forms from authenticated;
|
|
455
887
|
grant select (id, slug, title, config, success_message, created_at, updated_at)
|
|
456
888
|
on cms_forms to authenticated;
|