@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.
Files changed (47) hide show
  1. package/README.md +152 -8
  2. package/dist/assets/islands.77d11f5697.js +1 -0
  3. package/dist/assets/sites-forms.4e6aa42497.css +10 -0
  4. package/dist/assets/sites-forms.css +1 -1
  5. package/dist/assets/sites.css +1 -1
  6. package/dist/assets/sites.e1ffd2708b.css +2 -0
  7. package/dist/blocks.d.ts +1 -1
  8. package/dist/blocks.js +5 -1
  9. package/dist/blocks.js.map +1 -1
  10. package/dist/chunk-3VKTBE7S.js +69 -0
  11. package/dist/chunk-3VKTBE7S.js.map +1 -0
  12. package/dist/chunk-C375ZPU2.js +2241 -0
  13. package/dist/chunk-C375ZPU2.js.map +1 -0
  14. package/dist/{chunk-GA7U3CIN.js → chunk-IXMY4B56.js} +229 -10
  15. package/dist/chunk-IXMY4B56.js.map +1 -0
  16. package/dist/chunk-VZUDRPFG.js +13 -0
  17. package/dist/chunk-VZUDRPFG.js.map +1 -0
  18. package/dist/{chunk-KEF3V5ZN.js → chunk-ZGWGQGT2.js} +169 -73
  19. package/dist/chunk-ZGWGQGT2.js.map +1 -0
  20. package/dist/import.d.ts +130 -0
  21. package/dist/import.js +330 -0
  22. package/dist/import.js.map +1 -0
  23. package/dist/index.d.ts +40 -7
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/{registry-CdgVymsu.d.ts → registry-D_DCWRTc.d.ts} +274 -8
  27. package/dist/sections.d.ts +422 -6
  28. package/dist/sections.js +4 -2
  29. package/dist/{store-BzGw2i5J.d.ts → store-DkSycDhq.d.ts} +119 -53
  30. package/dist/supabase.d.ts +5 -5
  31. package/dist/supabase.js +115 -6
  32. package/dist/supabase.js.map +1 -1
  33. package/dist/web.d.ts +26 -8
  34. package/dist/web.js +142 -18
  35. package/dist/web.js.map +1 -1
  36. package/migrations/0001_genesis_sites.sql +64 -6
  37. package/migrations/0002_genesis_sites_posts.sql +404 -0
  38. package/migrations/tests/0001_genesis_sites.test.sql +30 -1
  39. package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
  40. package/package.json +18 -10
  41. package/dist/assets/islands.230b20b9c4.js +0 -1
  42. package/dist/assets/sites-forms.5e6ebf31c3.css +0 -10
  43. package/dist/assets/sites.b28b796c6f.css +0 -2
  44. package/dist/chunk-43XUAO63.js +0 -1255
  45. package/dist/chunk-43XUAO63.js.map +0 -1
  46. package/dist/chunk-GA7U3CIN.js.map +0 -1
  47. package/dist/chunk-KEF3V5ZN.js.map +0 -1
@@ -333,8 +333,8 @@ begin
333
333
  end;
334
334
  $$;
335
335
 
336
- -- A page path's shape, and not one of the app's reserved paths.
337
- create or replace function genesis_sites.assert_page_path(path text)
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 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
348
- raise exception 'genesis_sites: "%" isn''t a page path (lowercase segments, like /about)', path using errcode = '22023';
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 := rtrim(prefix, '/');
352
- if prefix <> '' and (path = prefix or starts_with(path, prefix || '/')) then
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(74);
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;