@marktiderman/genesis-sites 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +465 -2
- package/dist/assets/form-island.c0f6fac859.js +51 -0
- package/dist/assets/islands.77d11f5697.js +1 -0
- package/dist/assets/sites-forms.4e6aa42497.css +10 -0
- package/dist/assets/sites-forms.css +10 -0
- package/dist/assets/sites.css +2 -0
- package/dist/assets/sites.e1ffd2708b.css +2 -0
- package/dist/blocks.d.ts +13 -0
- package/dist/blocks.js +26 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-3VKTBE7S.js +69 -0
- package/dist/chunk-3VKTBE7S.js.map +1 -0
- package/dist/chunk-C375ZPU2.js +2241 -0
- package/dist/chunk-C375ZPU2.js.map +1 -0
- package/dist/chunk-IXMY4B56.js +877 -0
- package/dist/chunk-IXMY4B56.js.map +1 -0
- package/dist/chunk-VZUDRPFG.js +13 -0
- package/dist/chunk-VZUDRPFG.js.map +1 -0
- package/dist/chunk-ZGWGQGT2.js +595 -0
- package/dist/chunk-ZGWGQGT2.js.map +1 -0
- package/dist/import.d.ts +130 -0
- package/dist/import.js +330 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +273 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/dist/registry-D_DCWRTc.d.ts +1030 -0
- package/dist/sections.d.ts +802 -0
- package/dist/sections.js +6 -0
- package/dist/sections.js.map +1 -0
- package/dist/store-DkSycDhq.d.ts +443 -0
- package/dist/supabase.d.ts +41 -0
- package/dist/supabase.js +283 -0
- package/dist/supabase.js.map +1 -0
- package/dist/web.d.ts +136 -0
- package/dist/web.js +479 -0
- package/dist/web.js.map +1 -0
- package/migrations/0001_genesis_sites.sql +821 -0
- package/migrations/0002_genesis_sites_posts.sql +404 -0
- package/migrations/tests/0001_genesis_sites.test.sql +349 -0
- package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
- package/package.json +152 -3
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
-- Genesis Sites: the genesis_sites schema (sites, pages, releases, assets)
|
|
2
|
+
--
|
|
3
|
+
-- A site is a draft site document plus one draft per page. A publish freezes
|
|
4
|
+
-- them into an immutable release, and the live site points at it; a rollback
|
|
5
|
+
-- moves the pointer back. The client half is `createSupabaseSiteStore` in
|
|
6
|
+
-- @marktiderman/genesis-sites/supabase, which runs the publish checks
|
|
7
|
+
-- (accessibility, contrast, content) before it calls publish_site.
|
|
8
|
+
--
|
|
9
|
+
-- Everything lives in the genesis_sites schema. Expose it to the API: add it
|
|
10
|
+
-- to `schemas` under [api] in supabase/config.toml, and to Exposed schemas
|
|
11
|
+
-- in the dashboard.
|
|
12
|
+
--
|
|
13
|
+
-- Who may edit is yours. This file names no table of yours; it asks two hooks:
|
|
14
|
+
--
|
|
15
|
+
-- genesis_sites.can_edit(site_id uuid) returns boolean
|
|
16
|
+
-- May the signed-in person change this site (drafts, pages, assets,
|
|
17
|
+
-- publish, rollback)? Ships as a stub that answers false, so a fresh
|
|
18
|
+
-- install fails closed.
|
|
19
|
+
-- genesis_sites.can_view(site_id uuid) returns boolean
|
|
20
|
+
-- May they read its drafts and releases (an editor's preview, a
|
|
21
|
+
-- colleague who reviews)? Ships as a stub that asks can_edit.
|
|
22
|
+
--
|
|
23
|
+
-- Replace them in a migration of your own, after this one, with the same
|
|
24
|
+
-- signature. `create or replace` keeps the grants this file gives; keep
|
|
25
|
+
-- `stable`, and use `security definer set search_path = ''` when the hook
|
|
26
|
+
-- reads tables with row level security of their own.
|
|
27
|
+
--
|
|
28
|
+
-- Name the person in the hook (`auth.uid()`); never rely on your tables'
|
|
29
|
+
-- row level security to filter for you. The write functions below are
|
|
30
|
+
-- security definer, so the hook runs as their owner there, where your RLS
|
|
31
|
+
-- doesn't apply: a hook that asks "is any membership row visible?" would
|
|
32
|
+
-- answer yes for everyone. For example:
|
|
33
|
+
--
|
|
34
|
+
-- create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
|
|
35
|
+
-- language sql stable security definer set search_path = '' as $$
|
|
36
|
+
-- select exists (select 1 from public.brand_members m
|
|
37
|
+
-- join public.brand_sites b on b.brand_id = m.brand_id
|
|
38
|
+
-- where b.site_id = can_edit.site_id and m.user_id = auth.uid() and m.role in ('owner', 'editor'))
|
|
39
|
+
-- $$;
|
|
40
|
+
--
|
|
41
|
+
-- This file (and every later genesis-sites migration) creates a hook only
|
|
42
|
+
-- when it is missing or still exactly the stub, through
|
|
43
|
+
-- genesis_sites.ensure_hook_stubs(), so running it again never overwrites
|
|
44
|
+
-- your version. The same goes for genesis_sites.reserved_paths(): the page
|
|
45
|
+
-- paths a site can't use because it shares its hostname with your app.
|
|
46
|
+
--
|
|
47
|
+
-- Public reads: anon reads only a live site's live release, through
|
|
48
|
+
-- genesis_sites.live_release(site_id). Drafts are never readable by anon;
|
|
49
|
+
-- signed-in people read drafts and releases where can_view says so.
|
|
50
|
+
--
|
|
51
|
+
-- Writes go through the security definer functions below, each asking
|
|
52
|
+
-- can_edit. The tables have row level security on and no write grants to
|
|
53
|
+
-- anon or authenticated, except inserting and deleting asset rows (an
|
|
54
|
+
-- upload's record), under can_edit. Sites are created by the service role
|
|
55
|
+
-- (create_site), from a server job or your own function.
|
|
56
|
+
--
|
|
57
|
+
-- Assets: store files under `sites/<site_id>/...` in your bucket, so your
|
|
58
|
+
-- storage policies can scope by site with can_edit, for example:
|
|
59
|
+
--
|
|
60
|
+
-- create policy "site editors upload" on storage.objects for insert to authenticated
|
|
61
|
+
-- with check (bucket_id = 'site-assets' and (storage.foldername(name))[1] = 'sites'
|
|
62
|
+
-- and lower(storage.extension(name)) in ('png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'pdf')
|
|
63
|
+
-- and genesis_sites.can_edit(((storage.foldername(name))[2])::uuid));
|
|
64
|
+
--
|
|
65
|
+
-- and set the bucket's allowed_mime_types to the same image types and PDF:
|
|
66
|
+
-- never SVG or HTML, which a browser runs as a page.
|
|
67
|
+
--
|
|
68
|
+
-- Audit: add your own triggers on these tables if you keep an audit trail;
|
|
69
|
+
-- nothing here depends on one. Every change to what a site serves (publish,
|
|
70
|
+
-- rollback, archive, unarchive) sends `pg_notify('genesis_sites',
|
|
71
|
+
-- {"site_id", "release_id", "status", "action"})`, from a trigger on sites,
|
|
72
|
+
-- for a listener that purges the CDN (or purge from the server that made it).
|
|
73
|
+
--
|
|
74
|
+
-- Checks: publish_site checks shape, size, paths and revisions. The
|
|
75
|
+
-- accessibility and content checks run in TypeScript (`prepareRelease`, in
|
|
76
|
+
-- the stores) before it is called, so they are a guard for editors, not a
|
|
77
|
+
-- security boundary: an editor calling the function directly can skip them,
|
|
78
|
+
-- and `brand_hash` is what the caller reports. Access rules can't be skipped.
|
|
79
|
+
-- A hidden section is left out of the release, so it is never public.
|
|
80
|
+
--
|
|
81
|
+
-- Use: `genesis add sites` copies this file into supabase/migrations under a
|
|
82
|
+
-- new timestamped name. Never edit it once applied; change it with a new migration.
|
|
83
|
+
--
|
|
84
|
+
-- Verify: the pgTAP test beside this file (`tests/0001_genesis_sites.test.sql`).
|
|
85
|
+
|
|
86
|
+
create schema if not exists genesis_sites;
|
|
87
|
+
|
|
88
|
+
comment on schema genesis_sites is 'Genesis Sites: sites, pages, releases and assets. Reached through RLS and the functions in this schema.';
|
|
89
|
+
|
|
90
|
+
grant usage on schema genesis_sites to anon, authenticated, service_role;
|
|
91
|
+
|
|
92
|
+
-- ── Hooks ─────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
-- Creates each hook only when it is missing or still exactly the stub this
|
|
95
|
+
-- package ships, so a re-run (or a later genesis-sites migration) never
|
|
96
|
+
-- overwrites the app's own version.
|
|
97
|
+
create or replace function genesis_sites.ensure_hook_stubs()
|
|
98
|
+
returns void
|
|
99
|
+
language plpgsql
|
|
100
|
+
security definer
|
|
101
|
+
set search_path = ''
|
|
102
|
+
as $fn$
|
|
103
|
+
declare
|
|
104
|
+
stubs constant jsonb := jsonb_build_array(
|
|
105
|
+
jsonb_build_object(
|
|
106
|
+
'name', 'can_edit',
|
|
107
|
+
'returns', 'boolean',
|
|
108
|
+
'body', E'\n -- genesis-sites stub: nobody may edit until the app replaces this function.\n select false\n'
|
|
109
|
+
),
|
|
110
|
+
jsonb_build_object(
|
|
111
|
+
'name', 'can_view',
|
|
112
|
+
'returns', 'boolean',
|
|
113
|
+
'body', E'\n -- genesis-sites stub: whoever may edit may view.\n select genesis_sites.can_edit(site_id)\n'
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
reserved_body constant text := E'\n -- genesis-sites stub: the app\'s paths a site page can\'t use.\n select array[''/api'', ''/_server'', ''/auth'', ''/_gs'']::text[]\n';
|
|
117
|
+
stub jsonb;
|
|
118
|
+
current_body text;
|
|
119
|
+
begin
|
|
120
|
+
for stub in select * from jsonb_array_elements(stubs) loop
|
|
121
|
+
select p.prosrc into current_body
|
|
122
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
123
|
+
where n.nspname = 'genesis_sites' and p.proname = stub ->> 'name'
|
|
124
|
+
and pg_get_function_identity_arguments(p.oid) = 'site_id uuid';
|
|
125
|
+
if current_body is null or current_body = stub ->> 'body' then
|
|
126
|
+
execute format(
|
|
127
|
+
'create or replace function genesis_sites.%I(site_id uuid) returns %s language sql stable set search_path = '''' as %L',
|
|
128
|
+
stub ->> 'name', stub ->> 'returns', stub ->> 'body'
|
|
129
|
+
);
|
|
130
|
+
-- By name, each time it writes a stub, so a hook this step had to create again
|
|
131
|
+
-- never keeps Postgres's default (execute for public).
|
|
132
|
+
execute format('revoke execute on function genesis_sites.%I(uuid) from public, anon', stub ->> 'name');
|
|
133
|
+
execute format('grant execute on function genesis_sites.%I(uuid) to authenticated, service_role', stub ->> 'name');
|
|
134
|
+
end if;
|
|
135
|
+
current_body := null;
|
|
136
|
+
end loop;
|
|
137
|
+
|
|
138
|
+
select p.prosrc into current_body
|
|
139
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
140
|
+
where n.nspname = 'genesis_sites' and p.proname = 'reserved_paths' and pg_get_function_identity_arguments(p.oid) = '';
|
|
141
|
+
if current_body is null or current_body = reserved_body then
|
|
142
|
+
execute format('create or replace function genesis_sites.reserved_paths() returns text[] language sql stable set search_path = '''' as %L', reserved_body);
|
|
143
|
+
revoke execute on function genesis_sites.reserved_paths() from public, anon;
|
|
144
|
+
grant execute on function genesis_sites.reserved_paths() to authenticated, service_role;
|
|
145
|
+
end if;
|
|
146
|
+
end;
|
|
147
|
+
$fn$;
|
|
148
|
+
|
|
149
|
+
revoke execute on function genesis_sites.ensure_hook_stubs() from public, anon, authenticated;
|
|
150
|
+
|
|
151
|
+
select genesis_sites.ensure_hook_stubs();
|
|
152
|
+
|
|
153
|
+
comment on function genesis_sites.can_edit(uuid) is
|
|
154
|
+
'May the signed-in person edit this site? Replace it in your own migration (same signature). The stub answers false. Genesis Sites.';
|
|
155
|
+
comment on function genesis_sites.can_view(uuid) is
|
|
156
|
+
'May the signed-in person read this site''s drafts and releases? Replace it in your own migration (same signature). The stub asks can_edit. Genesis Sites.';
|
|
157
|
+
comment on function genesis_sites.reserved_paths() is
|
|
158
|
+
'Path prefixes a page can''t use, because the site shares its hostname with the app. Replace it to add yours. Genesis Sites.';
|
|
159
|
+
|
|
160
|
+
-- ── Tables ────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
create table genesis_sites.sites (
|
|
163
|
+
id uuid primary key default gen_random_uuid(),
|
|
164
|
+
name text not null constraint sites_name_length check (char_length(btrim(name)) between 1 and 120),
|
|
165
|
+
slug text not null unique constraint sites_slug_format check (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' and char_length(slug) <= 63),
|
|
166
|
+
-- The site document (SiteDoc): theme, header, menu, footer, SEO defaults, redirects.
|
|
167
|
+
draft jsonb not null constraint sites_draft_shape check (jsonb_typeof(draft) = 'object' and octet_length(draft::text) <= 524288),
|
|
168
|
+
draft_rev integer not null default 1,
|
|
169
|
+
live_release_id uuid,
|
|
170
|
+
status text not null default 'draft' constraint sites_status check (status in ('draft', 'live', 'archived')),
|
|
171
|
+
created_at timestamptz not null default now(),
|
|
172
|
+
updated_at timestamptz not null default now()
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
comment on table genesis_sites.sites is 'A site: its draft site document and the release it serves. Genesis Sites.';
|
|
176
|
+
|
|
177
|
+
create table genesis_sites.site_pages (
|
|
178
|
+
id uuid primary key default gen_random_uuid(),
|
|
179
|
+
site_id uuid not null references genesis_sites.sites (id) on delete cascade,
|
|
180
|
+
path text not null constraint site_pages_path_format check (
|
|
181
|
+
char_length(path) <= 200 and path ~ '^/([a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*){0,7})?$'
|
|
182
|
+
),
|
|
183
|
+
kind text not null default 'page' constraint site_pages_kind check (kind in ('page', 'landing')),
|
|
184
|
+
-- The page document (PageDoc): its sections and SEO.
|
|
185
|
+
draft jsonb not null constraint site_pages_draft_shape check (jsonb_typeof(draft) = 'object' and octet_length(draft::text) <= 524288),
|
|
186
|
+
draft_rev integer not null default 1,
|
|
187
|
+
sort integer not null default 0,
|
|
188
|
+
deleted_at timestamptz,
|
|
189
|
+
created_at timestamptz not null default now(),
|
|
190
|
+
updated_at timestamptz not null default now()
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
create unique index site_pages_one_per_path on genesis_sites.site_pages (site_id, path) where deleted_at is null;
|
|
194
|
+
create index site_pages_site on genesis_sites.site_pages (site_id, sort);
|
|
195
|
+
|
|
196
|
+
comment on table genesis_sites.site_pages is 'A site''s pages and their drafts. A deleted page keeps its row (deleted_at). Genesis Sites.';
|
|
197
|
+
|
|
198
|
+
create table genesis_sites.site_releases (
|
|
199
|
+
id uuid primary key default gen_random_uuid(),
|
|
200
|
+
site_id uuid not null references genesis_sites.sites (id) on delete cascade,
|
|
201
|
+
number integer not null constraint site_releases_number check (number >= 1),
|
|
202
|
+
-- Everything the release serves: { schemaVersion, site, pages: [{ id, path, kind, doc }] }.
|
|
203
|
+
snapshot jsonb not null,
|
|
204
|
+
note text constraint site_releases_note_length check (char_length(note) <= 500),
|
|
205
|
+
brand_hash text constraint site_releases_brand_hash check (brand_hash ~ '^[0-9a-f]{1,64}$'),
|
|
206
|
+
created_by uuid references auth.users (id) on delete set null,
|
|
207
|
+
created_at timestamptz not null default now(),
|
|
208
|
+
published_at timestamptz,
|
|
209
|
+
unique (site_id, number),
|
|
210
|
+
unique (id, site_id)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
comment on table genesis_sites.site_releases is 'Immutable published snapshots of a site. The live site points at one. Genesis Sites.';
|
|
214
|
+
|
|
215
|
+
-- The live release is one of the site's own releases.
|
|
216
|
+
alter table genesis_sites.sites
|
|
217
|
+
add constraint sites_live_release foreign key (live_release_id, id) references genesis_sites.site_releases (id, site_id);
|
|
218
|
+
|
|
219
|
+
create table genesis_sites.site_assets (
|
|
220
|
+
id uuid primary key default gen_random_uuid(),
|
|
221
|
+
site_id uuid not null references genesis_sites.sites (id) on delete cascade,
|
|
222
|
+
-- The storage key: always under sites/<site_id>/.
|
|
223
|
+
path text not null unique constraint site_assets_path check (
|
|
224
|
+
char_length(path) <= 512 and starts_with(path, 'sites/' || site_id::text || '/') and path !~ '(^|/)\.\.?(/|$)' and path !~ '[\\[:cntrl:]]'
|
|
225
|
+
),
|
|
226
|
+
-- Images and PDFs. No SVG: an SVG can carry script.
|
|
227
|
+
mime text not null constraint site_assets_mime check (mime in ('image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'application/pdf')),
|
|
228
|
+
width integer constraint site_assets_width check (width between 1 and 20000),
|
|
229
|
+
height integer constraint site_assets_height check (height between 1 and 20000),
|
|
230
|
+
alt text constraint site_assets_alt check (char_length(alt) <= 300),
|
|
231
|
+
blurhash text constraint site_assets_blurhash check (char_length(blurhash) <= 100),
|
|
232
|
+
created_by uuid default auth.uid() references auth.users (id) on delete set null,
|
|
233
|
+
created_at timestamptz not null default now()
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
create index site_assets_site on genesis_sites.site_assets (site_id);
|
|
237
|
+
|
|
238
|
+
comment on table genesis_sites.site_assets is 'Files a site uses (the record of an upload under sites/<site_id>/). Genesis Sites.';
|
|
239
|
+
|
|
240
|
+
create or replace function genesis_sites.touch()
|
|
241
|
+
returns trigger
|
|
242
|
+
language plpgsql
|
|
243
|
+
set search_path = ''
|
|
244
|
+
as $$
|
|
245
|
+
begin
|
|
246
|
+
new.updated_at := now();
|
|
247
|
+
return new;
|
|
248
|
+
end;
|
|
249
|
+
$$;
|
|
250
|
+
|
|
251
|
+
create trigger genesis_touch before update on genesis_sites.sites for each row execute function genesis_sites.touch();
|
|
252
|
+
create trigger genesis_touch before update on genesis_sites.site_pages for each row execute function genesis_sites.touch();
|
|
253
|
+
|
|
254
|
+
-- A release never changes once written (rollback moves the site's pointer instead).
|
|
255
|
+
create or replace function genesis_sites.release_is_immutable()
|
|
256
|
+
returns trigger
|
|
257
|
+
language plpgsql
|
|
258
|
+
set search_path = ''
|
|
259
|
+
as $$
|
|
260
|
+
begin
|
|
261
|
+
raise exception 'genesis_sites: a release can''t be changed; publish a new one' using errcode = '42501';
|
|
262
|
+
end;
|
|
263
|
+
$$;
|
|
264
|
+
|
|
265
|
+
create trigger genesis_immutable before update on genesis_sites.site_releases for each row execute function genesis_sites.release_is_immutable();
|
|
266
|
+
|
|
267
|
+
-- What a site serves changed (publish, rollback, archive, unarchive): tell a listener to purge its cache.
|
|
268
|
+
create or replace function genesis_sites.notify_served_change()
|
|
269
|
+
returns trigger
|
|
270
|
+
language plpgsql
|
|
271
|
+
set search_path = ''
|
|
272
|
+
as $$
|
|
273
|
+
begin
|
|
274
|
+
perform pg_notify('genesis_sites', jsonb_build_object(
|
|
275
|
+
'site_id', new.id,
|
|
276
|
+
'release_id', new.live_release_id,
|
|
277
|
+
'status', new.status,
|
|
278
|
+
'action', case
|
|
279
|
+
when new.status = 'archived' then 'archive'
|
|
280
|
+
when old.status = 'archived' then 'unarchive'
|
|
281
|
+
when new.live_release_id is distinct from old.live_release_id
|
|
282
|
+
and (select r.number from genesis_sites.site_releases r where r.id = new.live_release_id)
|
|
283
|
+
< coalesce((select r.number from genesis_sites.site_releases r where r.id = old.live_release_id), 0)
|
|
284
|
+
then 'rollback'
|
|
285
|
+
else 'publish'
|
|
286
|
+
end
|
|
287
|
+
)::text);
|
|
288
|
+
return new;
|
|
289
|
+
end;
|
|
290
|
+
$$;
|
|
291
|
+
|
|
292
|
+
create trigger genesis_notify_served after update of status, live_release_id on genesis_sites.sites
|
|
293
|
+
for each row when (old.status is distinct from new.status or old.live_release_id is distinct from new.live_release_id)
|
|
294
|
+
execute function genesis_sites.notify_served_change();
|
|
295
|
+
|
|
296
|
+
-- ── Row level security ────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
alter table genesis_sites.sites enable row level security;
|
|
299
|
+
alter table genesis_sites.site_pages enable row level security;
|
|
300
|
+
alter table genesis_sites.site_releases enable row level security;
|
|
301
|
+
alter table genesis_sites.site_assets enable row level security;
|
|
302
|
+
|
|
303
|
+
create policy "viewers read the site" on genesis_sites.sites
|
|
304
|
+
for select to authenticated using (genesis_sites.can_view(id));
|
|
305
|
+
create policy "viewers read its pages" on genesis_sites.site_pages
|
|
306
|
+
for select to authenticated using (deleted_at is null and genesis_sites.can_view(site_id));
|
|
307
|
+
create policy "viewers read its releases" on genesis_sites.site_releases
|
|
308
|
+
for select to authenticated using (genesis_sites.can_view(site_id));
|
|
309
|
+
create policy "viewers read its assets" on genesis_sites.site_assets
|
|
310
|
+
for select to authenticated using (genesis_sites.can_view(site_id));
|
|
311
|
+
create policy "editors record uploads" on genesis_sites.site_assets
|
|
312
|
+
for insert to authenticated with check (genesis_sites.can_edit(site_id));
|
|
313
|
+
create policy "editors remove uploads" on genesis_sites.site_assets
|
|
314
|
+
for delete to authenticated using (genesis_sites.can_edit(site_id));
|
|
315
|
+
|
|
316
|
+
-- ── Helpers ───────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
create or replace function genesis_sites.assert_can_edit(site uuid)
|
|
319
|
+
returns void
|
|
320
|
+
language plpgsql
|
|
321
|
+
stable
|
|
322
|
+
security definer
|
|
323
|
+
set search_path = ''
|
|
324
|
+
as $$
|
|
325
|
+
begin
|
|
326
|
+
if auth.uid() is null then
|
|
327
|
+
raise exception 'genesis_sites: not signed in' using errcode = '42501';
|
|
328
|
+
end if;
|
|
329
|
+
if site is null or not coalesce(genesis_sites.can_edit(site), false) then
|
|
330
|
+
raise exception 'genesis_sites: you can''t edit this site' using errcode = '42501',
|
|
331
|
+
hint = 'genesis_sites.can_edit(uuid) decides; replace its stub in your own migration.';
|
|
332
|
+
end if;
|
|
333
|
+
end;
|
|
334
|
+
$$;
|
|
335
|
+
|
|
336
|
+
-- Not one of the app's reserved paths (the path itself or anything under it).
|
|
337
|
+
create or replace function genesis_sites.assert_not_reserved(path text)
|
|
338
|
+
returns void
|
|
339
|
+
language plpgsql
|
|
340
|
+
stable
|
|
341
|
+
security definer
|
|
342
|
+
set search_path = ''
|
|
343
|
+
as $$
|
|
344
|
+
declare
|
|
345
|
+
prefix text;
|
|
346
|
+
-- Like isReservedPath: lower case, no query or fragment, no trailing slash except on the root itself.
|
|
347
|
+
probe text := lower(split_part(split_part(coalesce(path, ''), '?', 1), '#', 1));
|
|
348
|
+
begin
|
|
349
|
+
if char_length(probe) > 1 then
|
|
350
|
+
probe := rtrim(probe, '/');
|
|
351
|
+
end if;
|
|
352
|
+
foreach prefix in array coalesce(genesis_sites.reserved_paths(), '{}'::text[]) loop
|
|
353
|
+
prefix := lower(prefix);
|
|
354
|
+
if char_length(prefix) > 1 then
|
|
355
|
+
prefix := rtrim(prefix, '/');
|
|
356
|
+
end if;
|
|
357
|
+
if prefix = '' then
|
|
358
|
+
continue;
|
|
359
|
+
end if;
|
|
360
|
+
-- A reserved "/" reserves the root only, as in isReservedPath.
|
|
361
|
+
if probe = prefix or (prefix <> '/' and starts_with(probe, prefix || '/')) then
|
|
362
|
+
raise exception 'genesis_sites: "%" is reserved for the app', path using errcode = '22023';
|
|
363
|
+
end if;
|
|
364
|
+
end loop;
|
|
365
|
+
end;
|
|
366
|
+
$$;
|
|
367
|
+
|
|
368
|
+
-- A page path's shape, and not one of the app's reserved paths.
|
|
369
|
+
create or replace function genesis_sites.assert_page_path(path text)
|
|
370
|
+
returns void
|
|
371
|
+
language plpgsql
|
|
372
|
+
stable
|
|
373
|
+
security definer
|
|
374
|
+
set search_path = ''
|
|
375
|
+
as $$
|
|
376
|
+
begin
|
|
377
|
+
if path is null or char_length(path) > 200 or path !~ '^/([a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*){0,7})?$' then
|
|
378
|
+
raise exception 'genesis_sites: "%" isn''t a page path (lowercase segments, like /about)', path using errcode = '22023';
|
|
379
|
+
end if;
|
|
380
|
+
perform genesis_sites.assert_not_reserved(path);
|
|
381
|
+
end;
|
|
382
|
+
$$;
|
|
383
|
+
|
|
384
|
+
-- Every redirect's `from` is a site path (an old address, any case) that isn't one of the app's
|
|
385
|
+
-- reserved paths: a redirect from /auth or a reserved prefix would take over the app's own route.
|
|
386
|
+
create or replace function genesis_sites.assert_redirects(doc jsonb)
|
|
387
|
+
returns void
|
|
388
|
+
language plpgsql
|
|
389
|
+
stable
|
|
390
|
+
security definer
|
|
391
|
+
set search_path = ''
|
|
392
|
+
as $$
|
|
393
|
+
declare
|
|
394
|
+
r jsonb;
|
|
395
|
+
from_path text;
|
|
396
|
+
begin
|
|
397
|
+
if doc -> 'redirects' is null or jsonb_typeof(doc -> 'redirects') = 'null' then
|
|
398
|
+
return;
|
|
399
|
+
end if;
|
|
400
|
+
if jsonb_typeof(doc -> 'redirects') <> 'array' then
|
|
401
|
+
raise exception 'genesis_sites: redirects must be a list' using errcode = '22023';
|
|
402
|
+
end if;
|
|
403
|
+
for r in select value from jsonb_array_elements(doc -> 'redirects') loop
|
|
404
|
+
from_path := r ->> 'from';
|
|
405
|
+
if from_path is null or char_length(from_path) > 500 or left(from_path, 1) <> '/' or left(from_path, 2) = '//' or from_path ~ '[[:space:][:cntrl:]\\]' then
|
|
406
|
+
raise exception 'genesis_sites: "%" isn''t a redirect source (a site path starting with /)', from_path using errcode = '22023';
|
|
407
|
+
end if;
|
|
408
|
+
perform genesis_sites.assert_not_reserved(from_path);
|
|
409
|
+
end loop;
|
|
410
|
+
end;
|
|
411
|
+
$$;
|
|
412
|
+
|
|
413
|
+
create or replace function genesis_sites.assert_doc(doc jsonb, what text)
|
|
414
|
+
returns void
|
|
415
|
+
language plpgsql
|
|
416
|
+
immutable
|
|
417
|
+
set search_path = ''
|
|
418
|
+
as $$
|
|
419
|
+
begin
|
|
420
|
+
if doc is null or jsonb_typeof(doc) <> 'object' or octet_length(doc::text) > 524288 then
|
|
421
|
+
raise exception 'genesis_sites: the % document must be a JSON object of at most 512 KiB', what using errcode = '22023';
|
|
422
|
+
end if;
|
|
423
|
+
if doc -> 'schemaVersion' is distinct from '1'::jsonb then
|
|
424
|
+
raise exception 'genesis_sites: the % document needs schemaVersion 1', what using errcode = '22023';
|
|
425
|
+
end if;
|
|
426
|
+
if what = 'page' and (jsonb_typeof(doc -> 'content') is distinct from 'array' or jsonb_typeof(doc #> '{root,props,title}') is distinct from 'string') then
|
|
427
|
+
raise exception 'genesis_sites: a page document needs root.props.title and a content list' using errcode = '22023';
|
|
428
|
+
end if;
|
|
429
|
+
if what = 'site' and jsonb_typeof(doc -> 'name') is distinct from 'string' then
|
|
430
|
+
raise exception 'genesis_sites: a site document needs a name' using errcode = '22023';
|
|
431
|
+
end if;
|
|
432
|
+
end;
|
|
433
|
+
$$;
|
|
434
|
+
|
|
435
|
+
create or replace function genesis_sites.release_json(r genesis_sites.site_releases, with_snapshot boolean)
|
|
436
|
+
returns jsonb
|
|
437
|
+
language sql
|
|
438
|
+
stable
|
|
439
|
+
set search_path = ''
|
|
440
|
+
as $$
|
|
441
|
+
select jsonb_build_object(
|
|
442
|
+
'id', r.id, 'siteId', r.site_id, 'number', r.number, 'note', r.note,
|
|
443
|
+
'createdAt', r.created_at, 'publishedAt', r.published_at, 'brandHash', r.brand_hash
|
|
444
|
+
) || case when with_snapshot then jsonb_build_object('snapshot', r.snapshot) else '{}'::jsonb end
|
|
445
|
+
$$;
|
|
446
|
+
|
|
447
|
+
-- ── Writes (security definer, each asking can_edit) ───────────────────────────
|
|
448
|
+
|
|
449
|
+
-- Save a page's draft if nobody saved since `expected_rev`. Returns the new
|
|
450
|
+
-- revision; raises GS409 (with the current revision in DETAIL) when someone did.
|
|
451
|
+
create or replace function genesis_sites.save_page_draft(page_id uuid, expected_rev integer, doc jsonb)
|
|
452
|
+
returns integer
|
|
453
|
+
language plpgsql
|
|
454
|
+
security definer
|
|
455
|
+
set search_path = ''
|
|
456
|
+
as $$
|
|
457
|
+
declare
|
|
458
|
+
page genesis_sites.site_pages;
|
|
459
|
+
begin
|
|
460
|
+
-- can_edit first, then the row lock: someone who can't edit learns nothing and locks nothing.
|
|
461
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
|
|
462
|
+
select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
|
|
463
|
+
if not found then
|
|
464
|
+
raise exception 'genesis_sites: no page %', page_id using errcode = 'P0002';
|
|
465
|
+
end if;
|
|
466
|
+
-- The app may have reserved this page's path since the page was made (genesis_sites.reserved_paths()).
|
|
467
|
+
perform genesis_sites.assert_page_path(page.path);
|
|
468
|
+
perform genesis_sites.assert_doc(doc, 'page');
|
|
469
|
+
if page.draft_rev is distinct from expected_rev then
|
|
470
|
+
raise exception 'genesis_sites: someone saved this page first' using errcode = 'GS409', detail = page.draft_rev::text;
|
|
471
|
+
end if;
|
|
472
|
+
update genesis_sites.site_pages p set draft = doc, draft_rev = p.draft_rev + 1 where p.id = page_id;
|
|
473
|
+
return page.draft_rev + 1;
|
|
474
|
+
end;
|
|
475
|
+
$$;
|
|
476
|
+
|
|
477
|
+
create or replace function genesis_sites.save_site_draft(site_id uuid, expected_rev integer, doc jsonb)
|
|
478
|
+
returns integer
|
|
479
|
+
language plpgsql
|
|
480
|
+
security definer
|
|
481
|
+
set search_path = ''
|
|
482
|
+
as $$
|
|
483
|
+
declare
|
|
484
|
+
site genesis_sites.sites;
|
|
485
|
+
begin
|
|
486
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
487
|
+
select * into site from genesis_sites.sites s where s.id = save_site_draft.site_id for update;
|
|
488
|
+
if not found then
|
|
489
|
+
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
490
|
+
end if;
|
|
491
|
+
perform genesis_sites.assert_doc(doc, 'site');
|
|
492
|
+
perform genesis_sites.assert_redirects(doc);
|
|
493
|
+
if site.draft_rev is distinct from expected_rev then
|
|
494
|
+
raise exception 'genesis_sites: someone saved this site first' using errcode = 'GS409', detail = site.draft_rev::text;
|
|
495
|
+
end if;
|
|
496
|
+
update genesis_sites.sites s set draft = doc, draft_rev = s.draft_rev + 1 where s.id = save_site_draft.site_id;
|
|
497
|
+
return site.draft_rev + 1;
|
|
498
|
+
end;
|
|
499
|
+
$$;
|
|
500
|
+
|
|
501
|
+
-- Drop the redirect away from `path` (a page lives there now), bumping the draft's revision if there was one.
|
|
502
|
+
create or replace function genesis_sites.drop_redirect_from(site uuid, path text)
|
|
503
|
+
returns void
|
|
504
|
+
language plpgsql
|
|
505
|
+
security definer
|
|
506
|
+
set search_path = ''
|
|
507
|
+
as $$
|
|
508
|
+
begin
|
|
509
|
+
update genesis_sites.sites s
|
|
510
|
+
set draft = jsonb_set(s.draft, '{redirects}', coalesce((
|
|
511
|
+
select jsonb_agg(r order by i) from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) with ordinality as e(r, i)
|
|
512
|
+
where r ->> 'from' is distinct from path), '[]'::jsonb)),
|
|
513
|
+
draft_rev = s.draft_rev + 1
|
|
514
|
+
where s.id = site and exists (select 1 from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) r where r ->> 'from' = path);
|
|
515
|
+
end;
|
|
516
|
+
$$;
|
|
517
|
+
|
|
518
|
+
create or replace function genesis_sites.create_page(site_id uuid, path text, kind text, doc jsonb)
|
|
519
|
+
returns uuid
|
|
520
|
+
language plpgsql
|
|
521
|
+
security definer
|
|
522
|
+
set search_path = ''
|
|
523
|
+
as $$
|
|
524
|
+
declare
|
|
525
|
+
new_id uuid;
|
|
526
|
+
begin
|
|
527
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
528
|
+
perform genesis_sites.assert_page_path(path);
|
|
529
|
+
perform genesis_sites.assert_doc(doc, 'page');
|
|
530
|
+
perform 1 from genesis_sites.sites s where s.id = create_page.site_id for update;
|
|
531
|
+
if not found then
|
|
532
|
+
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
533
|
+
end if;
|
|
534
|
+
if exists (select 1 from genesis_sites.site_pages p where p.site_id = create_page.site_id and p.path = create_page.path and p.deleted_at is null) then
|
|
535
|
+
raise exception 'genesis_sites: a page is already at %', path using errcode = '23505';
|
|
536
|
+
end if;
|
|
537
|
+
insert into genesis_sites.site_pages (site_id, path, kind, draft, sort)
|
|
538
|
+
values (site_id, path, coalesce(kind, 'page'), doc,
|
|
539
|
+
coalesce((select max(p.sort) + 1 from genesis_sites.site_pages p where p.site_id = create_page.site_id and p.deleted_at is null), 0))
|
|
540
|
+
returning id into new_id;
|
|
541
|
+
perform genesis_sites.drop_redirect_from(site_id, path);
|
|
542
|
+
return new_id;
|
|
543
|
+
end;
|
|
544
|
+
$$;
|
|
545
|
+
|
|
546
|
+
-- Move a page; the old path redirects to the new one (301), with no chains.
|
|
547
|
+
create or replace function genesis_sites.move_page(page_id uuid, new_path text)
|
|
548
|
+
returns void
|
|
549
|
+
language plpgsql
|
|
550
|
+
security definer
|
|
551
|
+
set search_path = ''
|
|
552
|
+
as $$
|
|
553
|
+
declare
|
|
554
|
+
page genesis_sites.site_pages;
|
|
555
|
+
begin
|
|
556
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
|
|
557
|
+
-- The site's row first, then the page's: the order publish_site locks them in, so the two never deadlock.
|
|
558
|
+
perform 1 from genesis_sites.sites s where s.id = (select p.site_id from genesis_sites.site_pages p where p.id = page_id) for update;
|
|
559
|
+
select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
|
|
560
|
+
if not found then
|
|
561
|
+
raise exception 'genesis_sites: no page %', page_id using errcode = 'P0002';
|
|
562
|
+
end if;
|
|
563
|
+
perform genesis_sites.assert_page_path(new_path);
|
|
564
|
+
if page.path = '/' then
|
|
565
|
+
raise exception 'genesis_sites: the home page stays at /' using errcode = '22023';
|
|
566
|
+
end if;
|
|
567
|
+
if page.path = new_path then
|
|
568
|
+
return;
|
|
569
|
+
end if;
|
|
570
|
+
if exists (select 1 from genesis_sites.site_pages p where p.site_id = page.site_id and p.path = new_path and p.deleted_at is null) then
|
|
571
|
+
raise exception 'genesis_sites: a page is already at %', new_path using errcode = '23505';
|
|
572
|
+
end if;
|
|
573
|
+
update genesis_sites.site_pages p set path = new_path, draft_rev = p.draft_rev + 1 where p.id = page_id;
|
|
574
|
+
update genesis_sites.sites s
|
|
575
|
+
set draft = jsonb_set(s.draft, '{redirects}', coalesce((
|
|
576
|
+
select jsonb_agg(r order by i) from (
|
|
577
|
+
select case when e.r ->> 'to' = page.path then jsonb_set(e.r, '{to}', to_jsonb(new_path)) else e.r end as r, e.i
|
|
578
|
+
from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) with ordinality as e(r, i)
|
|
579
|
+
where e.r ->> 'from' is distinct from new_path and e.r ->> 'from' is distinct from page.path
|
|
580
|
+
) m
|
|
581
|
+
where m.r ->> 'from' is distinct from m.r ->> 'to'), '[]'::jsonb)
|
|
582
|
+
|| jsonb_build_array(jsonb_build_object('from', page.path, 'to', new_path, 'status', 301))),
|
|
583
|
+
draft_rev = s.draft_rev + 1
|
|
584
|
+
where s.id = page.site_id;
|
|
585
|
+
end;
|
|
586
|
+
$$;
|
|
587
|
+
|
|
588
|
+
create or replace function genesis_sites.delete_page(page_id uuid)
|
|
589
|
+
returns void
|
|
590
|
+
language plpgsql
|
|
591
|
+
security definer
|
|
592
|
+
set search_path = ''
|
|
593
|
+
as $$
|
|
594
|
+
declare
|
|
595
|
+
page genesis_sites.site_pages;
|
|
596
|
+
begin
|
|
597
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
|
|
598
|
+
select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
|
|
599
|
+
if not found then
|
|
600
|
+
return;
|
|
601
|
+
end if;
|
|
602
|
+
if page.path = '/' then
|
|
603
|
+
raise exception 'genesis_sites: the home page can''t be deleted' using errcode = '22023';
|
|
604
|
+
end if;
|
|
605
|
+
update genesis_sites.site_pages p set deleted_at = now() where p.id = page_id;
|
|
606
|
+
end;
|
|
607
|
+
$$;
|
|
608
|
+
|
|
609
|
+
-- A list of section nodes without the hidden ones: a release never carries a hidden section.
|
|
610
|
+
create or replace function genesis_sites.visible_nodes(nodes jsonb)
|
|
611
|
+
returns jsonb
|
|
612
|
+
language sql
|
|
613
|
+
immutable
|
|
614
|
+
set search_path = ''
|
|
615
|
+
as $$
|
|
616
|
+
select coalesce(jsonb_agg(n order by i), '[]'::jsonb)
|
|
617
|
+
from jsonb_array_elements(case when jsonb_typeof(nodes) = 'array' then nodes else '[]'::jsonb end) with ordinality as e(n, i)
|
|
618
|
+
where coalesce(n ->> 'hidden', 'false') <> 'true'
|
|
619
|
+
$$;
|
|
620
|
+
|
|
621
|
+
-- Publish: the drafts exactly as the caller checked them (`expected_site_rev`
|
|
622
|
+
-- and `expected_pages`, `{ "<page id>": rev }` for every page) become a new
|
|
623
|
+
-- release, and the live site points at it. Raises GS409 if anything changed
|
|
624
|
+
-- since the check, so what goes live is what was checked.
|
|
625
|
+
create or replace function genesis_sites.publish_site(site_id uuid, expected_site_rev integer, expected_pages jsonb, note text default null, brand_hash text default null)
|
|
626
|
+
returns jsonb
|
|
627
|
+
language plpgsql
|
|
628
|
+
security definer
|
|
629
|
+
set search_path = ''
|
|
630
|
+
as $$
|
|
631
|
+
declare
|
|
632
|
+
site genesis_sites.sites;
|
|
633
|
+
release genesis_sites.site_releases;
|
|
634
|
+
page record;
|
|
635
|
+
page_count integer := 0;
|
|
636
|
+
begin
|
|
637
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
638
|
+
select * into site from genesis_sites.sites s where s.id = publish_site.site_id for update;
|
|
639
|
+
if not found then
|
|
640
|
+
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
641
|
+
end if;
|
|
642
|
+
if site.status = 'archived' then
|
|
643
|
+
raise exception 'genesis_sites: an archived site can''t be published' using errcode = '22023';
|
|
644
|
+
end if;
|
|
645
|
+
perform genesis_sites.assert_doc(site.draft, 'site');
|
|
646
|
+
perform genesis_sites.assert_redirects(site.draft);
|
|
647
|
+
if site.draft_rev is distinct from expected_site_rev then
|
|
648
|
+
raise exception 'genesis_sites: the site changed since it was checked' using errcode = 'GS409', detail = site.draft_rev::text;
|
|
649
|
+
end if;
|
|
650
|
+
if expected_pages is null or jsonb_typeof(expected_pages) <> 'object' then
|
|
651
|
+
raise exception 'genesis_sites: expected_pages is { "<page id>": rev } for every page' using errcode = '22023';
|
|
652
|
+
end if;
|
|
653
|
+
for page in select p.id, p.path, p.draft, p.draft_rev from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.deleted_at is null for update loop
|
|
654
|
+
page_count := page_count + 1;
|
|
655
|
+
if (expected_pages ->> page.id::text) is distinct from page.draft_rev::text then
|
|
656
|
+
raise exception 'genesis_sites: the page at % changed since it was checked', page.path using errcode = 'GS409';
|
|
657
|
+
end if;
|
|
658
|
+
perform genesis_sites.assert_page_path(page.path);
|
|
659
|
+
perform genesis_sites.assert_doc(page.draft, 'page');
|
|
660
|
+
end loop;
|
|
661
|
+
if page_count <> (select count(*) from jsonb_object_keys(expected_pages)) then
|
|
662
|
+
raise exception 'genesis_sites: the pages changed since they were checked' using errcode = 'GS409';
|
|
663
|
+
end if;
|
|
664
|
+
if not exists (select 1 from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.path = '/' and p.deleted_at is null) then
|
|
665
|
+
raise exception 'genesis_sites: the site has no home page (/)' using errcode = '22023';
|
|
666
|
+
end if;
|
|
667
|
+
if note is not null and char_length(note) > 500 then
|
|
668
|
+
raise exception 'genesis_sites: a release note has at most 500 characters' using errcode = '22023';
|
|
669
|
+
end if;
|
|
670
|
+
|
|
671
|
+
insert into genesis_sites.site_releases (site_id, number, snapshot, note, brand_hash, created_by, published_at)
|
|
672
|
+
values (
|
|
673
|
+
site_id,
|
|
674
|
+
coalesce((select max(r.number) from genesis_sites.site_releases r where r.site_id = publish_site.site_id), 0) + 1,
|
|
675
|
+
jsonb_build_object(
|
|
676
|
+
'schemaVersion', 1,
|
|
677
|
+
'site', site.draft
|
|
678
|
+
|| jsonb_build_object('header', genesis_sites.visible_nodes(site.draft -> 'header'), 'footer', genesis_sites.visible_nodes(site.draft -> 'footer')),
|
|
679
|
+
'pages', coalesce((
|
|
680
|
+
select jsonb_agg(jsonb_build_object('id', p.id, 'path', p.path, 'kind', p.kind,
|
|
681
|
+
'doc', p.draft || jsonb_build_object('content', genesis_sites.visible_nodes(p.draft -> 'content')))
|
|
682
|
+
order by p.sort, p.path)
|
|
683
|
+
from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.deleted_at is null), '[]'::jsonb)
|
|
684
|
+
),
|
|
685
|
+
nullif(btrim(note), ''),
|
|
686
|
+
brand_hash,
|
|
687
|
+
(select u.id from auth.users u where u.id = auth.uid()),
|
|
688
|
+
now()
|
|
689
|
+
)
|
|
690
|
+
returning * into release;
|
|
691
|
+
update genesis_sites.sites s set live_release_id = release.id, status = 'live' where s.id = publish_site.site_id;
|
|
692
|
+
return genesis_sites.release_json(release, false);
|
|
693
|
+
end;
|
|
694
|
+
$$;
|
|
695
|
+
|
|
696
|
+
-- Point the live site at an earlier release.
|
|
697
|
+
create or replace function genesis_sites.rollback_site(site_id uuid, release_id uuid)
|
|
698
|
+
returns jsonb
|
|
699
|
+
language plpgsql
|
|
700
|
+
security definer
|
|
701
|
+
set search_path = ''
|
|
702
|
+
as $$
|
|
703
|
+
declare
|
|
704
|
+
release genesis_sites.site_releases;
|
|
705
|
+
current_status text;
|
|
706
|
+
begin
|
|
707
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
708
|
+
select s.status into current_status from genesis_sites.sites s where s.id = rollback_site.site_id for update;
|
|
709
|
+
if not found then
|
|
710
|
+
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
711
|
+
end if;
|
|
712
|
+
-- Archiving is a takedown: an editor can't bring the site back by rolling back.
|
|
713
|
+
if current_status = 'archived' then
|
|
714
|
+
raise exception 'genesis_sites: an archived site can''t be rolled back' using errcode = '22023';
|
|
715
|
+
end if;
|
|
716
|
+
select * into release from genesis_sites.site_releases r where r.id = release_id and r.site_id = rollback_site.site_id;
|
|
717
|
+
if not found then
|
|
718
|
+
raise exception 'genesis_sites: release % isn''t one of this site''s', release_id using errcode = 'P0002';
|
|
719
|
+
end if;
|
|
720
|
+
update genesis_sites.sites s set live_release_id = release.id, status = 'live' where s.id = rollback_site.site_id;
|
|
721
|
+
return genesis_sites.release_json(release, false);
|
|
722
|
+
end;
|
|
723
|
+
$$;
|
|
724
|
+
|
|
725
|
+
-- Create a site with its pages (the service role: a seed, a server job, your own function).
|
|
726
|
+
create or replace function genesis_sites.create_site(name text, slug text, draft jsonb, pages jsonb)
|
|
727
|
+
returns uuid
|
|
728
|
+
language plpgsql
|
|
729
|
+
security definer
|
|
730
|
+
set search_path = ''
|
|
731
|
+
as $$
|
|
732
|
+
declare
|
|
733
|
+
new_id uuid;
|
|
734
|
+
page jsonb;
|
|
735
|
+
i integer := 0;
|
|
736
|
+
begin
|
|
737
|
+
perform genesis_sites.assert_doc(draft, 'site');
|
|
738
|
+
if pages is null or jsonb_typeof(pages) <> 'array' or not exists (select 1 from jsonb_array_elements(pages) p where p ->> 'path' = '/') then
|
|
739
|
+
raise exception 'genesis_sites: a site starts with its pages, including / ' using errcode = '22023';
|
|
740
|
+
end if;
|
|
741
|
+
insert into genesis_sites.sites (name, slug, draft) values (name, slug, draft) returning id into new_id;
|
|
742
|
+
for page in select * from jsonb_array_elements(pages) loop
|
|
743
|
+
perform genesis_sites.assert_page_path(page ->> 'path');
|
|
744
|
+
perform genesis_sites.assert_doc(page -> 'doc', 'page');
|
|
745
|
+
insert into genesis_sites.site_pages (site_id, path, kind, draft, sort)
|
|
746
|
+
values (new_id, page ->> 'path', coalesce(page ->> 'kind', 'page'), page -> 'doc', i);
|
|
747
|
+
i := i + 1;
|
|
748
|
+
end loop;
|
|
749
|
+
return new_id;
|
|
750
|
+
end;
|
|
751
|
+
$$;
|
|
752
|
+
|
|
753
|
+
-- ── The public read ───────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
-- A live site's live release, for anyone (no session needed). Drafts never,
|
|
756
|
+
-- and not the release note, which is for the site's editors.
|
|
757
|
+
create or replace function genesis_sites.live_release(site_id uuid)
|
|
758
|
+
returns jsonb
|
|
759
|
+
language sql
|
|
760
|
+
stable
|
|
761
|
+
security definer
|
|
762
|
+
set search_path = ''
|
|
763
|
+
as $$
|
|
764
|
+
select genesis_sites.release_json(r, true) || jsonb_build_object('note', null)
|
|
765
|
+
from genesis_sites.sites s
|
|
766
|
+
join genesis_sites.site_releases r on r.id = s.live_release_id and r.site_id = s.id
|
|
767
|
+
where s.id = live_release.site_id and s.status = 'live'
|
|
768
|
+
$$;
|
|
769
|
+
|
|
770
|
+
comment on function genesis_sites.live_release(uuid) is
|
|
771
|
+
'The live release of a live site (its snapshot), or null. Public: anon may call it. Never returns drafts. Genesis Sites.';
|
|
772
|
+
|
|
773
|
+
-- ── Privileges, by name ───────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
revoke all on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets from public, anon, authenticated;
|
|
776
|
+
grant select on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets to authenticated;
|
|
777
|
+
grant insert (site_id, path, mime, width, height, alt, blurhash), delete on table genesis_sites.site_assets to authenticated;
|
|
778
|
+
revoke all on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets from service_role;
|
|
779
|
+
grant select, insert, update, delete on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_assets to service_role;
|
|
780
|
+
-- Releases: the service role reads and purges them (a live one is protected by its foreign key), never rewrites them.
|
|
781
|
+
grant select, insert, delete on table genesis_sites.site_releases to service_role;
|
|
782
|
+
|
|
783
|
+
-- The hooks: the policies ask them as the signed-in person.
|
|
784
|
+
revoke execute on function genesis_sites.can_edit(uuid) from public, anon;
|
|
785
|
+
grant execute on function genesis_sites.can_edit(uuid) to authenticated, service_role;
|
|
786
|
+
revoke execute on function genesis_sites.can_view(uuid) from public, anon;
|
|
787
|
+
grant execute on function genesis_sites.can_view(uuid) to authenticated, service_role;
|
|
788
|
+
revoke execute on function genesis_sites.reserved_paths() from public, anon;
|
|
789
|
+
grant execute on function genesis_sites.reserved_paths() to authenticated, service_role;
|
|
790
|
+
|
|
791
|
+
revoke execute on function genesis_sites.save_page_draft(uuid, integer, jsonb) from public, anon;
|
|
792
|
+
grant execute on function genesis_sites.save_page_draft(uuid, integer, jsonb) to authenticated;
|
|
793
|
+
revoke execute on function genesis_sites.save_site_draft(uuid, integer, jsonb) from public, anon;
|
|
794
|
+
grant execute on function genesis_sites.save_site_draft(uuid, integer, jsonb) to authenticated;
|
|
795
|
+
revoke execute on function genesis_sites.create_page(uuid, text, text, jsonb) from public, anon;
|
|
796
|
+
grant execute on function genesis_sites.create_page(uuid, text, text, jsonb) to authenticated;
|
|
797
|
+
revoke execute on function genesis_sites.move_page(uuid, text) from public, anon;
|
|
798
|
+
grant execute on function genesis_sites.move_page(uuid, text) to authenticated;
|
|
799
|
+
revoke execute on function genesis_sites.delete_page(uuid) from public, anon;
|
|
800
|
+
grant execute on function genesis_sites.delete_page(uuid) to authenticated;
|
|
801
|
+
revoke execute on function genesis_sites.publish_site(uuid, integer, jsonb, text, text) from public, anon;
|
|
802
|
+
grant execute on function genesis_sites.publish_site(uuid, integer, jsonb, text, text) to authenticated;
|
|
803
|
+
revoke execute on function genesis_sites.rollback_site(uuid, uuid) from public, anon;
|
|
804
|
+
grant execute on function genesis_sites.rollback_site(uuid, uuid) to authenticated;
|
|
805
|
+
revoke execute on function genesis_sites.create_site(text, text, jsonb, jsonb) from public, anon, authenticated;
|
|
806
|
+
grant execute on function genesis_sites.create_site(text, text, jsonb, jsonb) to service_role;
|
|
807
|
+
revoke execute on function genesis_sites.live_release(uuid) from public;
|
|
808
|
+
grant execute on function genesis_sites.live_release(uuid) to anon, authenticated, service_role;
|
|
809
|
+
|
|
810
|
+
-- Internal: only the functions above call these.
|
|
811
|
+
revoke execute on function genesis_sites.assert_can_edit(uuid) from public, anon, authenticated;
|
|
812
|
+
revoke execute on function genesis_sites.assert_page_path(text) from public, anon, authenticated;
|
|
813
|
+
revoke execute on function genesis_sites.assert_not_reserved(text) from public, anon, authenticated;
|
|
814
|
+
revoke execute on function genesis_sites.assert_redirects(jsonb) from public, anon, authenticated;
|
|
815
|
+
revoke execute on function genesis_sites.assert_doc(jsonb, text) from public, anon, authenticated;
|
|
816
|
+
revoke execute on function genesis_sites.drop_redirect_from(uuid, text) from public, anon, authenticated;
|
|
817
|
+
revoke execute on function genesis_sites.release_json(genesis_sites.site_releases, boolean) from public, anon, authenticated;
|
|
818
|
+
revoke execute on function genesis_sites.touch() from public, anon, authenticated;
|
|
819
|
+
revoke execute on function genesis_sites.release_is_immutable() from public, anon, authenticated;
|
|
820
|
+
revoke execute on function genesis_sites.notify_served_change() from public, anon, authenticated;
|
|
821
|
+
revoke execute on function genesis_sites.visible_nodes(jsonb) from public, anon, authenticated;
|