@marktiderman/genesis-sites 0.1.0 → 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/README.md +152 -8
- package/dist/assets/islands.77d11f5697.js +1 -0
- package/dist/assets/sites-forms.4e6aa42497.css +10 -0
- package/dist/assets/sites-forms.css +1 -1
- package/dist/assets/sites.css +1 -1
- package/dist/assets/sites.e1ffd2708b.css +2 -0
- package/dist/blocks.d.ts +1 -1
- package/dist/blocks.js +5 -1
- package/dist/blocks.js.map +1 -1
- 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-GA7U3CIN.js → chunk-IXMY4B56.js} +229 -10
- 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-KEF3V5ZN.js → chunk-ZGWGQGT2.js} +169 -73
- 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 +40 -7
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/{registry-CdgVymsu.d.ts → registry-D_DCWRTc.d.ts} +274 -8
- package/dist/sections.d.ts +422 -6
- package/dist/sections.js +4 -2
- package/dist/{store-BzGw2i5J.d.ts → store-DkSycDhq.d.ts} +119 -53
- package/dist/supabase.d.ts +5 -5
- package/dist/supabase.js +115 -6
- package/dist/supabase.js.map +1 -1
- package/dist/web.d.ts +26 -8
- package/dist/web.js +142 -18
- package/dist/web.js.map +1 -1
- package/migrations/0001_genesis_sites.sql +64 -6
- package/migrations/0002_genesis_sites_posts.sql +404 -0
- package/migrations/tests/0001_genesis_sites.test.sql +30 -1
- package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
- package/package.json +18 -10
- package/dist/assets/islands.230b20b9c4.js +0 -1
- package/dist/assets/sites-forms.5e6ebf31c3.css +0 -10
- package/dist/assets/sites.b28b796c6f.css +0 -2
- package/dist/chunk-43XUAO63.js +0 -1255
- package/dist/chunk-43XUAO63.js.map +0 -1
- package/dist/chunk-GA7U3CIN.js.map +0 -1
- package/dist/chunk-KEF3V5ZN.js.map +0 -1
|
@@ -333,8 +333,8 @@ begin
|
|
|
333
333
|
end;
|
|
334
334
|
$$;
|
|
335
335
|
|
|
336
|
-
--
|
|
337
|
-
create or replace function genesis_sites.
|
|
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
338
|
returns void
|
|
339
339
|
language plpgsql
|
|
340
340
|
stable
|
|
@@ -343,19 +343,73 @@ set search_path = ''
|
|
|
343
343
|
as $$
|
|
344
344
|
declare
|
|
345
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));
|
|
346
348
|
begin
|
|
347
|
-
if
|
|
348
|
-
|
|
349
|
+
if char_length(probe) > 1 then
|
|
350
|
+
probe := rtrim(probe, '/');
|
|
349
351
|
end if;
|
|
350
352
|
foreach prefix in array coalesce(genesis_sites.reserved_paths(), '{}'::text[]) loop
|
|
351
|
-
prefix :=
|
|
352
|
-
if
|
|
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
|
|
353
362
|
raise exception 'genesis_sites: "%" is reserved for the app', path using errcode = '22023';
|
|
354
363
|
end if;
|
|
355
364
|
end loop;
|
|
356
365
|
end;
|
|
357
366
|
$$;
|
|
358
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
|
+
|
|
359
413
|
create or replace function genesis_sites.assert_doc(doc jsonb, what text)
|
|
360
414
|
returns void
|
|
361
415
|
language plpgsql
|
|
@@ -435,6 +489,7 @@ begin
|
|
|
435
489
|
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
436
490
|
end if;
|
|
437
491
|
perform genesis_sites.assert_doc(doc, 'site');
|
|
492
|
+
perform genesis_sites.assert_redirects(doc);
|
|
438
493
|
if site.draft_rev is distinct from expected_rev then
|
|
439
494
|
raise exception 'genesis_sites: someone saved this site first' using errcode = 'GS409', detail = site.draft_rev::text;
|
|
440
495
|
end if;
|
|
@@ -588,6 +643,7 @@ begin
|
|
|
588
643
|
raise exception 'genesis_sites: an archived site can''t be published' using errcode = '22023';
|
|
589
644
|
end if;
|
|
590
645
|
perform genesis_sites.assert_doc(site.draft, 'site');
|
|
646
|
+
perform genesis_sites.assert_redirects(site.draft);
|
|
591
647
|
if site.draft_rev is distinct from expected_site_rev then
|
|
592
648
|
raise exception 'genesis_sites: the site changed since it was checked' using errcode = 'GS409', detail = site.draft_rev::text;
|
|
593
649
|
end if;
|
|
@@ -754,6 +810,8 @@ grant execute on function genesis_sites.live_release(uuid) to anon, authenticate
|
|
|
754
810
|
-- Internal: only the functions above call these.
|
|
755
811
|
revoke execute on function genesis_sites.assert_can_edit(uuid) from public, anon, authenticated;
|
|
756
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;
|
|
757
815
|
revoke execute on function genesis_sites.assert_doc(jsonb, text) from public, anon, authenticated;
|
|
758
816
|
revoke execute on function genesis_sites.drop_redirect_from(uuid, text) from public, anon, authenticated;
|
|
759
817
|
revoke execute on function genesis_sites.release_json(genesis_sites.site_releases, boolean) from public, anon, authenticated;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
-- Genesis Sites 0002: blog posts (site_posts) and the post page
|
|
2
|
+
--
|
|
3
|
+
-- A post is its own document beside the pages, with a draft and a published
|
|
4
|
+
-- copy. publish_post copies the draft (at the revision the editor checked)
|
|
5
|
+
-- to the published copy; that copy is what visitors read, through
|
|
6
|
+
-- genesis_sites.live_posts and live_post, so a post goes live (and comes
|
|
7
|
+
-- down) on its own, without a new release of the site. A site's rollback
|
|
8
|
+
-- doesn't touch its posts.
|
|
9
|
+
--
|
|
10
|
+
-- The site's `post` page (a new page kind, at most one per site and never
|
|
11
|
+
-- the home page) is the template every post renders in: at its own path it
|
|
12
|
+
-- is the blog's index, at <path>/<slug> it shows that post.
|
|
13
|
+
--
|
|
14
|
+
-- Access is the same seam as 0001: reads where genesis_sites.can_view says
|
|
15
|
+
-- so, writes through the security definer functions below, each asking
|
|
16
|
+
-- genesis_sites.can_edit. Anon reads only published posts of a live site.
|
|
17
|
+
-- The checks a post passes before it is published (alt text, headings) run
|
|
18
|
+
-- in TypeScript (`checkPost`, in the stores); the database checks shape,
|
|
19
|
+
-- size, slugs and revisions.
|
|
20
|
+
--
|
|
21
|
+
-- This migration needs 0001. It creates no hook; it runs
|
|
22
|
+
-- genesis_sites.ensure_hook_stubs() so a hook is there, and never replaces
|
|
23
|
+
-- an app's own version.
|
|
24
|
+
--
|
|
25
|
+
-- Use: `genesis add sites` copies this file into supabase/migrations under a
|
|
26
|
+
-- new timestamped name. Never edit it once applied; change it with a new migration.
|
|
27
|
+
--
|
|
28
|
+
-- Verify: the pgTAP test beside this file (`tests/0002_genesis_sites_posts.test.sql`).
|
|
29
|
+
|
|
30
|
+
select genesis_sites.ensure_hook_stubs();
|
|
31
|
+
|
|
32
|
+
-- ── The post page ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
alter table genesis_sites.site_pages drop constraint site_pages_kind;
|
|
35
|
+
alter table genesis_sites.site_pages
|
|
36
|
+
add constraint site_pages_kind check (kind in ('page', 'landing', 'post')),
|
|
37
|
+
-- Posts live under the post page's path; at / they would take over the site's paths.
|
|
38
|
+
add constraint site_pages_post_not_home check (kind <> 'post' or path <> '/');
|
|
39
|
+
|
|
40
|
+
-- One post page per site: it is where every post renders.
|
|
41
|
+
create unique index site_pages_one_post_page on genesis_sites.site_pages (site_id) where kind = 'post' and deleted_at is null;
|
|
42
|
+
|
|
43
|
+
-- ── Posts ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
create table genesis_sites.site_posts (
|
|
46
|
+
id uuid primary key default gen_random_uuid(),
|
|
47
|
+
site_id uuid not null references genesis_sites.sites (id) on delete cascade,
|
|
48
|
+
slug text not null constraint site_posts_slug_format check (char_length(slug) <= 80 and slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
|
49
|
+
-- The post document (PostDoc): title, excerpt, body, cover, author, SEO.
|
|
50
|
+
draft jsonb not null constraint site_posts_draft_shape check (jsonb_typeof(draft) = 'object' and octet_length(draft::text) <= 524288),
|
|
51
|
+
draft_rev integer not null default 1,
|
|
52
|
+
-- What visitors read: a copy of the draft at its last publish.
|
|
53
|
+
published jsonb constraint site_posts_published_shape check (published is null or (jsonb_typeof(published) = 'object' and octet_length(published::text) <= 524288)),
|
|
54
|
+
status text not null default 'draft' constraint site_posts_status check (status in ('draft', 'published')),
|
|
55
|
+
-- When it was first published, kept when it is published again (an import keeps the old site's date).
|
|
56
|
+
published_at timestamptz,
|
|
57
|
+
-- When the published copy last changed.
|
|
58
|
+
published_updated_at timestamptz,
|
|
59
|
+
created_by uuid default auth.uid() references auth.users (id) on delete set null,
|
|
60
|
+
created_at timestamptz not null default now(),
|
|
61
|
+
updated_at timestamptz not null default now(),
|
|
62
|
+
deleted_at timestamptz,
|
|
63
|
+
constraint site_posts_published_state check (
|
|
64
|
+
(status = 'published' and published is not null and published_at is not null and published_updated_at is not null)
|
|
65
|
+
or (status = 'draft' and published is null)
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
create unique index site_posts_one_per_slug on genesis_sites.site_posts (site_id, slug) where deleted_at is null;
|
|
70
|
+
create index site_posts_live on genesis_sites.site_posts (site_id, published_at desc) where status = 'published' and deleted_at is null;
|
|
71
|
+
|
|
72
|
+
comment on table genesis_sites.site_posts is 'A site''s blog posts: a draft and the published copy visitors read. A deleted post keeps its row (deleted_at). Genesis Sites.';
|
|
73
|
+
|
|
74
|
+
create trigger genesis_touch before update on genesis_sites.site_posts for each row execute function genesis_sites.touch();
|
|
75
|
+
|
|
76
|
+
-- What a post serves changed (published, taken down, deleted): tell a listener to purge the site's cache.
|
|
77
|
+
create or replace function genesis_sites.notify_post_change()
|
|
78
|
+
returns trigger
|
|
79
|
+
language plpgsql
|
|
80
|
+
set search_path = ''
|
|
81
|
+
as $$
|
|
82
|
+
begin
|
|
83
|
+
perform pg_notify('genesis_sites', jsonb_build_object(
|
|
84
|
+
'site_id', new.site_id,
|
|
85
|
+
'post_id', new.id,
|
|
86
|
+
'slug', new.slug,
|
|
87
|
+
'status', new.status,
|
|
88
|
+
'action', 'post'
|
|
89
|
+
)::text);
|
|
90
|
+
return new;
|
|
91
|
+
end;
|
|
92
|
+
$$;
|
|
93
|
+
|
|
94
|
+
create trigger genesis_notify_post after update of status, published on genesis_sites.site_posts
|
|
95
|
+
for each row when (old.status is distinct from new.status or old.published is distinct from new.published)
|
|
96
|
+
execute function genesis_sites.notify_post_change();
|
|
97
|
+
create trigger genesis_notify_post_insert after insert on genesis_sites.site_posts
|
|
98
|
+
for each row when (new.status = 'published')
|
|
99
|
+
execute function genesis_sites.notify_post_change();
|
|
100
|
+
|
|
101
|
+
alter table genesis_sites.site_posts enable row level security;
|
|
102
|
+
|
|
103
|
+
create policy "viewers read its posts" on genesis_sites.site_posts
|
|
104
|
+
for select to authenticated using (deleted_at is null and genesis_sites.can_view(site_id));
|
|
105
|
+
|
|
106
|
+
-- ── Helpers ───────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
create or replace function genesis_sites.assert_post(slug text, doc jsonb)
|
|
109
|
+
returns void
|
|
110
|
+
language plpgsql
|
|
111
|
+
immutable
|
|
112
|
+
set search_path = ''
|
|
113
|
+
as $$
|
|
114
|
+
begin
|
|
115
|
+
if slug is null or char_length(slug) > 80 or slug !~ '^[a-z0-9]+(-[a-z0-9]+)*$' then
|
|
116
|
+
raise exception 'genesis_sites: "%" isn''t a post slug (lowercase letters, digits and single hyphens)', slug using errcode = '22023';
|
|
117
|
+
end if;
|
|
118
|
+
if doc is null or jsonb_typeof(doc) <> 'object' or octet_length(doc::text) > 524288 then
|
|
119
|
+
raise exception 'genesis_sites: the post document must be a JSON object of at most 512 KiB' using errcode = '22023';
|
|
120
|
+
end if;
|
|
121
|
+
if doc -> 'schemaVersion' is distinct from '1'::jsonb then
|
|
122
|
+
raise exception 'genesis_sites: the post document needs schemaVersion 1' using errcode = '22023';
|
|
123
|
+
end if;
|
|
124
|
+
if jsonb_typeof(doc -> 'title') is distinct from 'string' or jsonb_typeof(doc -> 'body') is distinct from 'object' or doc #>> '{body,type}' is distinct from 'doc' then
|
|
125
|
+
raise exception 'genesis_sites: a post document needs a title and a rich text body' using errcode = '22023';
|
|
126
|
+
end if;
|
|
127
|
+
end;
|
|
128
|
+
$$;
|
|
129
|
+
|
|
130
|
+
create or replace function genesis_sites.post_json(p genesis_sites.site_posts)
|
|
131
|
+
returns jsonb
|
|
132
|
+
language sql
|
|
133
|
+
stable
|
|
134
|
+
set search_path = ''
|
|
135
|
+
as $$
|
|
136
|
+
select jsonb_build_object(
|
|
137
|
+
'id', p.id, 'siteId', p.site_id, 'slug', p.slug, 'title', p.draft ->> 'title', 'status', p.status,
|
|
138
|
+
'rev', p.draft_rev, 'publishedAt', p.published_at, 'updatedAt', p.updated_at
|
|
139
|
+
)
|
|
140
|
+
$$;
|
|
141
|
+
|
|
142
|
+
-- ── Writes (security definer, each asking can_edit) ───────────────────────────
|
|
143
|
+
|
|
144
|
+
-- Create a post (post_id null) or save its draft if nobody saved since
|
|
145
|
+
-- `expected_rev`. Returns { id, rev }; raises GS409 (with the current
|
|
146
|
+
-- revision in DETAIL) when someone saved first. A post keeps its slug once
|
|
147
|
+
-- it has been published.
|
|
148
|
+
create or replace function genesis_sites.save_post(site_id uuid, post_id uuid, slug text, expected_rev integer, doc jsonb)
|
|
149
|
+
returns jsonb
|
|
150
|
+
language plpgsql
|
|
151
|
+
security definer
|
|
152
|
+
set search_path = ''
|
|
153
|
+
as $$
|
|
154
|
+
declare
|
|
155
|
+
post genesis_sites.site_posts;
|
|
156
|
+
new_id uuid;
|
|
157
|
+
begin
|
|
158
|
+
-- can_edit first, then any lock: someone who can't edit learns nothing and locks nothing.
|
|
159
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
160
|
+
perform genesis_sites.assert_post(slug, doc);
|
|
161
|
+
perform 1 from genesis_sites.sites s where s.id = save_post.site_id for update;
|
|
162
|
+
if not found then
|
|
163
|
+
raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
|
|
164
|
+
end if;
|
|
165
|
+
if post_id is null then
|
|
166
|
+
if exists (select 1 from genesis_sites.site_posts p where p.site_id = save_post.site_id and p.slug = save_post.slug and p.deleted_at is null) then
|
|
167
|
+
raise exception 'genesis_sites: a post is already at %', slug using errcode = '23505';
|
|
168
|
+
end if;
|
|
169
|
+
insert into genesis_sites.site_posts (site_id, slug, draft) values (site_id, slug, doc) returning id into new_id;
|
|
170
|
+
return jsonb_build_object('id', new_id, 'rev', 1);
|
|
171
|
+
end if;
|
|
172
|
+
select * into post from genesis_sites.site_posts p where p.id = post_id and p.site_id = save_post.site_id and p.deleted_at is null for update;
|
|
173
|
+
if not found then
|
|
174
|
+
raise exception 'genesis_sites: no post %', post_id using errcode = 'P0002';
|
|
175
|
+
end if;
|
|
176
|
+
if post.draft_rev is distinct from expected_rev then
|
|
177
|
+
raise exception 'genesis_sites: someone saved this post first' using errcode = 'GS409', detail = post.draft_rev::text;
|
|
178
|
+
end if;
|
|
179
|
+
if post.slug <> slug then
|
|
180
|
+
if post.published_at is not null then
|
|
181
|
+
raise exception 'genesis_sites: a post keeps its slug once it has been published' using errcode = '22023';
|
|
182
|
+
end if;
|
|
183
|
+
if exists (select 1 from genesis_sites.site_posts p where p.site_id = save_post.site_id and p.slug = save_post.slug and p.deleted_at is null and p.id <> post_id) then
|
|
184
|
+
raise exception 'genesis_sites: a post is already at %', slug using errcode = '23505';
|
|
185
|
+
end if;
|
|
186
|
+
end if;
|
|
187
|
+
update genesis_sites.site_posts p set slug = save_post.slug, draft = doc, draft_rev = p.draft_rev + 1 where p.id = post_id;
|
|
188
|
+
return jsonb_build_object('id', post_id, 'rev', post.draft_rev + 1);
|
|
189
|
+
end;
|
|
190
|
+
$$;
|
|
191
|
+
|
|
192
|
+
-- Publish a post: the draft exactly as the caller checked it (`expected_rev`)
|
|
193
|
+
-- becomes the published copy. Raises GS409 if the draft changed since.
|
|
194
|
+
create or replace function genesis_sites.publish_post(post_id uuid, expected_rev integer)
|
|
195
|
+
returns jsonb
|
|
196
|
+
language plpgsql
|
|
197
|
+
security definer
|
|
198
|
+
set search_path = ''
|
|
199
|
+
as $$
|
|
200
|
+
declare
|
|
201
|
+
post genesis_sites.site_posts;
|
|
202
|
+
site_status text;
|
|
203
|
+
begin
|
|
204
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_posts p where p.id = post_id));
|
|
205
|
+
select s.status into site_status from genesis_sites.sites s where s.id = (select p.site_id from genesis_sites.site_posts p where p.id = post_id) for update;
|
|
206
|
+
-- Archiving is a takedown: an editor can't put posts back up.
|
|
207
|
+
if site_status = 'archived' then
|
|
208
|
+
raise exception 'genesis_sites: an archived site can''t publish posts' using errcode = '22023';
|
|
209
|
+
end if;
|
|
210
|
+
select * into post from genesis_sites.site_posts p where p.id = post_id and p.deleted_at is null for update;
|
|
211
|
+
if not found then
|
|
212
|
+
raise exception 'genesis_sites: no post %', post_id using errcode = 'P0002';
|
|
213
|
+
end if;
|
|
214
|
+
if post.draft_rev is distinct from expected_rev then
|
|
215
|
+
raise exception 'genesis_sites: the post changed since it was checked' using errcode = 'GS409', detail = post.draft_rev::text;
|
|
216
|
+
end if;
|
|
217
|
+
perform genesis_sites.assert_post(post.slug, post.draft);
|
|
218
|
+
update genesis_sites.site_posts p
|
|
219
|
+
set published = p.draft, status = 'published', published_at = coalesce(p.published_at, now()), published_updated_at = now()
|
|
220
|
+
where p.id = post_id
|
|
221
|
+
returning * into post;
|
|
222
|
+
return genesis_sites.post_json(post);
|
|
223
|
+
end;
|
|
224
|
+
$$;
|
|
225
|
+
|
|
226
|
+
-- Take a post down. Its draft stays; its first publish date stays, for when it goes back up.
|
|
227
|
+
create or replace function genesis_sites.unpublish_post(post_id uuid)
|
|
228
|
+
returns jsonb
|
|
229
|
+
language plpgsql
|
|
230
|
+
security definer
|
|
231
|
+
set search_path = ''
|
|
232
|
+
as $$
|
|
233
|
+
declare
|
|
234
|
+
post genesis_sites.site_posts;
|
|
235
|
+
begin
|
|
236
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_posts p where p.id = post_id));
|
|
237
|
+
update genesis_sites.site_posts p set status = 'draft', published = null, published_updated_at = null
|
|
238
|
+
where p.id = post_id and p.deleted_at is null
|
|
239
|
+
returning * into post;
|
|
240
|
+
if not found then
|
|
241
|
+
raise exception 'genesis_sites: no post %', post_id using errcode = 'P0002';
|
|
242
|
+
end if;
|
|
243
|
+
return genesis_sites.post_json(post);
|
|
244
|
+
end;
|
|
245
|
+
$$;
|
|
246
|
+
|
|
247
|
+
create or replace function genesis_sites.delete_post(post_id uuid)
|
|
248
|
+
returns void
|
|
249
|
+
language plpgsql
|
|
250
|
+
security definer
|
|
251
|
+
set search_path = ''
|
|
252
|
+
as $$
|
|
253
|
+
begin
|
|
254
|
+
perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_posts p where p.id = post_id));
|
|
255
|
+
update genesis_sites.site_posts p set deleted_at = now(), status = 'draft', published = null, published_updated_at = null
|
|
256
|
+
where p.id = post_id and p.deleted_at is null;
|
|
257
|
+
end;
|
|
258
|
+
$$;
|
|
259
|
+
|
|
260
|
+
-- A one-time import (a move from another site): `posts` is
|
|
261
|
+
-- [{ slug, doc, status, publishedAt }], at most 500. Each keeps its slug and
|
|
262
|
+
-- its date (never later than now); a slug the site already has, or a post
|
|
263
|
+
-- that isn't one, is skipped and reported. Returns { imported: [{ slug, id,
|
|
264
|
+
-- status }], skipped: [{ slug, reason }] }. A post is published only when it
|
|
265
|
+
-- asks for `published`; with no status it is a draft. The client runs the
|
|
266
|
+
-- checks first and asks for `draft` on a post that fails them.
|
|
267
|
+
create or replace function genesis_sites.import_posts(site_id uuid, posts jsonb)
|
|
268
|
+
returns jsonb
|
|
269
|
+
language plpgsql
|
|
270
|
+
security definer
|
|
271
|
+
set search_path = ''
|
|
272
|
+
as $$
|
|
273
|
+
declare
|
|
274
|
+
item jsonb;
|
|
275
|
+
item_slug text;
|
|
276
|
+
item_status text;
|
|
277
|
+
item_at timestamptz;
|
|
278
|
+
new_id uuid;
|
|
279
|
+
imported jsonb := '[]'::jsonb;
|
|
280
|
+
skipped jsonb := '[]'::jsonb;
|
|
281
|
+
begin
|
|
282
|
+
perform genesis_sites.assert_can_edit(site_id);
|
|
283
|
+
if posts is null or jsonb_typeof(posts) <> 'array' or jsonb_array_length(posts) > 500 then
|
|
284
|
+
raise exception 'genesis_sites: posts is an array of at most 500 posts' using errcode = '22023';
|
|
285
|
+
end if;
|
|
286
|
+
perform 1 from genesis_sites.sites s where s.id = import_posts.site_id and s.status <> 'archived' for update;
|
|
287
|
+
if not found then
|
|
288
|
+
raise exception 'genesis_sites: no site % to import into', site_id using errcode = 'P0002';
|
|
289
|
+
end if;
|
|
290
|
+
for item in select * from jsonb_array_elements(posts) loop
|
|
291
|
+
item_slug := item ->> 'slug';
|
|
292
|
+
begin
|
|
293
|
+
perform genesis_sites.assert_post(item_slug, item -> 'doc');
|
|
294
|
+
if exists (select 1 from genesis_sites.site_posts p where p.site_id = import_posts.site_id and p.slug = item_slug and p.deleted_at is null) then
|
|
295
|
+
skipped := skipped || jsonb_build_array(jsonb_build_object('slug', item_slug, 'reason', 'The site already has a post at this slug.'));
|
|
296
|
+
continue;
|
|
297
|
+
end if;
|
|
298
|
+
-- Published only when asked: a post with no status (or any other) comes in as a draft.
|
|
299
|
+
item_status := case when item ->> 'status' = 'published' then 'published' else 'draft' end;
|
|
300
|
+
item_at := least(coalesce((item ->> 'publishedAt')::timestamptz, now()), now());
|
|
301
|
+
-- A real date: not infinity, not before 1970 (the feeds and the sitemap print it).
|
|
302
|
+
if not isfinite(item_at) or item_at < '1970-01-01T00:00:00Z'::timestamptz then
|
|
303
|
+
raise exception 'genesis_sites: "%" isn''t a usable publish date', item ->> 'publishedAt' using errcode = '22008';
|
|
304
|
+
end if;
|
|
305
|
+
insert into genesis_sites.site_posts (site_id, slug, draft, published, status, published_at, published_updated_at)
|
|
306
|
+
values (
|
|
307
|
+
site_id, item_slug, item -> 'doc',
|
|
308
|
+
case when item_status = 'published' then item -> 'doc' end,
|
|
309
|
+
item_status,
|
|
310
|
+
case when item_status = 'published' then item_at end,
|
|
311
|
+
case when item_status = 'published' then item_at end
|
|
312
|
+
)
|
|
313
|
+
returning id into new_id;
|
|
314
|
+
imported := imported || jsonb_build_array(jsonb_build_object('slug', item_slug, 'id', new_id, 'status', item_status));
|
|
315
|
+
exception when sqlstate '22023' or sqlstate '22007' or sqlstate '22008' or sqlstate '23514' or sqlstate '23505' then
|
|
316
|
+
skipped := skipped || jsonb_build_array(jsonb_build_object('slug', coalesce(item_slug, '?'), 'reason', sqlerrm));
|
|
317
|
+
end;
|
|
318
|
+
end loop;
|
|
319
|
+
return jsonb_build_object('imported', imported, 'skipped', skipped);
|
|
320
|
+
end;
|
|
321
|
+
$$;
|
|
322
|
+
|
|
323
|
+
-- ── The public read ───────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
-- A live site's published posts, newest first, without their bodies (at
|
|
326
|
+
-- most 500): for the blog's index, the sitemap and the feed. Never drafts.
|
|
327
|
+
create or replace function genesis_sites.live_posts(site_id uuid)
|
|
328
|
+
returns jsonb
|
|
329
|
+
language sql
|
|
330
|
+
stable
|
|
331
|
+
security definer
|
|
332
|
+
set search_path = ''
|
|
333
|
+
as $$
|
|
334
|
+
select coalesce(jsonb_agg(item order by at desc, slug), '[]'::jsonb)
|
|
335
|
+
from (
|
|
336
|
+
select p.published_at as at, p.slug,
|
|
337
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
338
|
+
'slug', p.slug,
|
|
339
|
+
'title', p.published ->> 'title',
|
|
340
|
+
'excerpt', p.published -> 'excerpt',
|
|
341
|
+
'cover', p.published -> 'cover',
|
|
342
|
+
'author', p.published -> 'author',
|
|
343
|
+
'category', p.published -> 'category',
|
|
344
|
+
-- A JSON comparison, so a malformed stored value can't fail the whole list.
|
|
345
|
+
'noindex', case when p.published #> '{seo,noindex}' = 'true'::jsonb or p.published #> '{seo,canonical}' is not null then true end,
|
|
346
|
+
'publishedAt', p.published_at,
|
|
347
|
+
'updatedAt', p.published_updated_at
|
|
348
|
+
)) as item
|
|
349
|
+
from genesis_sites.sites s
|
|
350
|
+
join genesis_sites.site_posts p on p.site_id = s.id
|
|
351
|
+
where s.id = live_posts.site_id and s.status = 'live' and s.live_release_id is not null
|
|
352
|
+
and p.status = 'published' and p.deleted_at is null
|
|
353
|
+
order by p.published_at desc, p.slug
|
|
354
|
+
limit 500
|
|
355
|
+
) latest
|
|
356
|
+
$$;
|
|
357
|
+
|
|
358
|
+
comment on function genesis_sites.live_posts(uuid) is
|
|
359
|
+
'The published posts of a live site, newest first, without bodies (at most 500). Public: anon may call it. Never returns drafts. Genesis Sites.';
|
|
360
|
+
|
|
361
|
+
-- One published post of a live site: { slug, publishedAt, updatedAt, doc }, or null.
|
|
362
|
+
create or replace function genesis_sites.live_post(site_id uuid, slug text)
|
|
363
|
+
returns jsonb
|
|
364
|
+
language sql
|
|
365
|
+
stable
|
|
366
|
+
security definer
|
|
367
|
+
set search_path = ''
|
|
368
|
+
as $$
|
|
369
|
+
select jsonb_build_object('slug', p.slug, 'publishedAt', p.published_at, 'updatedAt', p.published_updated_at, 'doc', p.published)
|
|
370
|
+
from genesis_sites.sites s
|
|
371
|
+
join genesis_sites.site_posts p on p.site_id = s.id
|
|
372
|
+
where s.id = live_post.site_id and s.status = 'live' and s.live_release_id is not null
|
|
373
|
+
and p.slug = live_post.slug and p.status = 'published' and p.deleted_at is null
|
|
374
|
+
$$;
|
|
375
|
+
|
|
376
|
+
comment on function genesis_sites.live_post(uuid, text) is
|
|
377
|
+
'One published post of a live site, or null. Public: anon may call it. Never returns a draft. Genesis Sites.';
|
|
378
|
+
|
|
379
|
+
-- ── Privileges, by name ───────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
revoke all on table genesis_sites.site_posts from public, anon, authenticated;
|
|
382
|
+
grant select on table genesis_sites.site_posts to authenticated;
|
|
383
|
+
revoke all on table genesis_sites.site_posts from service_role;
|
|
384
|
+
grant select, insert, update, delete on table genesis_sites.site_posts to service_role;
|
|
385
|
+
|
|
386
|
+
revoke execute on function genesis_sites.save_post(uuid, uuid, text, integer, jsonb) from public, anon;
|
|
387
|
+
grant execute on function genesis_sites.save_post(uuid, uuid, text, integer, jsonb) to authenticated;
|
|
388
|
+
revoke execute on function genesis_sites.publish_post(uuid, integer) from public, anon;
|
|
389
|
+
grant execute on function genesis_sites.publish_post(uuid, integer) to authenticated;
|
|
390
|
+
revoke execute on function genesis_sites.unpublish_post(uuid) from public, anon;
|
|
391
|
+
grant execute on function genesis_sites.unpublish_post(uuid) to authenticated;
|
|
392
|
+
revoke execute on function genesis_sites.delete_post(uuid) from public, anon;
|
|
393
|
+
grant execute on function genesis_sites.delete_post(uuid) to authenticated;
|
|
394
|
+
revoke execute on function genesis_sites.import_posts(uuid, jsonb) from public, anon;
|
|
395
|
+
grant execute on function genesis_sites.import_posts(uuid, jsonb) to authenticated;
|
|
396
|
+
revoke execute on function genesis_sites.live_posts(uuid) from public;
|
|
397
|
+
grant execute on function genesis_sites.live_posts(uuid) to anon, authenticated, service_role;
|
|
398
|
+
revoke execute on function genesis_sites.live_post(uuid, text) from public;
|
|
399
|
+
grant execute on function genesis_sites.live_post(uuid, text) to anon, authenticated, service_role;
|
|
400
|
+
|
|
401
|
+
-- Internal: only the functions above call these.
|
|
402
|
+
revoke execute on function genesis_sites.assert_post(text, jsonb) from public, anon, authenticated;
|
|
403
|
+
revoke execute on function genesis_sites.post_json(genesis_sites.site_posts) from public, anon, authenticated;
|
|
404
|
+
revoke execute on function genesis_sites.notify_post_change() from public, anon, authenticated;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
-- request.jwt.claim.sub on older images), so pg_temp.sign_in sets both.
|
|
10
10
|
begin;
|
|
11
11
|
create extension if not exists pgtap with schema extensions;
|
|
12
|
-
select plan(
|
|
12
|
+
select plan(79);
|
|
13
13
|
|
|
14
14
|
-- People: e1 edits site A, b2 may view site A, c3 is nobody's.
|
|
15
15
|
insert into auth.users (id, aud, role, email, instance_id)
|
|
@@ -316,5 +316,34 @@ select throws_ok(
|
|
|
316
316
|
);
|
|
317
317
|
reset role;
|
|
318
318
|
|
|
319
|
+
reset role;
|
|
320
|
+
select pg_temp.sign_in('e1');
|
|
321
|
+
-- A redirect can't start on one of the app's paths: it would take over the app's own route.
|
|
322
|
+
create function pg_temp.site_with_redirect(from_path text) returns jsonb language sql immutable as $$
|
|
323
|
+
select jsonb_build_object('schemaVersion', 1, 'name', 'Riverside Gym', 'redirects', jsonb_build_array(jsonb_build_object('from', from_path, 'to', '/', 'status', 301)))
|
|
324
|
+
$$;
|
|
325
|
+
grant execute on function pg_temp.site_with_redirect(text) to authenticated;
|
|
326
|
+
select throws_like(
|
|
327
|
+
format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/auth/sign-in')),
|
|
328
|
+
'%reserved for the app%', 'a redirect from a reserved path is refused'
|
|
329
|
+
);
|
|
330
|
+
select throws_like(
|
|
331
|
+
format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/API?x=1')),
|
|
332
|
+
'%reserved for the app%', '... in any case, and with a query string'
|
|
333
|
+
);
|
|
334
|
+
select lives_ok(
|
|
335
|
+
format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/Old_Page.html')),
|
|
336
|
+
'a redirect from an old address that isn''t reserved is fine'
|
|
337
|
+
);
|
|
338
|
+
reset role;
|
|
339
|
+
|
|
340
|
+
-- A host that reserves "/" itself reserves the root only (as isReservedPath does), not every path.
|
|
341
|
+
reset role;
|
|
342
|
+
create or replace function genesis_sites.reserved_paths() returns text[] language sql stable set search_path = '' as $$
|
|
343
|
+
select array['/']::text[]
|
|
344
|
+
$$;
|
|
345
|
+
select throws_like('select genesis_sites.assert_not_reserved(''/?x=1'')', '%reserved for the app%', 'a reserved root refuses the root, with a query string too');
|
|
346
|
+
select lives_ok('select genesis_sites.assert_not_reserved(''/about'')', '... and leaves other paths alone');
|
|
347
|
+
|
|
319
348
|
select * from finish();
|
|
320
349
|
rollback;
|