@acornly/adapter-supabase 0.1.0 → 0.2.1
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 +25 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/supabase/install.sql +2295 -0
- package/supabase/migrations/20261011080000_acornly_delete_node_grants.sql +10 -0
- package/supabase/migrations/20261011150000_acornly_parent_ids.sql +53 -0
- package/supabase/migrations/20261011170000_acornly_pdf_document_keys.sql +185 -0
|
@@ -0,0 +1,2295 @@
|
|
|
1
|
+
-- @acornly/adapter-supabase installer, through migration 20261011170000.
|
|
2
|
+
--
|
|
3
|
+
-- Generated by scripts/build-install.mjs from supabase/migrations; do not edit it by hand.
|
|
4
|
+
--
|
|
5
|
+
-- Run the whole file in the project's database, as the owner: paste it into the dashboard's SQL
|
|
6
|
+
-- editor, or `psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f install.sql`. It runs in one transaction and applies only
|
|
7
|
+
-- the migrations it has not applied before, so run it again, as it is, after each upgrade of the
|
|
8
|
+
-- package. acornly_meta.migrations records what it applied, and when.
|
|
9
|
+
--
|
|
10
|
+
-- A database that already has the migrations from `supabase db push` is recognised from the
|
|
11
|
+
-- Supabase CLI's history. One where they were pasted in by hand is refused until you record which
|
|
12
|
+
-- ones it has (the error says how).
|
|
13
|
+
|
|
14
|
+
begin;
|
|
15
|
+
|
|
16
|
+
-- One installer at a time.
|
|
17
|
+
select pg_advisory_xact_lock(hashtext('acornly_install'));
|
|
18
|
+
|
|
19
|
+
create schema if not exists acornly_meta;
|
|
20
|
+
revoke all on schema acornly_meta from public;
|
|
21
|
+
create table if not exists acornly_meta.migrations (
|
|
22
|
+
version text primary key,
|
|
23
|
+
name text not null,
|
|
24
|
+
applied_at timestamptz not null default now()
|
|
25
|
+
);
|
|
26
|
+
revoke all on table acornly_meta.migrations from public;
|
|
27
|
+
|
|
28
|
+
do $acornly_install$
|
|
29
|
+
begin
|
|
30
|
+
if exists (select 1 from acornly_meta.migrations) then
|
|
31
|
+
return;
|
|
32
|
+
end if;
|
|
33
|
+
-- Applied by `supabase db push` from copies of these files: the same version, and the same
|
|
34
|
+
-- name where the CLI records one, so a site's own migration with the same version is not taken
|
|
35
|
+
-- for Acornly's.
|
|
36
|
+
if to_regclass('supabase_migrations.schema_migrations') is not null then
|
|
37
|
+
if exists (select 1 from information_schema.columns c
|
|
38
|
+
where c.table_schema = 'supabase_migrations' and c.table_name = 'schema_migrations'
|
|
39
|
+
and c.column_name = 'name') then
|
|
40
|
+
insert into acornly_meta.migrations (version, name)
|
|
41
|
+
select v.version, v.name
|
|
42
|
+
from (values
|
|
43
|
+
('20261005090000', 'contexthive_annotations'),
|
|
44
|
+
('20261005130000', 'contexthive_threads'),
|
|
45
|
+
('20261010070000', 'acornly_names'),
|
|
46
|
+
('20261011080000', 'acornly_delete_node_grants'),
|
|
47
|
+
('20261011150000', 'acornly_parent_ids'),
|
|
48
|
+
('20261011170000', 'acornly_pdf_document_keys')) v (version, name)
|
|
49
|
+
where exists (select 1 from supabase_migrations.schema_migrations h
|
|
50
|
+
where h.version = v.version and h.name = v.name);
|
|
51
|
+
else
|
|
52
|
+
insert into acornly_meta.migrations (version, name)
|
|
53
|
+
select v.version, v.name
|
|
54
|
+
from (values
|
|
55
|
+
('20261005090000', 'contexthive_annotations'),
|
|
56
|
+
('20261005130000', 'contexthive_threads'),
|
|
57
|
+
('20261010070000', 'acornly_names'),
|
|
58
|
+
('20261011080000', 'acornly_delete_node_grants'),
|
|
59
|
+
('20261011150000', 'acornly_parent_ids'),
|
|
60
|
+
('20261011170000', 'acornly_pdf_document_keys')) v (version, name)
|
|
61
|
+
where v.version in (select h.version from supabase_migrations.schema_migrations h);
|
|
62
|
+
end if;
|
|
63
|
+
end if;
|
|
64
|
+
if not exists (select 1 from acornly_meta.migrations)
|
|
65
|
+
and (to_regclass('public.acornly_annotations') is not null
|
|
66
|
+
or to_regclass('public.contexthive_annotations') is not null) then
|
|
67
|
+
raise exception 'Acornly''s tables are here, but not which of its migrations made them'
|
|
68
|
+
using hint = 'Record each migration file already applied, then run the installer again: '
|
|
69
|
+
|| 'create schema if not exists acornly_meta; '
|
|
70
|
+
|| 'create table if not exists acornly_meta.migrations (version text primary key, name text not null, '
|
|
71
|
+
|| 'applied_at timestamptz not null default now()); '
|
|
72
|
+
|| 'insert into acornly_meta.migrations (version, name) values (''<version>'', ''<name>''); '
|
|
73
|
+
|| 'the version and name are the file''s name, as in 20261005090000_contexthive_annotations.sql.';
|
|
74
|
+
end if;
|
|
75
|
+
end
|
|
76
|
+
$acornly_install$;
|
|
77
|
+
|
|
78
|
+
-- 20261005090000_contexthive_annotations.sql
|
|
79
|
+
do $acornly_install$
|
|
80
|
+
begin
|
|
81
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261005090000') then
|
|
82
|
+
raise notice 'acornly: applying %', '20261005090000_contexthive_annotations.sql';
|
|
83
|
+
execute $acornly_migration$
|
|
84
|
+
-- ContextHive notes in a site's own Supabase project (contexthive-adapter-supabase).
|
|
85
|
+
--
|
|
86
|
+
-- This is not the ContextHive Cloud schema in /supabase: a site that runs notes
|
|
87
|
+
-- on its own project applies this file (and later files in this folder) to that
|
|
88
|
+
-- project. Everything it creates is prefixed with `contexthive`, so it can sit
|
|
89
|
+
-- next to the site's own tables.
|
|
90
|
+
--
|
|
91
|
+
-- Rules this migration encodes:
|
|
92
|
+
--
|
|
93
|
+
-- * The stored W3C annotation (`annotation`) is the source of truth. The
|
|
94
|
+
-- columns the policies and indexes use (`id`, `scope`, `document`,
|
|
95
|
+
-- `audience`, `thread_root_id`, `modified`) are copied from it by a trigger
|
|
96
|
+
-- on every write, so a client can never make them disagree.
|
|
97
|
+
-- * The database owns `rev`: a new note starts at 1 and every update adds one.
|
|
98
|
+
-- The adapter updates `where rev = <expected>`, so one of two concurrent
|
|
99
|
+
-- edits wins and the other sees a conflict.
|
|
100
|
+
-- * Clients may write only the `annotation` column. `creator_id` is the
|
|
101
|
+
-- signed-in user, set by the trigger, and is the author a site should trust
|
|
102
|
+
-- (the annotation's own `creator` field is whatever the client sent).
|
|
103
|
+
-- * A signed-in user may write only to scopes the site lists in
|
|
104
|
+
-- `contexthive_scopes`, plus their own `user:<id>` scope.
|
|
105
|
+
-- * Audiences: `private` (only the creator reads it) and `public` (everyone
|
|
106
|
+
-- signed in reads it; anonymous visitors too where the scope allows it).
|
|
107
|
+
-- Other audience kinds need ContextHive Cloud and are refused.
|
|
108
|
+
-- * Replies (`ext.threadRoot` set) live in their root's scope and document,
|
|
109
|
+
-- can narrow the root's audience but never widen it, and are hidden from
|
|
110
|
+
-- others whenever they cannot read the root.
|
|
111
|
+
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
-- Internal schema: helpers for the policies and the trigger. It is not in the
|
|
114
|
+
-- API's exposed schemas, and only the helpers below are executable by clients.
|
|
115
|
+
-- ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
create schema if not exists contexthive_private;
|
|
118
|
+
revoke all on schema contexthive_private from public;
|
|
119
|
+
grant usage on schema contexthive_private to anon, authenticated, service_role;
|
|
120
|
+
|
|
121
|
+
-- ---------------------------------------------------------------------------
|
|
122
|
+
-- Tables
|
|
123
|
+
-- ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
-- The app and web scopes this site accepts notes in, managed by the site with
|
|
126
|
+
-- SQL or the dashboard (clients have no access). For example:
|
|
127
|
+
-- insert into public.contexthive_scopes (scope) values ('app:my-site');
|
|
128
|
+
-- insert into public.contexthive_scopes (scope, anonymous_read)
|
|
129
|
+
-- values ('web:example.com', true);
|
|
130
|
+
create table public.contexthive_scopes (
|
|
131
|
+
scope text primary key check (scope ~ '^(app|web):.+$'),
|
|
132
|
+
-- Whether visitors who are not signed in can read the public notes in this scope.
|
|
133
|
+
anonymous_read boolean not null default false
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
alter table public.contexthive_scopes enable row level security;
|
|
137
|
+
revoke all on table public.contexthive_scopes from anon, authenticated;
|
|
138
|
+
|
|
139
|
+
create table public.contexthive_annotations (
|
|
140
|
+
id text primary key check (char_length(id) between 1 and 255),
|
|
141
|
+
-- `app:<appId>`, `web:<registrable domain>` or `user:<user id>`.
|
|
142
|
+
scope text not null check (scope ~ '^(app|web|user):.+$'),
|
|
143
|
+
-- The `<appId>` of an app scope, for the site's own queries.
|
|
144
|
+
app_id text generated always as (
|
|
145
|
+
case when scope like 'app:%' then substr(scope, 5) end
|
|
146
|
+
) stored,
|
|
147
|
+
-- The canonical document URL, `target.source`.
|
|
148
|
+
document text not null check (document <> ''),
|
|
149
|
+
creator_id uuid not null references auth.users (id) on delete cascade,
|
|
150
|
+
audience text not null check (audience in ('private', 'public')),
|
|
151
|
+
thread_root_id text references public.contexthive_annotations (id) on delete cascade,
|
|
152
|
+
modified timestamptz not null,
|
|
153
|
+
rev integer not null check (rev >= 1),
|
|
154
|
+
annotation jsonb not null check (
|
|
155
|
+
jsonb_typeof(annotation) = 'object' and octet_length(annotation::text) <= 65536
|
|
156
|
+
),
|
|
157
|
+
|
|
158
|
+
constraint contexthive_annotations_user_scope_is_creator check (
|
|
159
|
+
scope !~ '^user:' or scope = 'user:' || creator_id::text
|
|
160
|
+
),
|
|
161
|
+
constraint contexthive_annotations_not_own_root check (thread_root_id is distinct from id)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
-- `list`: one document's notes in one scope, optionally modified after a time.
|
|
165
|
+
create index contexthive_annotations_page_idx
|
|
166
|
+
on public.contexthive_annotations (scope, document, modified);
|
|
167
|
+
-- Delta sync across documents.
|
|
168
|
+
create index contexthive_annotations_modified_idx on public.contexthive_annotations (modified);
|
|
169
|
+
create index contexthive_annotations_thread_idx
|
|
170
|
+
on public.contexthive_annotations (thread_root_id) where thread_root_id is not null;
|
|
171
|
+
-- Deleting a user cascades to their notes.
|
|
172
|
+
create index contexthive_annotations_creator_idx on public.contexthive_annotations (creator_id);
|
|
173
|
+
|
|
174
|
+
-- ---------------------------------------------------------------------------
|
|
175
|
+
-- Helpers
|
|
176
|
+
-- ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
-- Whether the caller is a signed-in user. Supabase's anonymous sign-ins get a
|
|
179
|
+
-- user id too, but count as visitors here: they read like anyone not signed in
|
|
180
|
+
-- and write nothing.
|
|
181
|
+
create function contexthive_private.signed_in()
|
|
182
|
+
returns boolean
|
|
183
|
+
language sql
|
|
184
|
+
stable
|
|
185
|
+
set search_path = ''
|
|
186
|
+
as $$
|
|
187
|
+
select auth.uid() is not null
|
|
188
|
+
and not coalesce((auth.jwt() ->> 'is_anonymous')::boolean, false)
|
|
189
|
+
$$;
|
|
190
|
+
|
|
191
|
+
-- Whether the request comes from the site's own server code: the service_role
|
|
192
|
+
-- key, or the owner in SQL. Every other role is a client. Decided from the
|
|
193
|
+
-- request's role, which a security-definer function does not change, so a
|
|
194
|
+
-- site function owned by postgres still counts its client caller as a client.
|
|
195
|
+
create function contexthive_private.trusted_caller()
|
|
196
|
+
returns boolean
|
|
197
|
+
language sql
|
|
198
|
+
stable
|
|
199
|
+
set search_path = ''
|
|
200
|
+
as $$
|
|
201
|
+
select coalesce(nullif(current_setting('role', true), 'none'), session_user::text)
|
|
202
|
+
in ('service_role', 'postgres', 'supabase_admin')
|
|
203
|
+
$$;
|
|
204
|
+
|
|
205
|
+
-- Whether the caller may read public notes in `note_scope`: anyone signed in,
|
|
206
|
+
-- and anonymous visitors where the site turned on `anonymous_read`.
|
|
207
|
+
create function contexthive_private.public_readable(note_scope text)
|
|
208
|
+
returns boolean
|
|
209
|
+
language sql
|
|
210
|
+
stable
|
|
211
|
+
security definer
|
|
212
|
+
set search_path = ''
|
|
213
|
+
as $$
|
|
214
|
+
select contexthive_private.signed_in()
|
|
215
|
+
or exists (
|
|
216
|
+
select 1 from public.contexthive_scopes s
|
|
217
|
+
where s.scope = note_scope and s.anonymous_read
|
|
218
|
+
)
|
|
219
|
+
$$;
|
|
220
|
+
|
|
221
|
+
-- Whether the caller may read the thread root `root_id`. Roots have no root of
|
|
222
|
+
-- their own, so this never recurses.
|
|
223
|
+
create function contexthive_private.root_readable(root_id text)
|
|
224
|
+
returns boolean
|
|
225
|
+
language sql
|
|
226
|
+
stable
|
|
227
|
+
security definer
|
|
228
|
+
set search_path = ''
|
|
229
|
+
as $$
|
|
230
|
+
select exists (
|
|
231
|
+
select 1 from public.contexthive_annotations r
|
|
232
|
+
where r.id = root_id
|
|
233
|
+
and (
|
|
234
|
+
r.creator_id = auth.uid()
|
|
235
|
+
or (r.audience = 'public' and contexthive_private.public_readable(r.scope))
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
$$;
|
|
239
|
+
|
|
240
|
+
-- Whether the signed-in caller may write notes in `note_scope`.
|
|
241
|
+
create function contexthive_private.scope_writable(note_scope text)
|
|
242
|
+
returns boolean
|
|
243
|
+
language sql
|
|
244
|
+
stable
|
|
245
|
+
security definer
|
|
246
|
+
set search_path = ''
|
|
247
|
+
as $$
|
|
248
|
+
select contexthive_private.signed_in()
|
|
249
|
+
and (
|
|
250
|
+
note_scope = 'user:' || auth.uid()::text
|
|
251
|
+
or exists (select 1 from public.contexthive_scopes s where s.scope = note_scope)
|
|
252
|
+
)
|
|
253
|
+
$$;
|
|
254
|
+
|
|
255
|
+
-- Raises unless a reply in `reply_scope` and `reply_document` with audience
|
|
256
|
+
-- `reply_audience` may hang off `root_id`. Reads the root as the owner, but
|
|
257
|
+
-- reports a root the caller cannot read, or a reply, exactly like a missing
|
|
258
|
+
-- one. A trusted caller may reply under any root.
|
|
259
|
+
create function contexthive_private.check_thread_root(
|
|
260
|
+
root_id text,
|
|
261
|
+
reply_scope text,
|
|
262
|
+
reply_document text,
|
|
263
|
+
reply_audience text
|
|
264
|
+
)
|
|
265
|
+
returns void
|
|
266
|
+
language plpgsql
|
|
267
|
+
stable
|
|
268
|
+
security definer
|
|
269
|
+
set search_path = ''
|
|
270
|
+
as $$
|
|
271
|
+
declare
|
|
272
|
+
root public.contexthive_annotations;
|
|
273
|
+
begin
|
|
274
|
+
select * into root from public.contexthive_annotations r where r.id = root_id;
|
|
275
|
+
if not found
|
|
276
|
+
or root.thread_root_id is not null
|
|
277
|
+
or (
|
|
278
|
+
not contexthive_private.trusted_caller()
|
|
279
|
+
and not (
|
|
280
|
+
root.creator_id = auth.uid()
|
|
281
|
+
or (root.audience = 'public' and contexthive_private.public_readable(root.scope))
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
then
|
|
285
|
+
raise exception 'thread root % not found', root_id using errcode = '23503';
|
|
286
|
+
end if;
|
|
287
|
+
if root.scope <> reply_scope or root.document <> reply_document then
|
|
288
|
+
raise exception 'a reply must be in its thread root''s scope and document'
|
|
289
|
+
using errcode = '23514';
|
|
290
|
+
end if;
|
|
291
|
+
if root.audience = 'private' and reply_audience <> 'private' then
|
|
292
|
+
raise exception 'a reply cannot widen its thread root''s audience' using errcode = '23514';
|
|
293
|
+
end if;
|
|
294
|
+
end
|
|
295
|
+
$$;
|
|
296
|
+
|
|
297
|
+
revoke all on all functions in schema contexthive_private from public;
|
|
298
|
+
grant execute on function
|
|
299
|
+
contexthive_private.signed_in(),
|
|
300
|
+
contexthive_private.trusted_caller(),
|
|
301
|
+
contexthive_private.public_readable(text),
|
|
302
|
+
contexthive_private.root_readable(text),
|
|
303
|
+
contexthive_private.scope_writable(text),
|
|
304
|
+
contexthive_private.check_thread_root(text, text, text, text)
|
|
305
|
+
to anon, authenticated, service_role;
|
|
306
|
+
|
|
307
|
+
-- ---------------------------------------------------------------------------
|
|
308
|
+
-- Write trigger: copies the indexed columns out of the annotation, owns
|
|
309
|
+
-- `creator_id` and `rev`, and checks threads. Runs as the caller, so the
|
|
310
|
+
-- policies below still apply to the row it produces.
|
|
311
|
+
-- ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
create function contexthive_private.prepare_annotation()
|
|
314
|
+
returns trigger
|
|
315
|
+
language plpgsql
|
|
316
|
+
set search_path = ''
|
|
317
|
+
as $$
|
|
318
|
+
declare
|
|
319
|
+
a jsonb := new.annotation;
|
|
320
|
+
audience_kind text := a #>> '{ext,audience,kind}';
|
|
321
|
+
-- The site's own server code writing on a user's behalf.
|
|
322
|
+
trusted boolean := contexthive_private.trusted_caller();
|
|
323
|
+
begin
|
|
324
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
325
|
+
or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
|
|
326
|
+
or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
|
|
327
|
+
or jsonb_typeof(a -> 'modified') is distinct from 'string'
|
|
328
|
+
-- ISO 8601 only, so no `infinity`, `now` or other special values.
|
|
329
|
+
or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
|
|
330
|
+
or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
|
|
331
|
+
then
|
|
332
|
+
raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
|
|
333
|
+
using errcode = '23514';
|
|
334
|
+
end if;
|
|
335
|
+
|
|
336
|
+
if tg_op = 'INSERT' then
|
|
337
|
+
if not trusted then
|
|
338
|
+
-- Clients cannot write this column; the author is always the caller.
|
|
339
|
+
new.creator_id := auth.uid();
|
|
340
|
+
if new.creator_id is null then
|
|
341
|
+
raise exception 'sign in to write notes' using errcode = '42501';
|
|
342
|
+
end if;
|
|
343
|
+
elsif new.creator_id is null then
|
|
344
|
+
raise exception 'creator_id is required' using errcode = '23502';
|
|
345
|
+
end if;
|
|
346
|
+
new.rev := 1;
|
|
347
|
+
else
|
|
348
|
+
new.creator_id := old.creator_id;
|
|
349
|
+
new.rev := old.rev + 1;
|
|
350
|
+
end if;
|
|
351
|
+
|
|
352
|
+
if audience_kind is null or audience_kind not in ('private', 'public') then
|
|
353
|
+
raise exception 'audience "%" is not supported here; use private or public', audience_kind
|
|
354
|
+
using errcode = '23514';
|
|
355
|
+
end if;
|
|
356
|
+
|
|
357
|
+
new.id := a ->> 'id';
|
|
358
|
+
new.scope := a #>> '{ext,scope}';
|
|
359
|
+
new.document := a #>> '{target,source}';
|
|
360
|
+
new.audience := audience_kind;
|
|
361
|
+
new.thread_root_id := a #>> '{ext,threadRoot}';
|
|
362
|
+
new.modified := (a ->> 'modified')::timestamptz;
|
|
363
|
+
new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
|
|
364
|
+
|
|
365
|
+
if tg_op = 'UPDATE' then
|
|
366
|
+
if new.id is distinct from old.id
|
|
367
|
+
or new.scope is distinct from old.scope
|
|
368
|
+
or new.document is distinct from old.document
|
|
369
|
+
or new.thread_root_id is distinct from old.thread_root_id
|
|
370
|
+
then
|
|
371
|
+
raise exception 'id, scope, target.source and threadRoot cannot change'
|
|
372
|
+
using errcode = '23514';
|
|
373
|
+
end if;
|
|
374
|
+
end if;
|
|
375
|
+
|
|
376
|
+
if new.thread_root_id is not null then
|
|
377
|
+
perform contexthive_private.check_thread_root(
|
|
378
|
+
new.thread_root_id, new.scope, new.document, new.audience
|
|
379
|
+
);
|
|
380
|
+
end if;
|
|
381
|
+
return new;
|
|
382
|
+
end
|
|
383
|
+
$$;
|
|
384
|
+
|
|
385
|
+
revoke all on function contexthive_private.prepare_annotation() from public;
|
|
386
|
+
|
|
387
|
+
create trigger contexthive_annotations_prepare
|
|
388
|
+
before insert or update on public.contexthive_annotations
|
|
389
|
+
for each row execute function contexthive_private.prepare_annotation();
|
|
390
|
+
|
|
391
|
+
-- ---------------------------------------------------------------------------
|
|
392
|
+
-- Access
|
|
393
|
+
-- ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
alter table public.contexthive_annotations enable row level security;
|
|
396
|
+
|
|
397
|
+
revoke all on table public.contexthive_annotations from anon, authenticated;
|
|
398
|
+
grant select on table public.contexthive_annotations to anon, authenticated;
|
|
399
|
+
grant insert (annotation), update (annotation), delete
|
|
400
|
+
on table public.contexthive_annotations to authenticated;
|
|
401
|
+
|
|
402
|
+
create policy contexthive_annotations_read on public.contexthive_annotations
|
|
403
|
+
for select to anon, authenticated
|
|
404
|
+
using (
|
|
405
|
+
creator_id = (select auth.uid())
|
|
406
|
+
or (
|
|
407
|
+
audience = 'public'
|
|
408
|
+
and contexthive_private.public_readable(scope)
|
|
409
|
+
and (thread_root_id is null or contexthive_private.root_readable(thread_root_id))
|
|
410
|
+
)
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
create policy contexthive_annotations_create on public.contexthive_annotations
|
|
414
|
+
for insert to authenticated
|
|
415
|
+
with check (
|
|
416
|
+
creator_id = (select auth.uid()) and contexthive_private.scope_writable(scope)
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
create policy contexthive_annotations_update on public.contexthive_annotations
|
|
420
|
+
for update to authenticated
|
|
421
|
+
using (creator_id = (select auth.uid()))
|
|
422
|
+
with check (
|
|
423
|
+
creator_id = (select auth.uid()) and contexthive_private.scope_writable(scope)
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
create policy contexthive_annotations_delete on public.contexthive_annotations
|
|
427
|
+
for delete to authenticated
|
|
428
|
+
using (creator_id = (select auth.uid()));
|
|
429
|
+
|
|
430
|
+
-- ---------------------------------------------------------------------------
|
|
431
|
+
-- Realtime: `subscribe` listens to changes on this table. Realtime applies the
|
|
432
|
+
-- read policy above to inserts and updates before sending them.
|
|
433
|
+
-- ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
do $$
|
|
436
|
+
begin
|
|
437
|
+
if exists (
|
|
438
|
+
select 1 from pg_publication where pubname = 'supabase_realtime' and not puballtables
|
|
439
|
+
) and not exists (
|
|
440
|
+
select 1 from pg_publication_tables
|
|
441
|
+
where pubname = 'supabase_realtime'
|
|
442
|
+
and schemaname = 'public' and tablename = 'contexthive_annotations'
|
|
443
|
+
) then
|
|
444
|
+
alter publication supabase_realtime add table public.contexthive_annotations;
|
|
445
|
+
end if;
|
|
446
|
+
end
|
|
447
|
+
$$;
|
|
448
|
+
$acornly_migration$;
|
|
449
|
+
insert into acornly_meta.migrations (version, name) values ('20261005090000', 'contexthive_annotations');
|
|
450
|
+
end if;
|
|
451
|
+
end
|
|
452
|
+
$acornly_install$;
|
|
453
|
+
|
|
454
|
+
-- 20261005130000_contexthive_threads.sql
|
|
455
|
+
do $acornly_install$
|
|
456
|
+
begin
|
|
457
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261005130000') then
|
|
458
|
+
raise notice 'acornly: applying %', '20261005130000_contexthive_threads.sql';
|
|
459
|
+
execute $acornly_migration$
|
|
460
|
+
-- Reply threads and edit history for contexthive-adapter-supabase (#87).
|
|
461
|
+
--
|
|
462
|
+
-- Context on context (replies, tags, reactions, highlights on a note) follows the
|
|
463
|
+
-- tree rules in contexthive-core's thread.ts:
|
|
464
|
+
--
|
|
465
|
+
-- * A child's `target.source` is its parent's id as an IRI
|
|
466
|
+
-- (`urn:contexthive:annotation:<encodeURIComponent(id)>`, the scheme matched
|
|
467
|
+
-- case-insensitively). The trigger decodes it into `parent_id`. Children
|
|
468
|
+
-- nest to any depth and carry `ext.threadRoot` (the note on the page) and
|
|
469
|
+
-- `ext.depth`.
|
|
470
|
+
-- * A new child is checked against its stored parent in the same statement,
|
|
471
|
+
-- with the parent row locked: the parent exists and the replier can read it,
|
|
472
|
+
-- is not a deleted placeholder, and the child has its scope, thread root,
|
|
473
|
+
-- the next depth, an audience no wider (private under anything, public only
|
|
474
|
+
-- under public) and `ext.parentRev` exactly when it quotes the parent.
|
|
475
|
+
-- * Others read a child only if they can read every note above it, so
|
|
476
|
+
-- narrowing any note hides the replies below it (not only narrowing a root).
|
|
477
|
+
-- * Plain writes keep the tree: an update never moves a note in its thread,
|
|
478
|
+
-- never changes `ext.deleted`, never touches a placeholder, never changes the
|
|
479
|
+
-- author of a note with children, and never widens a child past its parent.
|
|
480
|
+
-- A plain delete refuses children and notes with children.
|
|
481
|
+
-- * Deleting goes through `contexthive_delete_node`, which runs as the owner:
|
|
482
|
+
-- a note with any child (even one the caller cannot see) becomes a
|
|
483
|
+
-- placeholder, and one without is removed along with each placeholder above
|
|
484
|
+
-- it left with no children, whoever wrote them.
|
|
485
|
+
-- * `contexthive_versions` keeps earlier versions of edited notes, readable
|
|
486
|
+
-- only by the note's author. Removing a note or turning it into a
|
|
487
|
+
-- placeholder drops its versions.
|
|
488
|
+
--
|
|
489
|
+
-- Errors the adapter maps: CH001 (a tree rule, ThreadError), CH002 (an
|
|
490
|
+
-- audience wider than the parent's, AudienceError), CH003 (a stale rev,
|
|
491
|
+
-- ConflictError, with the stored rev as the detail) and P0002 (missing).
|
|
492
|
+
|
|
493
|
+
-- ---------------------------------------------------------------------------
|
|
494
|
+
-- Columns
|
|
495
|
+
-- ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
alter table public.contexthive_annotations add column parent_id text;
|
|
498
|
+
|
|
499
|
+
create index contexthive_annotations_parent_idx
|
|
500
|
+
on public.contexthive_annotations (parent_id) where parent_id is not null;
|
|
501
|
+
|
|
502
|
+
-- ---------------------------------------------------------------------------
|
|
503
|
+
-- Helpers
|
|
504
|
+
-- ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
-- The parent id a target names, or null for a note on a page. Mirrors core's
|
|
507
|
+
-- `parentIdOf`: percent-escapes are decoded as UTF-8, and a badly escaped or
|
|
508
|
+
-- empty id is refused.
|
|
509
|
+
create function contexthive_private.parent_id_of(source text)
|
|
510
|
+
returns text
|
|
511
|
+
language plpgsql
|
|
512
|
+
stable
|
|
513
|
+
set search_path = ''
|
|
514
|
+
as $$
|
|
515
|
+
declare
|
|
516
|
+
prefix constant text := 'urn:contexthive:annotation:';
|
|
517
|
+
encoded text;
|
|
518
|
+
decoded text;
|
|
519
|
+
begin
|
|
520
|
+
if lower(left(source, length(prefix))) <> prefix then
|
|
521
|
+
return null;
|
|
522
|
+
end if;
|
|
523
|
+
encoded := substr(source, length(prefix) + 1);
|
|
524
|
+
if encoded !~ '^([^%]|%[0-9A-Fa-f]{2})*$' then
|
|
525
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
526
|
+
end if;
|
|
527
|
+
begin
|
|
528
|
+
select convert_from(
|
|
529
|
+
coalesce(
|
|
530
|
+
string_agg(
|
|
531
|
+
case when m[1] is not null
|
|
532
|
+
then decode(substr(m[1], 2), 'hex')
|
|
533
|
+
else convert_to(m[2], 'UTF8')
|
|
534
|
+
end,
|
|
535
|
+
''::bytea order by n
|
|
536
|
+
),
|
|
537
|
+
''::bytea
|
|
538
|
+
),
|
|
539
|
+
'UTF8'
|
|
540
|
+
)
|
|
541
|
+
into decoded
|
|
542
|
+
from regexp_matches(encoded, '(%[0-9A-Fa-f]{2})|([^%]+)', 'g') with ordinality as t (m, n);
|
|
543
|
+
exception when character_not_in_repertoire or untranslatable_character then
|
|
544
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
545
|
+
end;
|
|
546
|
+
if decoded = '' then
|
|
547
|
+
raise exception 'target % names an empty id', source using errcode = 'CH001';
|
|
548
|
+
end if;
|
|
549
|
+
return decoded;
|
|
550
|
+
end
|
|
551
|
+
$$;
|
|
552
|
+
|
|
553
|
+
-- Whether the caller may read every note from `first` up to its thread root:
|
|
554
|
+
-- each is the caller's own, or public in a scope they may read. False when one
|
|
555
|
+
-- is missing (its author was deleted), so orphans are hidden too. Reads as the
|
|
556
|
+
-- owner, so it never recurses into the read policy.
|
|
557
|
+
create function contexthive_private.ancestors_readable(first text)
|
|
558
|
+
returns boolean
|
|
559
|
+
language sql
|
|
560
|
+
stable
|
|
561
|
+
security definer
|
|
562
|
+
set search_path = ''
|
|
563
|
+
as $$
|
|
564
|
+
with recursive chain (id, parent_id, readable, depth, last_parent) as (
|
|
565
|
+
select a.id,
|
|
566
|
+
a.parent_id,
|
|
567
|
+
coalesce(
|
|
568
|
+
a.creator_id = auth.uid()
|
|
569
|
+
or (a.audience = 'public' and contexthive_private.public_readable(a.scope)),
|
|
570
|
+
false
|
|
571
|
+
),
|
|
572
|
+
1,
|
|
573
|
+
a.parent_id
|
|
574
|
+
from public.contexthive_annotations a
|
|
575
|
+
where a.id = first
|
|
576
|
+
union all
|
|
577
|
+
select a.id,
|
|
578
|
+
a.parent_id,
|
|
579
|
+
coalesce(
|
|
580
|
+
a.creator_id = auth.uid()
|
|
581
|
+
or (a.audience = 'public' and contexthive_private.public_readable(a.scope)),
|
|
582
|
+
false
|
|
583
|
+
),
|
|
584
|
+
c.depth + 1,
|
|
585
|
+
a.parent_id
|
|
586
|
+
from public.contexthive_annotations a
|
|
587
|
+
join chain c on a.id = c.parent_id
|
|
588
|
+
-- Stop at the first unreadable note. A cycle cannot be written (children
|
|
589
|
+
-- need an existing parent), but stay bounded anyway.
|
|
590
|
+
where c.readable and c.depth < 10000
|
|
591
|
+
)
|
|
592
|
+
select coalesce(bool_and(readable), false)
|
|
593
|
+
-- The top of the chain must be a note on a page, not a dangling parent.
|
|
594
|
+
and bool_or(parent_id is null)
|
|
595
|
+
from chain
|
|
596
|
+
$$;
|
|
597
|
+
|
|
598
|
+
-- The read policy, shared by the policy itself and the checked functions that
|
|
599
|
+
-- run as the owner. A child is hidden from others unless every note above it is
|
|
600
|
+
-- readable to them, so narrowing a note hides the replies below it.
|
|
601
|
+
create function contexthive_private.can_read(r public.contexthive_annotations)
|
|
602
|
+
returns boolean
|
|
603
|
+
language sql
|
|
604
|
+
stable
|
|
605
|
+
security definer
|
|
606
|
+
set search_path = ''
|
|
607
|
+
as $$
|
|
608
|
+
select r.creator_id = auth.uid()
|
|
609
|
+
or (
|
|
610
|
+
r.audience = 'public'
|
|
611
|
+
and contexthive_private.public_readable(r.scope)
|
|
612
|
+
and (r.thread_root_id is null or contexthive_private.root_readable(r.thread_root_id))
|
|
613
|
+
and (r.parent_id is null or contexthive_private.ancestors_readable(r.parent_id))
|
|
614
|
+
)
|
|
615
|
+
$$;
|
|
616
|
+
|
|
617
|
+
-- Whether any stored note targets `note_id`, seen by the owner: children the
|
|
618
|
+
-- caller cannot read count too. A thread root also keeps its place for any
|
|
619
|
+
-- note in its thread, including replies from before threads nested (they name
|
|
620
|
+
-- only their root) and replies whose parent went with its deleted author, since
|
|
621
|
+
-- removing the root would take them with it.
|
|
622
|
+
create function contexthive_private.has_children(note_id text)
|
|
623
|
+
returns boolean
|
|
624
|
+
language sql
|
|
625
|
+
stable
|
|
626
|
+
security definer
|
|
627
|
+
set search_path = ''
|
|
628
|
+
as $$
|
|
629
|
+
select exists (
|
|
630
|
+
select 1 from public.contexthive_annotations c
|
|
631
|
+
where c.parent_id = note_id or c.thread_root_id = note_id
|
|
632
|
+
)
|
|
633
|
+
$$;
|
|
634
|
+
|
|
635
|
+
-- Whether a note is a deleted placeholder.
|
|
636
|
+
create function contexthive_private.is_deleted(a jsonb)
|
|
637
|
+
returns boolean
|
|
638
|
+
language sql
|
|
639
|
+
immutable
|
|
640
|
+
set search_path = ''
|
|
641
|
+
as $$
|
|
642
|
+
select coalesce(a #> '{ext,deleted}' = 'true'::jsonb, false)
|
|
643
|
+
$$;
|
|
644
|
+
|
|
645
|
+
-- Whether `child` reaches no one who cannot read `parent`. Only private and
|
|
646
|
+
-- public exist here: private fits under anything, public only under public.
|
|
647
|
+
create function contexthive_private.audience_within(child text, parent text)
|
|
648
|
+
returns boolean
|
|
649
|
+
language sql
|
|
650
|
+
immutable
|
|
651
|
+
set search_path = ''
|
|
652
|
+
as $$
|
|
653
|
+
select child = 'private' or parent = 'public'
|
|
654
|
+
$$;
|
|
655
|
+
|
|
656
|
+
-- Raises unless the new child `a` (in scope `child_scope`, audience
|
|
657
|
+
-- `child_audience`, under thread root `child_root`) fits under its stored
|
|
658
|
+
-- parent `parent`, which it locks until the transaction ends so a delete
|
|
659
|
+
-- cannot miss the child. Reads the parent as the owner but reports one the
|
|
660
|
+
-- caller cannot read exactly like a missing one; a trusted caller may add
|
|
661
|
+
-- children under any note.
|
|
662
|
+
create function contexthive_private.check_child(
|
|
663
|
+
parent text,
|
|
664
|
+
a jsonb,
|
|
665
|
+
child_scope text,
|
|
666
|
+
child_root text,
|
|
667
|
+
child_audience text
|
|
668
|
+
)
|
|
669
|
+
returns void
|
|
670
|
+
language plpgsql
|
|
671
|
+
volatile
|
|
672
|
+
security definer
|
|
673
|
+
set search_path = ''
|
|
674
|
+
as $$
|
|
675
|
+
declare
|
|
676
|
+
p public.contexthive_annotations;
|
|
677
|
+
parent_rev jsonb := a #> '{ext,parentRev}';
|
|
678
|
+
selectors jsonb := a #> '{target,selector}';
|
|
679
|
+
quoted boolean;
|
|
680
|
+
begin
|
|
681
|
+
select * into p from public.contexthive_annotations r where r.id = parent for share;
|
|
682
|
+
if not found
|
|
683
|
+
or not (contexthive_private.trusted_caller() or contexthive_private.can_read(p))
|
|
684
|
+
then
|
|
685
|
+
raise exception 'annotation % not found', parent using errcode = 'P0002';
|
|
686
|
+
end if;
|
|
687
|
+
if contexthive_private.is_deleted(p.annotation) then
|
|
688
|
+
raise exception 'annotation % is deleted', parent using errcode = 'CH001';
|
|
689
|
+
end if;
|
|
690
|
+
-- A reply from before threads nested shows only on its page.
|
|
691
|
+
if p.parent_id is null and p.thread_root_id is not null then
|
|
692
|
+
raise exception 'annotation % is a reply from before threads nested; reply to its root',
|
|
693
|
+
parent using errcode = 'CH001';
|
|
694
|
+
end if;
|
|
695
|
+
if p.scope <> child_scope then
|
|
696
|
+
raise exception 'annotation % is not in its parent''s scope', a ->> 'id' using errcode = 'CH001';
|
|
697
|
+
end if;
|
|
698
|
+
if child_root is distinct from coalesce(p.thread_root_id, p.id) then
|
|
699
|
+
raise exception 'annotation % is not in its parent''s thread', a ->> 'id'
|
|
700
|
+
using errcode = 'CH001';
|
|
701
|
+
end if;
|
|
702
|
+
if (a #>> '{ext,depth}')::numeric
|
|
703
|
+
is distinct from coalesce((p.annotation #>> '{ext,depth}')::numeric, 0) + 1
|
|
704
|
+
then
|
|
705
|
+
raise exception 'annotation % has the wrong depth', a ->> 'id' using errcode = 'CH001';
|
|
706
|
+
end if;
|
|
707
|
+
if not contexthive_private.audience_within(child_audience, p.audience) then
|
|
708
|
+
raise exception 'annotation % has a wider audience than its parent', a ->> 'id'
|
|
709
|
+
using errcode = 'CH002';
|
|
710
|
+
end if;
|
|
711
|
+
quoted := jsonb_typeof(selectors) = 'array' and exists (
|
|
712
|
+
select 1 from jsonb_array_elements(selectors) s where s ->> 'type' = 'TextQuoteSelector'
|
|
713
|
+
);
|
|
714
|
+
if quoted is distinct from (parent_rev is not null)
|
|
715
|
+
or (parent_rev is not null and ((parent_rev #>> '{}')::numeric < 1
|
|
716
|
+
or (parent_rev #>> '{}')::numeric > p.rev))
|
|
717
|
+
then
|
|
718
|
+
raise exception 'annotation % must record the parent''s rev exactly when it quotes the parent',
|
|
719
|
+
a ->> 'id' using errcode = 'CH001';
|
|
720
|
+
end if;
|
|
721
|
+
end
|
|
722
|
+
$$;
|
|
723
|
+
|
|
724
|
+
-- The audience of `note_id`, read as the owner and locked until the
|
|
725
|
+
-- transaction ends, or 'public' when it is gone (nothing left to protect).
|
|
726
|
+
create function contexthive_private.locked_audience(note_id text)
|
|
727
|
+
returns text
|
|
728
|
+
language sql
|
|
729
|
+
volatile
|
|
730
|
+
security definer
|
|
731
|
+
set search_path = ''
|
|
732
|
+
as $$
|
|
733
|
+
select coalesce(
|
|
734
|
+
(select a.audience from public.contexthive_annotations a where a.id = note_id for share),
|
|
735
|
+
'public'
|
|
736
|
+
)
|
|
737
|
+
$$;
|
|
738
|
+
|
|
739
|
+
-- Whether the current statement runs inside `contexthive_delete_node`, which
|
|
740
|
+
-- writes placeholders and removes children. The flag alone is not enough, as
|
|
741
|
+
-- any SQL session can set a custom setting: the statement must also run as the
|
|
742
|
+
-- table's owner, which only that security-definer function does for a client.
|
|
743
|
+
create function contexthive_private.in_delete_node()
|
|
744
|
+
returns boolean
|
|
745
|
+
language sql
|
|
746
|
+
stable
|
|
747
|
+
set search_path = ''
|
|
748
|
+
as $$
|
|
749
|
+
select coalesce(current_setting('contexthive.delete_node', true), '') = 'on'
|
|
750
|
+
and current_user = (
|
|
751
|
+
select pg_catalog.pg_get_userbyid(c.relowner)
|
|
752
|
+
from pg_catalog.pg_class c
|
|
753
|
+
where c.oid = 'public.contexthive_annotations'::regclass
|
|
754
|
+
)
|
|
755
|
+
$$;
|
|
756
|
+
|
|
757
|
+
-- The checks on the root replaced by `check_child`.
|
|
758
|
+
drop function contexthive_private.check_thread_root(text, text, text, text);
|
|
759
|
+
|
|
760
|
+
revoke all on all functions in schema contexthive_private from public;
|
|
761
|
+
grant execute on function
|
|
762
|
+
contexthive_private.signed_in(),
|
|
763
|
+
contexthive_private.trusted_caller(),
|
|
764
|
+
contexthive_private.public_readable(text),
|
|
765
|
+
contexthive_private.root_readable(text),
|
|
766
|
+
contexthive_private.scope_writable(text),
|
|
767
|
+
contexthive_private.parent_id_of(text),
|
|
768
|
+
contexthive_private.ancestors_readable(text),
|
|
769
|
+
contexthive_private.can_read(public.contexthive_annotations),
|
|
770
|
+
contexthive_private.has_children(text),
|
|
771
|
+
contexthive_private.is_deleted(jsonb),
|
|
772
|
+
contexthive_private.audience_within(text, text),
|
|
773
|
+
contexthive_private.check_child(text, jsonb, text, text, text),
|
|
774
|
+
contexthive_private.locked_audience(text),
|
|
775
|
+
contexthive_private.in_delete_node()
|
|
776
|
+
to anon, authenticated, service_role;
|
|
777
|
+
|
|
778
|
+
-- ---------------------------------------------------------------------------
|
|
779
|
+
-- Write triggers
|
|
780
|
+
-- ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
create or replace function contexthive_private.prepare_annotation()
|
|
783
|
+
returns trigger
|
|
784
|
+
language plpgsql
|
|
785
|
+
set search_path = ''
|
|
786
|
+
as $$
|
|
787
|
+
declare
|
|
788
|
+
a jsonb := new.annotation;
|
|
789
|
+
audience_kind text := a #>> '{ext,audience,kind}';
|
|
790
|
+
-- The site's own server code writing on a user's behalf.
|
|
791
|
+
trusted boolean := contexthive_private.trusted_caller();
|
|
792
|
+
begin
|
|
793
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
794
|
+
or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
|
|
795
|
+
or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
|
|
796
|
+
or jsonb_typeof(a -> 'modified') is distinct from 'string'
|
|
797
|
+
-- ISO 8601 only, so no `infinity`, `now` or other special values.
|
|
798
|
+
or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
|
|
799
|
+
or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
|
|
800
|
+
then
|
|
801
|
+
raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
|
|
802
|
+
using errcode = '23514';
|
|
803
|
+
end if;
|
|
804
|
+
if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
|
|
805
|
+
or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
|
|
806
|
+
or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
|
|
807
|
+
then
|
|
808
|
+
raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
|
|
809
|
+
using errcode = '23514';
|
|
810
|
+
end if;
|
|
811
|
+
|
|
812
|
+
if tg_op = 'INSERT' then
|
|
813
|
+
if not trusted then
|
|
814
|
+
-- Clients cannot write this column; the author is always the caller.
|
|
815
|
+
new.creator_id := auth.uid();
|
|
816
|
+
if new.creator_id is null then
|
|
817
|
+
raise exception 'sign in to write notes' using errcode = '42501';
|
|
818
|
+
end if;
|
|
819
|
+
elsif new.creator_id is null then
|
|
820
|
+
raise exception 'creator_id is required' using errcode = '23502';
|
|
821
|
+
end if;
|
|
822
|
+
new.rev := 1;
|
|
823
|
+
else
|
|
824
|
+
new.creator_id := old.creator_id;
|
|
825
|
+
new.rev := old.rev + 1;
|
|
826
|
+
end if;
|
|
827
|
+
|
|
828
|
+
if audience_kind is null or audience_kind not in ('private', 'public') then
|
|
829
|
+
raise exception 'audience "%" is not supported here; use private or public', audience_kind
|
|
830
|
+
using errcode = '23514';
|
|
831
|
+
end if;
|
|
832
|
+
|
|
833
|
+
new.id := a ->> 'id';
|
|
834
|
+
new.scope := a #>> '{ext,scope}';
|
|
835
|
+
new.document := a #>> '{target,source}';
|
|
836
|
+
new.audience := audience_kind;
|
|
837
|
+
new.thread_root_id := a #>> '{ext,threadRoot}';
|
|
838
|
+
-- The document never changes, so neither does the parent; notes stored
|
|
839
|
+
-- before this migration keep theirs (none) when edited.
|
|
840
|
+
new.parent_id := case
|
|
841
|
+
when tg_op = 'UPDATE' then old.parent_id
|
|
842
|
+
else contexthive_private.parent_id_of(new.document)
|
|
843
|
+
end;
|
|
844
|
+
new.modified := (a ->> 'modified')::timestamptz;
|
|
845
|
+
new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
|
|
846
|
+
|
|
847
|
+
if tg_op = 'INSERT' then
|
|
848
|
+
if contexthive_private.is_deleted(a) then
|
|
849
|
+
raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
|
|
850
|
+
end if;
|
|
851
|
+
if new.parent_id is null then
|
|
852
|
+
if new.thread_root_id is not null
|
|
853
|
+
or a #> '{ext,depth}' is not null
|
|
854
|
+
or a #> '{ext,parentRev}' is not null
|
|
855
|
+
then
|
|
856
|
+
raise exception 'annotation % has thread fields but targets a page', new.id
|
|
857
|
+
using errcode = 'CH001';
|
|
858
|
+
end if;
|
|
859
|
+
else
|
|
860
|
+
perform contexthive_private.check_child(
|
|
861
|
+
new.parent_id, a, new.scope, new.thread_root_id, new.audience
|
|
862
|
+
);
|
|
863
|
+
end if;
|
|
864
|
+
return new;
|
|
865
|
+
end if;
|
|
866
|
+
|
|
867
|
+
if new.id is distinct from old.id then
|
|
868
|
+
raise exception 'id cannot change' using errcode = '23514';
|
|
869
|
+
end if;
|
|
870
|
+
-- `contexthive_delete_node` writing a placeholder.
|
|
871
|
+
if contexthive_private.in_delete_node() then
|
|
872
|
+
return new;
|
|
873
|
+
end if;
|
|
874
|
+
if contexthive_private.is_deleted(a) is distinct from contexthive_private.is_deleted(old.annotation) then
|
|
875
|
+
raise exception 'annotation % is deleted or restored only by deleteNode', new.id
|
|
876
|
+
using errcode = 'CH001';
|
|
877
|
+
end if;
|
|
878
|
+
if contexthive_private.is_deleted(old.annotation) then
|
|
879
|
+
raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
|
|
880
|
+
end if;
|
|
881
|
+
if new.scope is distinct from old.scope
|
|
882
|
+
or new.document is distinct from old.document
|
|
883
|
+
or new.thread_root_id is distinct from old.thread_root_id
|
|
884
|
+
or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
|
|
885
|
+
or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
|
|
886
|
+
or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
|
|
887
|
+
then
|
|
888
|
+
raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
|
|
889
|
+
new.id using errcode = 'CH001';
|
|
890
|
+
end if;
|
|
891
|
+
if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
|
|
892
|
+
and contexthive_private.has_children(new.id)
|
|
893
|
+
then
|
|
894
|
+
raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
|
|
895
|
+
end if;
|
|
896
|
+
-- An unchanged or narrower audience is fine even if the parent was narrowed
|
|
897
|
+
-- meanwhile. A wider one is checked against the parent, locked so a
|
|
898
|
+
-- concurrent narrowing cannot slip past; a reply from before threads nested
|
|
899
|
+
-- is checked against its root.
|
|
900
|
+
if new.thread_root_id is not null
|
|
901
|
+
and not contexthive_private.audience_within(new.audience, old.audience)
|
|
902
|
+
and not contexthive_private.audience_within(
|
|
903
|
+
new.audience,
|
|
904
|
+
contexthive_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
|
|
905
|
+
)
|
|
906
|
+
then
|
|
907
|
+
raise exception 'annotation % cannot widen its parent''s audience', new.id
|
|
908
|
+
using errcode = 'CH002';
|
|
909
|
+
end if;
|
|
910
|
+
return new;
|
|
911
|
+
end
|
|
912
|
+
$$;
|
|
913
|
+
|
|
914
|
+
revoke all on function contexthive_private.prepare_annotation() from public;
|
|
915
|
+
|
|
916
|
+
-- Plain deletes keep the tree; `contexthive_delete_node` and cascades (a
|
|
917
|
+
-- deleted user's notes) are let through.
|
|
918
|
+
create function contexthive_private.check_delete()
|
|
919
|
+
returns trigger
|
|
920
|
+
language plpgsql
|
|
921
|
+
set search_path = ''
|
|
922
|
+
as $$
|
|
923
|
+
begin
|
|
924
|
+
if contexthive_private.in_delete_node() or pg_trigger_depth() > 1 then
|
|
925
|
+
return old;
|
|
926
|
+
end if;
|
|
927
|
+
if old.parent_id is not null then
|
|
928
|
+
raise exception 'annotation % is context on context; use threads.deleteNode', old.id
|
|
929
|
+
using errcode = 'CH001';
|
|
930
|
+
end if;
|
|
931
|
+
if contexthive_private.has_children(old.id) then
|
|
932
|
+
raise exception 'annotation % has children; use threads.deleteNode', old.id
|
|
933
|
+
using errcode = 'CH001';
|
|
934
|
+
end if;
|
|
935
|
+
return old;
|
|
936
|
+
end
|
|
937
|
+
$$;
|
|
938
|
+
|
|
939
|
+
revoke all on function contexthive_private.check_delete() from public;
|
|
940
|
+
|
|
941
|
+
create trigger contexthive_annotations_check_delete
|
|
942
|
+
before delete on public.contexthive_annotations
|
|
943
|
+
for each row execute function contexthive_private.check_delete();
|
|
944
|
+
|
|
945
|
+
-- ---------------------------------------------------------------------------
|
|
946
|
+
-- Reading: replies are hidden below any note the reader cannot read
|
|
947
|
+
-- ---------------------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
drop policy contexthive_annotations_read on public.contexthive_annotations;
|
|
950
|
+
|
|
951
|
+
create policy contexthive_annotations_read on public.contexthive_annotations
|
|
952
|
+
for select to anon, authenticated
|
|
953
|
+
using (
|
|
954
|
+
creator_id = (select auth.uid())
|
|
955
|
+
or (
|
|
956
|
+
audience = 'public'
|
|
957
|
+
and contexthive_private.public_readable(scope)
|
|
958
|
+
and (thread_root_id is null or contexthive_private.root_readable(thread_root_id))
|
|
959
|
+
and (parent_id is null or contexthive_private.ancestors_readable(parent_id))
|
|
960
|
+
)
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
-- No `parent_id` backfill: before this migration a reply had to share its
|
|
964
|
+
-- root's page, so no stored row is context on context.
|
|
965
|
+
|
|
966
|
+
-- ---------------------------------------------------------------------------
|
|
967
|
+
-- Edit history
|
|
968
|
+
-- ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
create table public.contexthive_versions (
|
|
971
|
+
note_id text not null references public.contexthive_annotations (id) on delete cascade,
|
|
972
|
+
rev integer not null check (rev >= 1),
|
|
973
|
+
-- The note's author, set by the trigger.
|
|
974
|
+
creator_id uuid not null references auth.users (id) on delete cascade,
|
|
975
|
+
annotation jsonb not null check (
|
|
976
|
+
jsonb_typeof(annotation) = 'object' and octet_length(annotation::text) <= 65536
|
|
977
|
+
),
|
|
978
|
+
primary key (note_id, rev)
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
create index contexthive_versions_creator_idx on public.contexthive_versions (creator_id);
|
|
982
|
+
|
|
983
|
+
create function contexthive_private.prepare_version()
|
|
984
|
+
returns trigger
|
|
985
|
+
language plpgsql
|
|
986
|
+
set search_path = ''
|
|
987
|
+
as $$
|
|
988
|
+
declare
|
|
989
|
+
a jsonb := new.annotation;
|
|
990
|
+
author uuid;
|
|
991
|
+
begin
|
|
992
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
993
|
+
or jsonb_typeof(a #> '{ext,rev}') is distinct from 'number'
|
|
994
|
+
or (a #>> '{ext,rev}') !~ '^[1-9][0-9]{0,8}$'
|
|
995
|
+
then
|
|
996
|
+
raise exception 'a version needs a string id and a positive integer ext.rev'
|
|
997
|
+
using errcode = '23514';
|
|
998
|
+
end if;
|
|
999
|
+
new.note_id := a ->> 'id';
|
|
1000
|
+
new.rev := (a #>> '{ext,rev}')::integer;
|
|
1001
|
+
if tg_op = 'UPDATE' and (new.note_id <> old.note_id or new.rev <> old.rev) then
|
|
1002
|
+
raise exception 'a saved version keeps its id and rev' using errcode = '23514';
|
|
1003
|
+
end if;
|
|
1004
|
+
-- Read as the caller: a client finds only notes it can read.
|
|
1005
|
+
select n.creator_id into author from public.contexthive_annotations n where n.id = new.note_id;
|
|
1006
|
+
if not found then
|
|
1007
|
+
raise exception 'annotation % not found', new.note_id using errcode = 'P0002';
|
|
1008
|
+
end if;
|
|
1009
|
+
if not contexthive_private.trusted_caller() and author is distinct from auth.uid() then
|
|
1010
|
+
raise exception 'only the author keeps versions of annotation %', new.note_id
|
|
1011
|
+
using errcode = '42501';
|
|
1012
|
+
end if;
|
|
1013
|
+
new.creator_id := author;
|
|
1014
|
+
return new;
|
|
1015
|
+
end
|
|
1016
|
+
$$;
|
|
1017
|
+
|
|
1018
|
+
revoke all on function contexthive_private.prepare_version() from public;
|
|
1019
|
+
|
|
1020
|
+
create trigger contexthive_versions_prepare
|
|
1021
|
+
before insert or update on public.contexthive_versions
|
|
1022
|
+
for each row execute function contexthive_private.prepare_version();
|
|
1023
|
+
|
|
1024
|
+
alter table public.contexthive_versions enable row level security;
|
|
1025
|
+
|
|
1026
|
+
revoke all on table public.contexthive_versions from anon, authenticated;
|
|
1027
|
+
grant select, delete on table public.contexthive_versions to authenticated;
|
|
1028
|
+
-- Visitors read none (no policy admits them), but get an empty list, not an error.
|
|
1029
|
+
grant select on table public.contexthive_versions to anon;
|
|
1030
|
+
grant insert (annotation), update (annotation) on table public.contexthive_versions to authenticated;
|
|
1031
|
+
|
|
1032
|
+
create policy contexthive_versions_read on public.contexthive_versions
|
|
1033
|
+
for select to authenticated
|
|
1034
|
+
using (creator_id = (select auth.uid()));
|
|
1035
|
+
|
|
1036
|
+
create policy contexthive_versions_create on public.contexthive_versions
|
|
1037
|
+
for insert to authenticated
|
|
1038
|
+
with check (creator_id = (select auth.uid()));
|
|
1039
|
+
|
|
1040
|
+
create policy contexthive_versions_update on public.contexthive_versions
|
|
1041
|
+
for update to authenticated
|
|
1042
|
+
using (creator_id = (select auth.uid()))
|
|
1043
|
+
with check (creator_id = (select auth.uid()));
|
|
1044
|
+
|
|
1045
|
+
create policy contexthive_versions_delete on public.contexthive_versions
|
|
1046
|
+
for delete to authenticated
|
|
1047
|
+
using (creator_id = (select auth.uid()));
|
|
1048
|
+
|
|
1049
|
+
-- ---------------------------------------------------------------------------
|
|
1050
|
+
-- Thread functions the adapter calls
|
|
1051
|
+
-- ---------------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
-- The ids in a thread the caller may read: the read policy's rules worked out
|
|
1054
|
+
-- top-down in one pass, seeded with the root and its replies from before
|
|
1055
|
+
-- threads nested and then walking children through the parent index, so the
|
|
1056
|
+
-- cost is linear in the thread's size whatever its shape. The caller's own
|
|
1057
|
+
-- notes are always in, and everything for the site's server. Empty unless
|
|
1058
|
+
-- `root_id` is a note on a page in `thread_scope`.
|
|
1059
|
+
create function contexthive_private.thread_visible(root_id text, thread_scope text)
|
|
1060
|
+
returns setof text
|
|
1061
|
+
language sql
|
|
1062
|
+
stable
|
|
1063
|
+
security definer
|
|
1064
|
+
set search_path = ''
|
|
1065
|
+
as $$
|
|
1066
|
+
with recursive
|
|
1067
|
+
me (uid, trusted, scope_readable) as materialized (
|
|
1068
|
+
select auth.uid(),
|
|
1069
|
+
contexthive_private.trusted_caller(),
|
|
1070
|
+
contexthive_private.public_readable(thread_scope)
|
|
1071
|
+
),
|
|
1072
|
+
tree (id, path_readable, depth) as (
|
|
1073
|
+
select a.id,
|
|
1074
|
+
coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false),
|
|
1075
|
+
0
|
|
1076
|
+
from public.contexthive_annotations a, me
|
|
1077
|
+
where a.id = root_id
|
|
1078
|
+
and a.scope = thread_scope
|
|
1079
|
+
and a.parent_id is null
|
|
1080
|
+
and a.thread_root_id is null
|
|
1081
|
+
union all
|
|
1082
|
+
-- Replies from before threads nested target the page and name the root.
|
|
1083
|
+
select c.id,
|
|
1084
|
+
r.path_readable
|
|
1085
|
+
and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
|
|
1086
|
+
1
|
|
1087
|
+
from public.contexthive_annotations a
|
|
1088
|
+
join public.contexthive_annotations c
|
|
1089
|
+
on c.thread_root_id = a.id and c.parent_id is null and c.scope = thread_scope
|
|
1090
|
+
cross join me
|
|
1091
|
+
cross join lateral (
|
|
1092
|
+
select coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false)
|
|
1093
|
+
) r (path_readable)
|
|
1094
|
+
where a.id = root_id and a.scope = thread_scope and a.thread_root_id is null
|
|
1095
|
+
union all
|
|
1096
|
+
select c.id,
|
|
1097
|
+
t.path_readable
|
|
1098
|
+
and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
|
|
1099
|
+
t.depth + 1
|
|
1100
|
+
from tree t
|
|
1101
|
+
join public.contexthive_annotations c on c.parent_id = t.id
|
|
1102
|
+
cross join me
|
|
1103
|
+
where c.scope = thread_scope and t.depth < 10000
|
|
1104
|
+
)
|
|
1105
|
+
select t.id from tree t, me where t.path_readable or me.trusted
|
|
1106
|
+
union
|
|
1107
|
+
-- The caller's own notes, and everything for the site's server, even below a
|
|
1108
|
+
-- parent that is gone.
|
|
1109
|
+
select a.id
|
|
1110
|
+
from public.contexthive_annotations a, me
|
|
1111
|
+
where a.scope = thread_scope
|
|
1112
|
+
and a.thread_root_id = root_id
|
|
1113
|
+
and (a.creator_id = me.uid or me.trusted)
|
|
1114
|
+
-- Only a real root starts a thread.
|
|
1115
|
+
and exists (select 1 from tree t where t.depth = 0)
|
|
1116
|
+
$$;
|
|
1117
|
+
|
|
1118
|
+
-- Only the functions below call it, as the owner.
|
|
1119
|
+
revoke all on function contexthive_private.thread_visible(text, text) from public;
|
|
1120
|
+
|
|
1121
|
+
-- A thread's root and every node below it in one scope, as the caller may read
|
|
1122
|
+
-- them, as one JSON array of annotations ordered by `modified` and id. One call
|
|
1123
|
+
-- returns everything, as paging would redo the walk for each page.
|
|
1124
|
+
create function public.contexthive_list_thread(root_id text, thread_scope text)
|
|
1125
|
+
returns jsonb
|
|
1126
|
+
language sql
|
|
1127
|
+
stable
|
|
1128
|
+
security definer
|
|
1129
|
+
set search_path = ''
|
|
1130
|
+
as $$
|
|
1131
|
+
select coalesce(jsonb_agg(a.annotation order by a.modified, a.id), '[]'::jsonb)
|
|
1132
|
+
from contexthive_private.thread_visible(root_id, thread_scope) v (id)
|
|
1133
|
+
join public.contexthive_annotations a on a.id = v.id
|
|
1134
|
+
$$;
|
|
1135
|
+
|
|
1136
|
+
-- The number of direct children of each id that the caller may read. Replies
|
|
1137
|
+
-- from before threads nested are left out, as `assembleThread` does not place
|
|
1138
|
+
-- them. Ids with none are left out.
|
|
1139
|
+
-- Visibility comes from one top-down walk per thread involved.
|
|
1140
|
+
create function public.contexthive_count_children(ids text[])
|
|
1141
|
+
returns table (id text, children bigint)
|
|
1142
|
+
language sql
|
|
1143
|
+
stable
|
|
1144
|
+
security definer
|
|
1145
|
+
set search_path = ''
|
|
1146
|
+
as $$
|
|
1147
|
+
with children (parent, child, thread_root_id, scope) as materialized (
|
|
1148
|
+
select c.parent_id, c.id, c.thread_root_id, c.scope
|
|
1149
|
+
from public.contexthive_annotations c
|
|
1150
|
+
where c.parent_id = any (ids)
|
|
1151
|
+
),
|
|
1152
|
+
visible (id) as materialized (
|
|
1153
|
+
select v.id
|
|
1154
|
+
from (select distinct thread_root_id, scope from children) t
|
|
1155
|
+
cross join lateral contexthive_private.thread_visible(t.thread_root_id, t.scope) v (id)
|
|
1156
|
+
)
|
|
1157
|
+
select c.parent, count(*)
|
|
1158
|
+
from children c
|
|
1159
|
+
join visible v on v.id = c.child
|
|
1160
|
+
group by c.parent
|
|
1161
|
+
$$;
|
|
1162
|
+
|
|
1163
|
+
-- Deletes `note_id` at `expected_rev` for its author (or the site's server).
|
|
1164
|
+
-- A note with any child becomes a placeholder at the next rev, modified at
|
|
1165
|
+
-- `deleted_at`: no body, author name or edited mark, `ext.deleted` true, and
|
|
1166
|
+
-- its versions dropped. One without children is removed, and so is each
|
|
1167
|
+
-- placeholder above it left with no children. Returns
|
|
1168
|
+
-- `{"placeholder": <annotation>}` or `{"removed": [{id, scope, document}, ...]}`,
|
|
1169
|
+
-- the note first.
|
|
1170
|
+
create function public.contexthive_delete_node(
|
|
1171
|
+
note_id text,
|
|
1172
|
+
expected_rev integer,
|
|
1173
|
+
deleted_at text
|
|
1174
|
+
)
|
|
1175
|
+
returns jsonb
|
|
1176
|
+
language plpgsql
|
|
1177
|
+
volatile
|
|
1178
|
+
security definer
|
|
1179
|
+
set search_path = ''
|
|
1180
|
+
as $$
|
|
1181
|
+
declare
|
|
1182
|
+
target public.contexthive_annotations;
|
|
1183
|
+
node public.contexthive_annotations;
|
|
1184
|
+
above public.contexthive_annotations;
|
|
1185
|
+
placeholder jsonb;
|
|
1186
|
+
removed jsonb := '[]'::jsonb;
|
|
1187
|
+
trusted boolean := contexthive_private.trusted_caller();
|
|
1188
|
+
begin
|
|
1189
|
+
select * into target from public.contexthive_annotations a where a.id = note_id for update;
|
|
1190
|
+
if not found or not (trusted or contexthive_private.can_read(target)) then
|
|
1191
|
+
raise exception 'annotation % not found', note_id using errcode = 'P0002';
|
|
1192
|
+
end if;
|
|
1193
|
+
if not trusted and target.creator_id is distinct from auth.uid() then
|
|
1194
|
+
raise exception 'Not allowed to change annotation %', note_id using errcode = '42501';
|
|
1195
|
+
end if;
|
|
1196
|
+
if target.rev <> expected_rev then
|
|
1197
|
+
raise exception 'annotation % is at rev %, expected %', note_id, target.rev, expected_rev
|
|
1198
|
+
using errcode = 'CH003', detail = target.rev::text;
|
|
1199
|
+
end if;
|
|
1200
|
+
|
|
1201
|
+
perform set_config('contexthive.delete_node', 'on', true);
|
|
1202
|
+
|
|
1203
|
+
if contexthive_private.has_children(note_id) then
|
|
1204
|
+
placeholder := (target.annotation - 'body') || jsonb_build_object('modified', deleted_at);
|
|
1205
|
+
placeholder := jsonb_set(
|
|
1206
|
+
placeholder,
|
|
1207
|
+
'{ext}',
|
|
1208
|
+
((target.annotation -> 'ext') - 'edited') || '{"deleted": true}'::jsonb
|
|
1209
|
+
);
|
|
1210
|
+
if jsonb_typeof(target.annotation -> 'creator') = 'object' then
|
|
1211
|
+
placeholder := jsonb_set(
|
|
1212
|
+
placeholder,
|
|
1213
|
+
'{creator}',
|
|
1214
|
+
jsonb_strip_nulls(jsonb_build_object('id', target.annotation #> '{creator,id}'))
|
|
1215
|
+
);
|
|
1216
|
+
end if;
|
|
1217
|
+
update public.contexthive_annotations a
|
|
1218
|
+
set annotation = placeholder
|
|
1219
|
+
where a.id = note_id
|
|
1220
|
+
returning a.annotation into placeholder;
|
|
1221
|
+
delete from public.contexthive_versions v where v.note_id = target.id;
|
|
1222
|
+
perform set_config('contexthive.delete_node', '', true);
|
|
1223
|
+
return jsonb_build_object('placeholder', placeholder);
|
|
1224
|
+
end if;
|
|
1225
|
+
|
|
1226
|
+
-- Remove it, then each placeholder above it left with no children. Each
|
|
1227
|
+
-- parent is locked before its children are counted, so a child landing
|
|
1228
|
+
-- meanwhile either waits for this delete or is seen.
|
|
1229
|
+
node := target;
|
|
1230
|
+
loop
|
|
1231
|
+
delete from public.contexthive_annotations a where a.id = node.id;
|
|
1232
|
+
removed := removed || jsonb_build_object(
|
|
1233
|
+
'id', node.id, 'scope', node.scope, 'document', node.document
|
|
1234
|
+
);
|
|
1235
|
+
exit when coalesce(node.parent_id, node.thread_root_id) is null;
|
|
1236
|
+
select * into above from public.contexthive_annotations a
|
|
1237
|
+
where a.id = coalesce(node.parent_id, node.thread_root_id)
|
|
1238
|
+
for update;
|
|
1239
|
+
-- An orphan's parent went with its author: its root may still be waiting.
|
|
1240
|
+
if not found and node.parent_id is not null and node.thread_root_id is not null then
|
|
1241
|
+
select * into above from public.contexthive_annotations a
|
|
1242
|
+
where a.id = node.thread_root_id
|
|
1243
|
+
for update;
|
|
1244
|
+
end if;
|
|
1245
|
+
exit when not found
|
|
1246
|
+
or not contexthive_private.is_deleted(above.annotation)
|
|
1247
|
+
or contexthive_private.has_children(above.id);
|
|
1248
|
+
node := above;
|
|
1249
|
+
end loop;
|
|
1250
|
+
perform set_config('contexthive.delete_node', '', true);
|
|
1251
|
+
return jsonb_build_object('removed', removed);
|
|
1252
|
+
end
|
|
1253
|
+
$$;
|
|
1254
|
+
|
|
1255
|
+
revoke all on function
|
|
1256
|
+
public.contexthive_list_thread(text, text),
|
|
1257
|
+
public.contexthive_count_children(text[]),
|
|
1258
|
+
public.contexthive_delete_node(text, integer, text)
|
|
1259
|
+
from public;
|
|
1260
|
+
grant execute on function
|
|
1261
|
+
public.contexthive_list_thread(text, text),
|
|
1262
|
+
public.contexthive_count_children(text[])
|
|
1263
|
+
to anon, authenticated, service_role;
|
|
1264
|
+
grant execute on function public.contexthive_delete_node(text, integer, text)
|
|
1265
|
+
to authenticated, service_role;
|
|
1266
|
+
$acornly_migration$;
|
|
1267
|
+
insert into acornly_meta.migrations (version, name) values ('20261005130000', 'contexthive_threads');
|
|
1268
|
+
end if;
|
|
1269
|
+
end
|
|
1270
|
+
$acornly_install$;
|
|
1271
|
+
|
|
1272
|
+
-- 20261010070000_acornly_names.sql
|
|
1273
|
+
do $acornly_install$
|
|
1274
|
+
begin
|
|
1275
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261010070000') then
|
|
1276
|
+
raise notice 'acornly: applying %', '20261010070000_acornly_names.sql';
|
|
1277
|
+
execute $acornly_migration$
|
|
1278
|
+
-- Renames everything the earlier migrations in this folder created from
|
|
1279
|
+
-- `contexthive` to `acornly`, as the product is now Acornly.
|
|
1280
|
+
--
|
|
1281
|
+
-- Renaming keeps each object's data, grants, row level security, policies,
|
|
1282
|
+
-- triggers and Realtime publication, since those follow the object. Function
|
|
1283
|
+
-- bodies are stored as text, though, so every function that names a renamed
|
|
1284
|
+
-- object is recreated below with the new names. The delete-node flag becomes
|
|
1285
|
+
-- `acornly.delete_node`; its reader and its writer change together here.
|
|
1286
|
+
--
|
|
1287
|
+
-- Left as they are: the `urn:contexthive:annotation:` target prefix (part of
|
|
1288
|
+
-- the stored notes, not of the schema) and the CH001 to CH003 error codes the
|
|
1289
|
+
-- adapter maps.
|
|
1290
|
+
|
|
1291
|
+
-- ---------------------------------------------------------------------------
|
|
1292
|
+
-- Schema and tables
|
|
1293
|
+
-- ---------------------------------------------------------------------------
|
|
1294
|
+
|
|
1295
|
+
alter schema contexthive_private rename to acornly_private;
|
|
1296
|
+
|
|
1297
|
+
alter table public.contexthive_scopes rename to acornly_scopes;
|
|
1298
|
+
alter table public.contexthive_annotations rename to acornly_annotations;
|
|
1299
|
+
alter table public.contexthive_versions rename to acornly_versions;
|
|
1300
|
+
|
|
1301
|
+
-- Constraints (primary keys rename their index too), then the other indexes,
|
|
1302
|
+
-- including the names Postgres chose itself such as `..._scope_check`.
|
|
1303
|
+
do $$
|
|
1304
|
+
declare
|
|
1305
|
+
c record;
|
|
1306
|
+
begin
|
|
1307
|
+
for c in
|
|
1308
|
+
select con.conrelid::regclass as tbl, con.conname
|
|
1309
|
+
from pg_catalog.pg_constraint con
|
|
1310
|
+
where con.conrelid in (
|
|
1311
|
+
'public.acornly_scopes'::regclass,
|
|
1312
|
+
'public.acornly_annotations'::regclass,
|
|
1313
|
+
'public.acornly_versions'::regclass
|
|
1314
|
+
)
|
|
1315
|
+
and con.conname like 'contexthive\_%'
|
|
1316
|
+
loop
|
|
1317
|
+
execute format(
|
|
1318
|
+
'alter table %s rename constraint %I to %I',
|
|
1319
|
+
c.tbl, c.conname, 'acornly_' || substr(c.conname, length('contexthive_') + 1)
|
|
1320
|
+
);
|
|
1321
|
+
end loop;
|
|
1322
|
+
|
|
1323
|
+
for c in
|
|
1324
|
+
select i.relname
|
|
1325
|
+
from pg_catalog.pg_index x
|
|
1326
|
+
join pg_catalog.pg_class i on i.oid = x.indexrelid
|
|
1327
|
+
where x.indrelid in (
|
|
1328
|
+
'public.acornly_scopes'::regclass,
|
|
1329
|
+
'public.acornly_annotations'::regclass,
|
|
1330
|
+
'public.acornly_versions'::regclass
|
|
1331
|
+
)
|
|
1332
|
+
and i.relname like 'contexthive\_%'
|
|
1333
|
+
loop
|
|
1334
|
+
execute format(
|
|
1335
|
+
'alter index public.%I rename to %I',
|
|
1336
|
+
c.relname, 'acornly_' || substr(c.relname, length('contexthive_') + 1)
|
|
1337
|
+
);
|
|
1338
|
+
end loop;
|
|
1339
|
+
end
|
|
1340
|
+
$$;
|
|
1341
|
+
|
|
1342
|
+
-- ---------------------------------------------------------------------------
|
|
1343
|
+
-- Triggers and policies
|
|
1344
|
+
-- ---------------------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
alter trigger contexthive_annotations_prepare on public.acornly_annotations
|
|
1347
|
+
rename to acornly_annotations_prepare;
|
|
1348
|
+
alter trigger contexthive_annotations_check_delete on public.acornly_annotations
|
|
1349
|
+
rename to acornly_annotations_check_delete;
|
|
1350
|
+
alter trigger contexthive_versions_prepare on public.acornly_versions
|
|
1351
|
+
rename to acornly_versions_prepare;
|
|
1352
|
+
|
|
1353
|
+
alter policy contexthive_annotations_read on public.acornly_annotations
|
|
1354
|
+
rename to acornly_annotations_read;
|
|
1355
|
+
alter policy contexthive_annotations_create on public.acornly_annotations
|
|
1356
|
+
rename to acornly_annotations_create;
|
|
1357
|
+
alter policy contexthive_annotations_update on public.acornly_annotations
|
|
1358
|
+
rename to acornly_annotations_update;
|
|
1359
|
+
alter policy contexthive_annotations_delete on public.acornly_annotations
|
|
1360
|
+
rename to acornly_annotations_delete;
|
|
1361
|
+
|
|
1362
|
+
alter policy contexthive_versions_read on public.acornly_versions
|
|
1363
|
+
rename to acornly_versions_read;
|
|
1364
|
+
alter policy contexthive_versions_create on public.acornly_versions
|
|
1365
|
+
rename to acornly_versions_create;
|
|
1366
|
+
alter policy contexthive_versions_update on public.acornly_versions
|
|
1367
|
+
rename to acornly_versions_update;
|
|
1368
|
+
alter policy contexthive_versions_delete on public.acornly_versions
|
|
1369
|
+
rename to acornly_versions_delete;
|
|
1370
|
+
|
|
1371
|
+
-- ---------------------------------------------------------------------------
|
|
1372
|
+
-- Functions: the adapter's entry points get the new prefix, and every function
|
|
1373
|
+
-- naming a renamed object is recreated. `create or replace` keeps each one's
|
|
1374
|
+
-- owner and grants. The helpers below name no renamed object and are left:
|
|
1375
|
+
-- signed_in, trusted_caller, is_deleted, audience_within and parent_id_of.
|
|
1376
|
+
-- ---------------------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
alter function public.contexthive_list_thread(text, text) rename to acornly_list_thread;
|
|
1379
|
+
alter function public.contexthive_count_children(text[]) rename to acornly_count_children;
|
|
1380
|
+
alter function public.contexthive_delete_node(text, integer, text) rename to acornly_delete_node;
|
|
1381
|
+
|
|
1382
|
+
-- Whether the caller may read public notes in `note_scope`: anyone signed in,
|
|
1383
|
+
-- and anonymous visitors where the site turned on `anonymous_read`.
|
|
1384
|
+
create or replace function acornly_private.public_readable(note_scope text)
|
|
1385
|
+
returns boolean
|
|
1386
|
+
language sql
|
|
1387
|
+
stable
|
|
1388
|
+
security definer
|
|
1389
|
+
set search_path = ''
|
|
1390
|
+
as $$
|
|
1391
|
+
select acornly_private.signed_in()
|
|
1392
|
+
or exists (
|
|
1393
|
+
select 1 from public.acornly_scopes s
|
|
1394
|
+
where s.scope = note_scope and s.anonymous_read
|
|
1395
|
+
)
|
|
1396
|
+
$$;
|
|
1397
|
+
|
|
1398
|
+
-- Whether the caller may read the thread root `root_id`. Roots have no root of
|
|
1399
|
+
-- their own, so this never recurses.
|
|
1400
|
+
create or replace function acornly_private.root_readable(root_id text)
|
|
1401
|
+
returns boolean
|
|
1402
|
+
language sql
|
|
1403
|
+
stable
|
|
1404
|
+
security definer
|
|
1405
|
+
set search_path = ''
|
|
1406
|
+
as $$
|
|
1407
|
+
select exists (
|
|
1408
|
+
select 1 from public.acornly_annotations r
|
|
1409
|
+
where r.id = root_id
|
|
1410
|
+
and (
|
|
1411
|
+
r.creator_id = auth.uid()
|
|
1412
|
+
or (r.audience = 'public' and acornly_private.public_readable(r.scope))
|
|
1413
|
+
)
|
|
1414
|
+
)
|
|
1415
|
+
$$;
|
|
1416
|
+
|
|
1417
|
+
-- Whether the signed-in caller may write notes in `note_scope`.
|
|
1418
|
+
create or replace function acornly_private.scope_writable(note_scope text)
|
|
1419
|
+
returns boolean
|
|
1420
|
+
language sql
|
|
1421
|
+
stable
|
|
1422
|
+
security definer
|
|
1423
|
+
set search_path = ''
|
|
1424
|
+
as $$
|
|
1425
|
+
select acornly_private.signed_in()
|
|
1426
|
+
and (
|
|
1427
|
+
note_scope = 'user:' || auth.uid()::text
|
|
1428
|
+
or exists (select 1 from public.acornly_scopes s where s.scope = note_scope)
|
|
1429
|
+
)
|
|
1430
|
+
$$;
|
|
1431
|
+
|
|
1432
|
+
-- Whether the caller may read every note from `first` up to its thread root:
|
|
1433
|
+
-- each is the caller's own, or public in a scope they may read. False when one
|
|
1434
|
+
-- is missing (its author was deleted), so orphans are hidden too. Reads as the
|
|
1435
|
+
-- owner, so it never recurses into the read policy.
|
|
1436
|
+
create or replace function acornly_private.ancestors_readable(first text)
|
|
1437
|
+
returns boolean
|
|
1438
|
+
language sql
|
|
1439
|
+
stable
|
|
1440
|
+
security definer
|
|
1441
|
+
set search_path = ''
|
|
1442
|
+
as $$
|
|
1443
|
+
with recursive chain (id, parent_id, readable, depth, last_parent) as (
|
|
1444
|
+
select a.id,
|
|
1445
|
+
a.parent_id,
|
|
1446
|
+
coalesce(
|
|
1447
|
+
a.creator_id = auth.uid()
|
|
1448
|
+
or (a.audience = 'public' and acornly_private.public_readable(a.scope)),
|
|
1449
|
+
false
|
|
1450
|
+
),
|
|
1451
|
+
1,
|
|
1452
|
+
a.parent_id
|
|
1453
|
+
from public.acornly_annotations a
|
|
1454
|
+
where a.id = first
|
|
1455
|
+
union all
|
|
1456
|
+
select a.id,
|
|
1457
|
+
a.parent_id,
|
|
1458
|
+
coalesce(
|
|
1459
|
+
a.creator_id = auth.uid()
|
|
1460
|
+
or (a.audience = 'public' and acornly_private.public_readable(a.scope)),
|
|
1461
|
+
false
|
|
1462
|
+
),
|
|
1463
|
+
c.depth + 1,
|
|
1464
|
+
a.parent_id
|
|
1465
|
+
from public.acornly_annotations a
|
|
1466
|
+
join chain c on a.id = c.parent_id
|
|
1467
|
+
-- Stop at the first unreadable note. A cycle cannot be written (children
|
|
1468
|
+
-- need an existing parent), but stay bounded anyway.
|
|
1469
|
+
where c.readable and c.depth < 10000
|
|
1470
|
+
)
|
|
1471
|
+
select coalesce(bool_and(readable), false)
|
|
1472
|
+
-- The top of the chain must be a note on a page, not a dangling parent.
|
|
1473
|
+
and bool_or(parent_id is null)
|
|
1474
|
+
from chain
|
|
1475
|
+
$$;
|
|
1476
|
+
|
|
1477
|
+
-- The read policy, shared by the policy itself and the checked functions that
|
|
1478
|
+
-- run as the owner. A child is hidden from others unless every note above it is
|
|
1479
|
+
-- readable to them, so narrowing a note hides the replies below it.
|
|
1480
|
+
create or replace function acornly_private.can_read(r public.acornly_annotations)
|
|
1481
|
+
returns boolean
|
|
1482
|
+
language sql
|
|
1483
|
+
stable
|
|
1484
|
+
security definer
|
|
1485
|
+
set search_path = ''
|
|
1486
|
+
as $$
|
|
1487
|
+
select r.creator_id = auth.uid()
|
|
1488
|
+
or (
|
|
1489
|
+
r.audience = 'public'
|
|
1490
|
+
and acornly_private.public_readable(r.scope)
|
|
1491
|
+
and (r.thread_root_id is null or acornly_private.root_readable(r.thread_root_id))
|
|
1492
|
+
and (r.parent_id is null or acornly_private.ancestors_readable(r.parent_id))
|
|
1493
|
+
)
|
|
1494
|
+
$$;
|
|
1495
|
+
|
|
1496
|
+
-- Whether any stored note targets `note_id`, seen by the owner: children the
|
|
1497
|
+
-- caller cannot read count too. A thread root also keeps its place for any
|
|
1498
|
+
-- note in its thread, including replies from before threads nested (they name
|
|
1499
|
+
-- only their root) and replies whose parent went with its deleted author, since
|
|
1500
|
+
-- removing the root would take them with it.
|
|
1501
|
+
create or replace function acornly_private.has_children(note_id text)
|
|
1502
|
+
returns boolean
|
|
1503
|
+
language sql
|
|
1504
|
+
stable
|
|
1505
|
+
security definer
|
|
1506
|
+
set search_path = ''
|
|
1507
|
+
as $$
|
|
1508
|
+
select exists (
|
|
1509
|
+
select 1 from public.acornly_annotations c
|
|
1510
|
+
where c.parent_id = note_id or c.thread_root_id = note_id
|
|
1511
|
+
)
|
|
1512
|
+
$$;
|
|
1513
|
+
|
|
1514
|
+
-- Raises unless the new child `a` (in scope `child_scope`, audience
|
|
1515
|
+
-- `child_audience`, under thread root `child_root`) fits under its stored
|
|
1516
|
+
-- parent `parent`, which it locks until the transaction ends so a delete
|
|
1517
|
+
-- cannot miss the child. Reads the parent as the owner but reports one the
|
|
1518
|
+
-- caller cannot read exactly like a missing one; a trusted caller may add
|
|
1519
|
+
-- children under any note.
|
|
1520
|
+
create or replace function acornly_private.check_child(
|
|
1521
|
+
parent text,
|
|
1522
|
+
a jsonb,
|
|
1523
|
+
child_scope text,
|
|
1524
|
+
child_root text,
|
|
1525
|
+
child_audience text
|
|
1526
|
+
)
|
|
1527
|
+
returns void
|
|
1528
|
+
language plpgsql
|
|
1529
|
+
volatile
|
|
1530
|
+
security definer
|
|
1531
|
+
set search_path = ''
|
|
1532
|
+
as $$
|
|
1533
|
+
declare
|
|
1534
|
+
p public.acornly_annotations;
|
|
1535
|
+
parent_rev jsonb := a #> '{ext,parentRev}';
|
|
1536
|
+
selectors jsonb := a #> '{target,selector}';
|
|
1537
|
+
quoted boolean;
|
|
1538
|
+
begin
|
|
1539
|
+
select * into p from public.acornly_annotations r where r.id = parent for share;
|
|
1540
|
+
if not found
|
|
1541
|
+
or not (acornly_private.trusted_caller() or acornly_private.can_read(p))
|
|
1542
|
+
then
|
|
1543
|
+
raise exception 'annotation % not found', parent using errcode = 'P0002';
|
|
1544
|
+
end if;
|
|
1545
|
+
if acornly_private.is_deleted(p.annotation) then
|
|
1546
|
+
raise exception 'annotation % is deleted', parent using errcode = 'CH001';
|
|
1547
|
+
end if;
|
|
1548
|
+
-- A reply from before threads nested shows only on its page.
|
|
1549
|
+
if p.parent_id is null and p.thread_root_id is not null then
|
|
1550
|
+
raise exception 'annotation % is a reply from before threads nested; reply to its root',
|
|
1551
|
+
parent using errcode = 'CH001';
|
|
1552
|
+
end if;
|
|
1553
|
+
if p.scope <> child_scope then
|
|
1554
|
+
raise exception 'annotation % is not in its parent''s scope', a ->> 'id' using errcode = 'CH001';
|
|
1555
|
+
end if;
|
|
1556
|
+
if child_root is distinct from coalesce(p.thread_root_id, p.id) then
|
|
1557
|
+
raise exception 'annotation % is not in its parent''s thread', a ->> 'id'
|
|
1558
|
+
using errcode = 'CH001';
|
|
1559
|
+
end if;
|
|
1560
|
+
if (a #>> '{ext,depth}')::numeric
|
|
1561
|
+
is distinct from coalesce((p.annotation #>> '{ext,depth}')::numeric, 0) + 1
|
|
1562
|
+
then
|
|
1563
|
+
raise exception 'annotation % has the wrong depth', a ->> 'id' using errcode = 'CH001';
|
|
1564
|
+
end if;
|
|
1565
|
+
if not acornly_private.audience_within(child_audience, p.audience) then
|
|
1566
|
+
raise exception 'annotation % has a wider audience than its parent', a ->> 'id'
|
|
1567
|
+
using errcode = 'CH002';
|
|
1568
|
+
end if;
|
|
1569
|
+
quoted := jsonb_typeof(selectors) = 'array' and exists (
|
|
1570
|
+
select 1 from jsonb_array_elements(selectors) s where s ->> 'type' = 'TextQuoteSelector'
|
|
1571
|
+
);
|
|
1572
|
+
if quoted is distinct from (parent_rev is not null)
|
|
1573
|
+
or (parent_rev is not null and ((parent_rev #>> '{}')::numeric < 1
|
|
1574
|
+
or (parent_rev #>> '{}')::numeric > p.rev))
|
|
1575
|
+
then
|
|
1576
|
+
raise exception 'annotation % must record the parent''s rev exactly when it quotes the parent',
|
|
1577
|
+
a ->> 'id' using errcode = 'CH001';
|
|
1578
|
+
end if;
|
|
1579
|
+
end
|
|
1580
|
+
$$;
|
|
1581
|
+
|
|
1582
|
+
-- The audience of `note_id`, read as the owner and locked until the
|
|
1583
|
+
-- transaction ends, or 'public' when it is gone (nothing left to protect).
|
|
1584
|
+
create or replace function acornly_private.locked_audience(note_id text)
|
|
1585
|
+
returns text
|
|
1586
|
+
language sql
|
|
1587
|
+
volatile
|
|
1588
|
+
security definer
|
|
1589
|
+
set search_path = ''
|
|
1590
|
+
as $$
|
|
1591
|
+
select coalesce(
|
|
1592
|
+
(select a.audience from public.acornly_annotations a where a.id = note_id for share),
|
|
1593
|
+
'public'
|
|
1594
|
+
)
|
|
1595
|
+
$$;
|
|
1596
|
+
|
|
1597
|
+
-- Whether the current statement runs inside `acornly_delete_node`, which
|
|
1598
|
+
-- writes placeholders and removes children. The flag alone is not enough, as
|
|
1599
|
+
-- any SQL session can set a custom setting: the statement must also run as the
|
|
1600
|
+
-- table's owner, which only that security-definer function does for a client.
|
|
1601
|
+
create or replace function acornly_private.in_delete_node()
|
|
1602
|
+
returns boolean
|
|
1603
|
+
language sql
|
|
1604
|
+
stable
|
|
1605
|
+
set search_path = ''
|
|
1606
|
+
as $$
|
|
1607
|
+
select coalesce(current_setting('acornly.delete_node', true), '') = 'on'
|
|
1608
|
+
and current_user = (
|
|
1609
|
+
select pg_catalog.pg_get_userbyid(c.relowner)
|
|
1610
|
+
from pg_catalog.pg_class c
|
|
1611
|
+
where c.oid = 'public.acornly_annotations'::regclass
|
|
1612
|
+
)
|
|
1613
|
+
$$;
|
|
1614
|
+
|
|
1615
|
+
-- The write trigger on notes.
|
|
1616
|
+
create or replace function acornly_private.prepare_annotation()
|
|
1617
|
+
returns trigger
|
|
1618
|
+
language plpgsql
|
|
1619
|
+
set search_path = ''
|
|
1620
|
+
as $$
|
|
1621
|
+
declare
|
|
1622
|
+
a jsonb := new.annotation;
|
|
1623
|
+
audience_kind text := a #>> '{ext,audience,kind}';
|
|
1624
|
+
-- The site's own server code writing on a user's behalf.
|
|
1625
|
+
trusted boolean := acornly_private.trusted_caller();
|
|
1626
|
+
begin
|
|
1627
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
1628
|
+
or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
|
|
1629
|
+
or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
|
|
1630
|
+
or jsonb_typeof(a -> 'modified') is distinct from 'string'
|
|
1631
|
+
-- ISO 8601 only, so no `infinity`, `now` or other special values.
|
|
1632
|
+
or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
|
|
1633
|
+
or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
|
|
1634
|
+
then
|
|
1635
|
+
raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
|
|
1636
|
+
using errcode = '23514';
|
|
1637
|
+
end if;
|
|
1638
|
+
if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
|
|
1639
|
+
or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
|
|
1640
|
+
or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
|
|
1641
|
+
then
|
|
1642
|
+
raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
|
|
1643
|
+
using errcode = '23514';
|
|
1644
|
+
end if;
|
|
1645
|
+
|
|
1646
|
+
if tg_op = 'INSERT' then
|
|
1647
|
+
if not trusted then
|
|
1648
|
+
-- Clients cannot write this column; the author is always the caller.
|
|
1649
|
+
new.creator_id := auth.uid();
|
|
1650
|
+
if new.creator_id is null then
|
|
1651
|
+
raise exception 'sign in to write notes' using errcode = '42501';
|
|
1652
|
+
end if;
|
|
1653
|
+
elsif new.creator_id is null then
|
|
1654
|
+
raise exception 'creator_id is required' using errcode = '23502';
|
|
1655
|
+
end if;
|
|
1656
|
+
new.rev := 1;
|
|
1657
|
+
else
|
|
1658
|
+
new.creator_id := old.creator_id;
|
|
1659
|
+
new.rev := old.rev + 1;
|
|
1660
|
+
end if;
|
|
1661
|
+
|
|
1662
|
+
if audience_kind is null or audience_kind not in ('private', 'public') then
|
|
1663
|
+
raise exception 'audience "%" is not supported here; use private or public', audience_kind
|
|
1664
|
+
using errcode = '23514';
|
|
1665
|
+
end if;
|
|
1666
|
+
|
|
1667
|
+
new.id := a ->> 'id';
|
|
1668
|
+
new.scope := a #>> '{ext,scope}';
|
|
1669
|
+
new.document := a #>> '{target,source}';
|
|
1670
|
+
new.audience := audience_kind;
|
|
1671
|
+
new.thread_root_id := a #>> '{ext,threadRoot}';
|
|
1672
|
+
-- The document never changes, so neither does the parent; notes stored
|
|
1673
|
+
-- before this migration keep theirs (none) when edited.
|
|
1674
|
+
new.parent_id := case
|
|
1675
|
+
when tg_op = 'UPDATE' then old.parent_id
|
|
1676
|
+
else acornly_private.parent_id_of(new.document)
|
|
1677
|
+
end;
|
|
1678
|
+
new.modified := (a ->> 'modified')::timestamptz;
|
|
1679
|
+
new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
|
|
1680
|
+
|
|
1681
|
+
if tg_op = 'INSERT' then
|
|
1682
|
+
if acornly_private.is_deleted(a) then
|
|
1683
|
+
raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
|
|
1684
|
+
end if;
|
|
1685
|
+
if new.parent_id is null then
|
|
1686
|
+
if new.thread_root_id is not null
|
|
1687
|
+
or a #> '{ext,depth}' is not null
|
|
1688
|
+
or a #> '{ext,parentRev}' is not null
|
|
1689
|
+
then
|
|
1690
|
+
raise exception 'annotation % has thread fields but targets a page', new.id
|
|
1691
|
+
using errcode = 'CH001';
|
|
1692
|
+
end if;
|
|
1693
|
+
else
|
|
1694
|
+
perform acornly_private.check_child(
|
|
1695
|
+
new.parent_id, a, new.scope, new.thread_root_id, new.audience
|
|
1696
|
+
);
|
|
1697
|
+
end if;
|
|
1698
|
+
return new;
|
|
1699
|
+
end if;
|
|
1700
|
+
|
|
1701
|
+
if new.id is distinct from old.id then
|
|
1702
|
+
raise exception 'id cannot change' using errcode = '23514';
|
|
1703
|
+
end if;
|
|
1704
|
+
-- `acornly_delete_node` writing a placeholder.
|
|
1705
|
+
if acornly_private.in_delete_node() then
|
|
1706
|
+
return new;
|
|
1707
|
+
end if;
|
|
1708
|
+
if acornly_private.is_deleted(a) is distinct from acornly_private.is_deleted(old.annotation) then
|
|
1709
|
+
raise exception 'annotation % is deleted or restored only by deleteNode', new.id
|
|
1710
|
+
using errcode = 'CH001';
|
|
1711
|
+
end if;
|
|
1712
|
+
if acornly_private.is_deleted(old.annotation) then
|
|
1713
|
+
raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
|
|
1714
|
+
end if;
|
|
1715
|
+
if new.scope is distinct from old.scope
|
|
1716
|
+
or new.document is distinct from old.document
|
|
1717
|
+
or new.thread_root_id is distinct from old.thread_root_id
|
|
1718
|
+
or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
|
|
1719
|
+
or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
|
|
1720
|
+
or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
|
|
1721
|
+
then
|
|
1722
|
+
raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
|
|
1723
|
+
new.id using errcode = 'CH001';
|
|
1724
|
+
end if;
|
|
1725
|
+
if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
|
|
1726
|
+
and acornly_private.has_children(new.id)
|
|
1727
|
+
then
|
|
1728
|
+
raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
|
|
1729
|
+
end if;
|
|
1730
|
+
-- An unchanged or narrower audience is fine even if the parent was narrowed
|
|
1731
|
+
-- meanwhile. A wider one is checked against the parent, locked so a
|
|
1732
|
+
-- concurrent narrowing cannot slip past; a reply from before threads nested
|
|
1733
|
+
-- is checked against its root.
|
|
1734
|
+
if new.thread_root_id is not null
|
|
1735
|
+
and not acornly_private.audience_within(new.audience, old.audience)
|
|
1736
|
+
and not acornly_private.audience_within(
|
|
1737
|
+
new.audience,
|
|
1738
|
+
acornly_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
|
|
1739
|
+
)
|
|
1740
|
+
then
|
|
1741
|
+
raise exception 'annotation % cannot widen its parent''s audience', new.id
|
|
1742
|
+
using errcode = 'CH002';
|
|
1743
|
+
end if;
|
|
1744
|
+
return new;
|
|
1745
|
+
end
|
|
1746
|
+
$$;
|
|
1747
|
+
|
|
1748
|
+
-- Plain deletes keep the tree; `acornly_delete_node` and cascades (a
|
|
1749
|
+
-- deleted user's notes) are let through.
|
|
1750
|
+
create or replace function acornly_private.check_delete()
|
|
1751
|
+
returns trigger
|
|
1752
|
+
language plpgsql
|
|
1753
|
+
set search_path = ''
|
|
1754
|
+
as $$
|
|
1755
|
+
begin
|
|
1756
|
+
if acornly_private.in_delete_node() or pg_trigger_depth() > 1 then
|
|
1757
|
+
return old;
|
|
1758
|
+
end if;
|
|
1759
|
+
if old.parent_id is not null then
|
|
1760
|
+
raise exception 'annotation % is context on context; use threads.deleteNode', old.id
|
|
1761
|
+
using errcode = 'CH001';
|
|
1762
|
+
end if;
|
|
1763
|
+
if acornly_private.has_children(old.id) then
|
|
1764
|
+
raise exception 'annotation % has children; use threads.deleteNode', old.id
|
|
1765
|
+
using errcode = 'CH001';
|
|
1766
|
+
end if;
|
|
1767
|
+
return old;
|
|
1768
|
+
end
|
|
1769
|
+
$$;
|
|
1770
|
+
|
|
1771
|
+
-- The write trigger on versions.
|
|
1772
|
+
create or replace function acornly_private.prepare_version()
|
|
1773
|
+
returns trigger
|
|
1774
|
+
language plpgsql
|
|
1775
|
+
set search_path = ''
|
|
1776
|
+
as $$
|
|
1777
|
+
declare
|
|
1778
|
+
a jsonb := new.annotation;
|
|
1779
|
+
author uuid;
|
|
1780
|
+
begin
|
|
1781
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
1782
|
+
or jsonb_typeof(a #> '{ext,rev}') is distinct from 'number'
|
|
1783
|
+
or (a #>> '{ext,rev}') !~ '^[1-9][0-9]{0,8}$'
|
|
1784
|
+
then
|
|
1785
|
+
raise exception 'a version needs a string id and a positive integer ext.rev'
|
|
1786
|
+
using errcode = '23514';
|
|
1787
|
+
end if;
|
|
1788
|
+
new.note_id := a ->> 'id';
|
|
1789
|
+
new.rev := (a #>> '{ext,rev}')::integer;
|
|
1790
|
+
if tg_op = 'UPDATE' and (new.note_id <> old.note_id or new.rev <> old.rev) then
|
|
1791
|
+
raise exception 'a saved version keeps its id and rev' using errcode = '23514';
|
|
1792
|
+
end if;
|
|
1793
|
+
-- Read as the caller: a client finds only notes it can read.
|
|
1794
|
+
select n.creator_id into author from public.acornly_annotations n where n.id = new.note_id;
|
|
1795
|
+
if not found then
|
|
1796
|
+
raise exception 'annotation % not found', new.note_id using errcode = 'P0002';
|
|
1797
|
+
end if;
|
|
1798
|
+
if not acornly_private.trusted_caller() and author is distinct from auth.uid() then
|
|
1799
|
+
raise exception 'only the author keeps versions of annotation %', new.note_id
|
|
1800
|
+
using errcode = '42501';
|
|
1801
|
+
end if;
|
|
1802
|
+
new.creator_id := author;
|
|
1803
|
+
return new;
|
|
1804
|
+
end
|
|
1805
|
+
$$;
|
|
1806
|
+
|
|
1807
|
+
-- The ids in a thread the caller may read: the read policy's rules worked out
|
|
1808
|
+
-- top-down in one pass, seeded with the root and its replies from before
|
|
1809
|
+
-- threads nested and then walking children through the parent index, so the
|
|
1810
|
+
-- cost is linear in the thread's size whatever its shape. The caller's own
|
|
1811
|
+
-- notes are always in, and everything for the site's server. Empty unless
|
|
1812
|
+
-- `root_id` is a note on a page in `thread_scope`.
|
|
1813
|
+
create or replace function acornly_private.thread_visible(root_id text, thread_scope text)
|
|
1814
|
+
returns setof text
|
|
1815
|
+
language sql
|
|
1816
|
+
stable
|
|
1817
|
+
security definer
|
|
1818
|
+
set search_path = ''
|
|
1819
|
+
as $$
|
|
1820
|
+
with recursive
|
|
1821
|
+
me (uid, trusted, scope_readable) as materialized (
|
|
1822
|
+
select auth.uid(),
|
|
1823
|
+
acornly_private.trusted_caller(),
|
|
1824
|
+
acornly_private.public_readable(thread_scope)
|
|
1825
|
+
),
|
|
1826
|
+
tree (id, path_readable, depth) as (
|
|
1827
|
+
select a.id,
|
|
1828
|
+
coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false),
|
|
1829
|
+
0
|
|
1830
|
+
from public.acornly_annotations a, me
|
|
1831
|
+
where a.id = root_id
|
|
1832
|
+
and a.scope = thread_scope
|
|
1833
|
+
and a.parent_id is null
|
|
1834
|
+
and a.thread_root_id is null
|
|
1835
|
+
union all
|
|
1836
|
+
-- Replies from before threads nested target the page and name the root.
|
|
1837
|
+
select c.id,
|
|
1838
|
+
r.path_readable
|
|
1839
|
+
and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
|
|
1840
|
+
1
|
|
1841
|
+
from public.acornly_annotations a
|
|
1842
|
+
join public.acornly_annotations c
|
|
1843
|
+
on c.thread_root_id = a.id and c.parent_id is null and c.scope = thread_scope
|
|
1844
|
+
cross join me
|
|
1845
|
+
cross join lateral (
|
|
1846
|
+
select coalesce(a.creator_id = me.uid or (a.audience = 'public' and me.scope_readable), false)
|
|
1847
|
+
) r (path_readable)
|
|
1848
|
+
where a.id = root_id and a.scope = thread_scope and a.thread_root_id is null
|
|
1849
|
+
union all
|
|
1850
|
+
select c.id,
|
|
1851
|
+
t.path_readable
|
|
1852
|
+
and coalesce(c.creator_id = me.uid or (c.audience = 'public' and me.scope_readable), false),
|
|
1853
|
+
t.depth + 1
|
|
1854
|
+
from tree t
|
|
1855
|
+
join public.acornly_annotations c on c.parent_id = t.id
|
|
1856
|
+
cross join me
|
|
1857
|
+
where c.scope = thread_scope and t.depth < 10000
|
|
1858
|
+
)
|
|
1859
|
+
select t.id from tree t, me where t.path_readable or me.trusted
|
|
1860
|
+
union
|
|
1861
|
+
-- The caller's own notes, and everything for the site's server, even below a
|
|
1862
|
+
-- parent that is gone.
|
|
1863
|
+
select a.id
|
|
1864
|
+
from public.acornly_annotations a, me
|
|
1865
|
+
where a.scope = thread_scope
|
|
1866
|
+
and a.thread_root_id = root_id
|
|
1867
|
+
and (a.creator_id = me.uid or me.trusted)
|
|
1868
|
+
-- Only a real root starts a thread.
|
|
1869
|
+
and exists (select 1 from tree t where t.depth = 0)
|
|
1870
|
+
$$;
|
|
1871
|
+
|
|
1872
|
+
-- A thread's root and every node below it in one scope, as the caller may read
|
|
1873
|
+
-- them, as one JSON array of annotations ordered by `modified` and id. One call
|
|
1874
|
+
-- returns everything, as paging would redo the walk for each page.
|
|
1875
|
+
create or replace function public.acornly_list_thread(root_id text, thread_scope text)
|
|
1876
|
+
returns jsonb
|
|
1877
|
+
language sql
|
|
1878
|
+
stable
|
|
1879
|
+
security definer
|
|
1880
|
+
set search_path = ''
|
|
1881
|
+
as $$
|
|
1882
|
+
select coalesce(jsonb_agg(a.annotation order by a.modified, a.id), '[]'::jsonb)
|
|
1883
|
+
from acornly_private.thread_visible(root_id, thread_scope) v (id)
|
|
1884
|
+
join public.acornly_annotations a on a.id = v.id
|
|
1885
|
+
$$;
|
|
1886
|
+
|
|
1887
|
+
-- The number of direct children of each id that the caller may read. Replies
|
|
1888
|
+
-- from before threads nested are left out, as `assembleThread` does not place
|
|
1889
|
+
-- them. Ids with none are left out.
|
|
1890
|
+
-- Visibility comes from one top-down walk per thread involved.
|
|
1891
|
+
create or replace function public.acornly_count_children(ids text[])
|
|
1892
|
+
returns table (id text, children bigint)
|
|
1893
|
+
language sql
|
|
1894
|
+
stable
|
|
1895
|
+
security definer
|
|
1896
|
+
set search_path = ''
|
|
1897
|
+
as $$
|
|
1898
|
+
with children (parent, child, thread_root_id, scope) as materialized (
|
|
1899
|
+
select c.parent_id, c.id, c.thread_root_id, c.scope
|
|
1900
|
+
from public.acornly_annotations c
|
|
1901
|
+
where c.parent_id = any (ids)
|
|
1902
|
+
),
|
|
1903
|
+
visible (id) as materialized (
|
|
1904
|
+
select v.id
|
|
1905
|
+
from (select distinct thread_root_id, scope from children) t
|
|
1906
|
+
cross join lateral acornly_private.thread_visible(t.thread_root_id, t.scope) v (id)
|
|
1907
|
+
)
|
|
1908
|
+
select c.parent, count(*)
|
|
1909
|
+
from children c
|
|
1910
|
+
join visible v on v.id = c.child
|
|
1911
|
+
group by c.parent
|
|
1912
|
+
$$;
|
|
1913
|
+
|
|
1914
|
+
-- Deletes `note_id` at `expected_rev` for its author (or the site's server).
|
|
1915
|
+
-- A note with any child becomes a placeholder at the next rev, modified at
|
|
1916
|
+
-- `deleted_at`: no body, author name or edited mark, `ext.deleted` true, and
|
|
1917
|
+
-- its versions dropped. One without children is removed, and so is each
|
|
1918
|
+
-- placeholder above it left with no children. Returns
|
|
1919
|
+
-- `{"placeholder": <annotation>}` or `{"removed": [{id, scope, document}, ...]}`,
|
|
1920
|
+
-- the note first.
|
|
1921
|
+
create or replace function public.acornly_delete_node(
|
|
1922
|
+
note_id text,
|
|
1923
|
+
expected_rev integer,
|
|
1924
|
+
deleted_at text
|
|
1925
|
+
)
|
|
1926
|
+
returns jsonb
|
|
1927
|
+
language plpgsql
|
|
1928
|
+
volatile
|
|
1929
|
+
security definer
|
|
1930
|
+
set search_path = ''
|
|
1931
|
+
as $$
|
|
1932
|
+
declare
|
|
1933
|
+
target public.acornly_annotations;
|
|
1934
|
+
node public.acornly_annotations;
|
|
1935
|
+
above public.acornly_annotations;
|
|
1936
|
+
placeholder jsonb;
|
|
1937
|
+
removed jsonb := '[]'::jsonb;
|
|
1938
|
+
trusted boolean := acornly_private.trusted_caller();
|
|
1939
|
+
begin
|
|
1940
|
+
select * into target from public.acornly_annotations a where a.id = note_id for update;
|
|
1941
|
+
if not found or not (trusted or acornly_private.can_read(target)) then
|
|
1942
|
+
raise exception 'annotation % not found', note_id using errcode = 'P0002';
|
|
1943
|
+
end if;
|
|
1944
|
+
if not trusted and target.creator_id is distinct from auth.uid() then
|
|
1945
|
+
raise exception 'Not allowed to change annotation %', note_id using errcode = '42501';
|
|
1946
|
+
end if;
|
|
1947
|
+
if target.rev <> expected_rev then
|
|
1948
|
+
raise exception 'annotation % is at rev %, expected %', note_id, target.rev, expected_rev
|
|
1949
|
+
using errcode = 'CH003', detail = target.rev::text;
|
|
1950
|
+
end if;
|
|
1951
|
+
|
|
1952
|
+
perform set_config('acornly.delete_node', 'on', true);
|
|
1953
|
+
|
|
1954
|
+
if acornly_private.has_children(note_id) then
|
|
1955
|
+
placeholder := (target.annotation - 'body') || jsonb_build_object('modified', deleted_at);
|
|
1956
|
+
placeholder := jsonb_set(
|
|
1957
|
+
placeholder,
|
|
1958
|
+
'{ext}',
|
|
1959
|
+
((target.annotation -> 'ext') - 'edited') || '{"deleted": true}'::jsonb
|
|
1960
|
+
);
|
|
1961
|
+
if jsonb_typeof(target.annotation -> 'creator') = 'object' then
|
|
1962
|
+
placeholder := jsonb_set(
|
|
1963
|
+
placeholder,
|
|
1964
|
+
'{creator}',
|
|
1965
|
+
jsonb_strip_nulls(jsonb_build_object('id', target.annotation #> '{creator,id}'))
|
|
1966
|
+
);
|
|
1967
|
+
end if;
|
|
1968
|
+
update public.acornly_annotations a
|
|
1969
|
+
set annotation = placeholder
|
|
1970
|
+
where a.id = note_id
|
|
1971
|
+
returning a.annotation into placeholder;
|
|
1972
|
+
delete from public.acornly_versions v where v.note_id = target.id;
|
|
1973
|
+
perform set_config('acornly.delete_node', '', true);
|
|
1974
|
+
return jsonb_build_object('placeholder', placeholder);
|
|
1975
|
+
end if;
|
|
1976
|
+
|
|
1977
|
+
-- Remove it, then each placeholder above it left with no children. Each
|
|
1978
|
+
-- parent is locked before its children are counted, so a child landing
|
|
1979
|
+
-- meanwhile either waits for this delete or is seen.
|
|
1980
|
+
node := target;
|
|
1981
|
+
loop
|
|
1982
|
+
delete from public.acornly_annotations a where a.id = node.id;
|
|
1983
|
+
removed := removed || jsonb_build_object(
|
|
1984
|
+
'id', node.id, 'scope', node.scope, 'document', node.document
|
|
1985
|
+
);
|
|
1986
|
+
exit when coalesce(node.parent_id, node.thread_root_id) is null;
|
|
1987
|
+
select * into above from public.acornly_annotations a
|
|
1988
|
+
where a.id = coalesce(node.parent_id, node.thread_root_id)
|
|
1989
|
+
for update;
|
|
1990
|
+
-- An orphan's parent went with its author: its root may still be waiting.
|
|
1991
|
+
if not found and node.parent_id is not null and node.thread_root_id is not null then
|
|
1992
|
+
select * into above from public.acornly_annotations a
|
|
1993
|
+
where a.id = node.thread_root_id
|
|
1994
|
+
for update;
|
|
1995
|
+
end if;
|
|
1996
|
+
exit when not found
|
|
1997
|
+
or not acornly_private.is_deleted(above.annotation)
|
|
1998
|
+
or acornly_private.has_children(above.id);
|
|
1999
|
+
node := above;
|
|
2000
|
+
end loop;
|
|
2001
|
+
perform set_config('acornly.delete_node', '', true);
|
|
2002
|
+
return jsonb_build_object('removed', removed);
|
|
2003
|
+
end
|
|
2004
|
+
$$;
|
|
2005
|
+
$acornly_migration$;
|
|
2006
|
+
insert into acornly_meta.migrations (version, name) values ('20261010070000', 'acornly_names');
|
|
2007
|
+
end if;
|
|
2008
|
+
end
|
|
2009
|
+
$acornly_install$;
|
|
2010
|
+
|
|
2011
|
+
-- 20261011080000_acornly_delete_node_grants.sql
|
|
2012
|
+
do $acornly_install$
|
|
2013
|
+
begin
|
|
2014
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261011080000') then
|
|
2015
|
+
raise notice 'acornly: applying %', '20261011080000_acornly_delete_node_grants.sql';
|
|
2016
|
+
execute $acornly_migration$
|
|
2017
|
+
-- Only signed-in callers (and the service role) may run `acornly_delete_node`,
|
|
2018
|
+
-- as the threads migration meant.
|
|
2019
|
+
--
|
|
2020
|
+
-- That migration revoked it from `public` and granted it to `authenticated`
|
|
2021
|
+
-- and `service_role`, but Supabase's default privileges give `anon` its own
|
|
2022
|
+
-- grant on every function created in `public`, and revoking from `public`
|
|
2023
|
+
-- leaves that grant in place. A visitor could not delete anything with it,
|
|
2024
|
+
-- since only a note's creator may and every note has one, but the function
|
|
2025
|
+
-- should refuse them before it looks a note up.
|
|
2026
|
+
revoke execute on function public.acornly_delete_node(text, integer, text) from anon;
|
|
2027
|
+
$acornly_migration$;
|
|
2028
|
+
insert into acornly_meta.migrations (version, name) values ('20261011080000', 'acornly_delete_node_grants');
|
|
2029
|
+
end if;
|
|
2030
|
+
end
|
|
2031
|
+
$acornly_install$;
|
|
2032
|
+
|
|
2033
|
+
-- 20261011150000_acornly_parent_ids.sql
|
|
2034
|
+
do $acornly_install$
|
|
2035
|
+
begin
|
|
2036
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261011150000') then
|
|
2037
|
+
raise notice 'acornly: applying %', '20261011150000_acornly_parent_ids.sql';
|
|
2038
|
+
execute $acornly_migration$
|
|
2039
|
+
-- A reply's target may name its parent under `urn:acornly:annotation:` as well as
|
|
2040
|
+
-- `urn:contexthive:annotation:` (docs/design/acornly-rename.md, phase 3). Without this, a reply
|
|
2041
|
+
-- written by a version of core that uses the new prefix is refused (CH001: thread fields on a note
|
|
2042
|
+
-- that targets a page).
|
|
2043
|
+
--
|
|
2044
|
+
-- create or replace keeps the function's owner and grants.
|
|
2045
|
+
create or replace function acornly_private.parent_id_of(source text)
|
|
2046
|
+
returns text
|
|
2047
|
+
language plpgsql
|
|
2048
|
+
stable
|
|
2049
|
+
set search_path = ''
|
|
2050
|
+
as $$
|
|
2051
|
+
declare
|
|
2052
|
+
prefix text;
|
|
2053
|
+
encoded text;
|
|
2054
|
+
decoded text;
|
|
2055
|
+
begin
|
|
2056
|
+
prefix := case
|
|
2057
|
+
when lower(left(source, 23)) = 'urn:acornly:annotation:' then 'urn:acornly:annotation:'
|
|
2058
|
+
when lower(left(source, 27)) = 'urn:contexthive:annotation:' then 'urn:contexthive:annotation:'
|
|
2059
|
+
end;
|
|
2060
|
+
if prefix is null then
|
|
2061
|
+
return null;
|
|
2062
|
+
end if;
|
|
2063
|
+
encoded := substr(source, length(prefix) + 1);
|
|
2064
|
+
if encoded !~ '^([^%]|%[0-9A-Fa-f]{2})*$' then
|
|
2065
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
2066
|
+
end if;
|
|
2067
|
+
begin
|
|
2068
|
+
select convert_from(
|
|
2069
|
+
coalesce(
|
|
2070
|
+
string_agg(
|
|
2071
|
+
case when m[1] is not null
|
|
2072
|
+
then decode(substr(m[1], 2), 'hex')
|
|
2073
|
+
else convert_to(m[2], 'UTF8')
|
|
2074
|
+
end,
|
|
2075
|
+
''::bytea order by n
|
|
2076
|
+
),
|
|
2077
|
+
''::bytea
|
|
2078
|
+
),
|
|
2079
|
+
'UTF8'
|
|
2080
|
+
)
|
|
2081
|
+
into decoded
|
|
2082
|
+
from regexp_matches(encoded, '(%[0-9A-Fa-f]{2})|([^%]+)', 'g') with ordinality as t (m, n);
|
|
2083
|
+
exception when character_not_in_repertoire or untranslatable_character then
|
|
2084
|
+
raise exception 'target % is badly escaped', source using errcode = 'CH001';
|
|
2085
|
+
end;
|
|
2086
|
+
if decoded = '' then
|
|
2087
|
+
raise exception 'target % names an empty id', source using errcode = 'CH001';
|
|
2088
|
+
end if;
|
|
2089
|
+
return decoded;
|
|
2090
|
+
end
|
|
2091
|
+
$$;
|
|
2092
|
+
$acornly_migration$;
|
|
2093
|
+
insert into acornly_meta.migrations (version, name) values ('20261011150000', 'acornly_parent_ids');
|
|
2094
|
+
end if;
|
|
2095
|
+
end
|
|
2096
|
+
$acornly_install$;
|
|
2097
|
+
|
|
2098
|
+
-- 20261011170000_acornly_pdf_document_keys.sql
|
|
2099
|
+
do $acornly_install$
|
|
2100
|
+
begin
|
|
2101
|
+
if not exists (select 1 from acornly_meta.migrations where version = '20261011170000') then
|
|
2102
|
+
raise notice 'acornly: applying %', '20261011170000_acornly_pdf_document_keys.sql';
|
|
2103
|
+
execute $acornly_migration$
|
|
2104
|
+
-- Notes on PDFs are listed by the file's key (#403).
|
|
2105
|
+
--
|
|
2106
|
+
-- A note on a PDF carries `ext.documentKey`, `urn:x-pdf:<fingerprint>` (see
|
|
2107
|
+
-- `pdfDocumentKey` in @acornly/core), and `target.source` keeps the URL it was
|
|
2108
|
+
-- made on, so the same file opened from another URL shows its notes. The
|
|
2109
|
+
-- `document` column now holds that key, as `documentKeyOf` reads it: the key
|
|
2110
|
+
-- when it is a valid PDF key, else `target.source`. Notes stored before keep
|
|
2111
|
+
-- their source as their document.
|
|
2112
|
+
--
|
|
2113
|
+
-- The key is set when a note is made and never changes: an update that leaves
|
|
2114
|
+
-- it out, or names another, gets the stored one back, and a note's
|
|
2115
|
+
-- `target.source` stays as before. A reply's document is still its target
|
|
2116
|
+
-- (whatever key it carries), and its parent is still read from it. Notes stored
|
|
2117
|
+
-- before with a key already in their annotation move to it below.
|
|
2118
|
+
|
|
2119
|
+
-- `documentKeyOf` for a stored annotation. The trigger runs as its caller, so
|
|
2120
|
+
-- every role that writes notes may call it, as the other helpers it uses.
|
|
2121
|
+
create or replace function acornly_private.document_of(a jsonb)
|
|
2122
|
+
returns text
|
|
2123
|
+
language sql
|
|
2124
|
+
immutable
|
|
2125
|
+
set search_path = ''
|
|
2126
|
+
as $$
|
|
2127
|
+
select case
|
|
2128
|
+
when jsonb_typeof(a #> '{ext,documentKey}') = 'string'
|
|
2129
|
+
and (a #>> '{ext,documentKey}') ~ '^urn:x-pdf:[0-9a-f]{2,128}$'
|
|
2130
|
+
and (a #>> '{target,source}') !~* '^urn:(acornly|contexthive):annotation:'
|
|
2131
|
+
then a #>> '{ext,documentKey}'
|
|
2132
|
+
else a #>> '{target,source}'
|
|
2133
|
+
end
|
|
2134
|
+
$$;
|
|
2135
|
+
|
|
2136
|
+
revoke all on function acornly_private.document_of(jsonb) from public;
|
|
2137
|
+
grant execute on function acornly_private.document_of(jsonb)
|
|
2138
|
+
to anon, authenticated, service_role;
|
|
2139
|
+
|
|
2140
|
+
-- The write trigger on notes, from 20261010070000_acornly_names.sql with the
|
|
2141
|
+
-- document key.
|
|
2142
|
+
create or replace function acornly_private.prepare_annotation()
|
|
2143
|
+
returns trigger
|
|
2144
|
+
language plpgsql
|
|
2145
|
+
set search_path = ''
|
|
2146
|
+
as $$
|
|
2147
|
+
declare
|
|
2148
|
+
a jsonb := new.annotation;
|
|
2149
|
+
audience_kind text := a #>> '{ext,audience,kind}';
|
|
2150
|
+
-- The site's own server code writing on a user's behalf.
|
|
2151
|
+
trusted boolean := acornly_private.trusted_caller();
|
|
2152
|
+
begin
|
|
2153
|
+
if jsonb_typeof(a -> 'id') is distinct from 'string'
|
|
2154
|
+
or jsonb_typeof(a #> '{ext,scope}') is distinct from 'string'
|
|
2155
|
+
or jsonb_typeof(a #> '{target,source}') is distinct from 'string'
|
|
2156
|
+
or jsonb_typeof(a -> 'modified') is distinct from 'string'
|
|
2157
|
+
-- ISO 8601 only, so no `infinity`, `now` or other special values.
|
|
2158
|
+
or (a ->> 'modified') !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)$'
|
|
2159
|
+
or coalesce(jsonb_typeof(a #> '{ext,threadRoot}'), 'null') not in ('string', 'null')
|
|
2160
|
+
then
|
|
2161
|
+
raise exception 'id, ext.scope and target.source must be strings, modified an ISO 8601 time, and ext.threadRoot a string or null'
|
|
2162
|
+
using errcode = '23514';
|
|
2163
|
+
end if;
|
|
2164
|
+
if coalesce(jsonb_typeof(a #> '{ext,depth}'), 'null') not in ('number', 'null')
|
|
2165
|
+
or coalesce(jsonb_typeof(a #> '{ext,parentRev}'), 'null') not in ('number', 'null')
|
|
2166
|
+
or coalesce(jsonb_typeof(a #> '{ext,deleted}'), 'null') not in ('boolean', 'null')
|
|
2167
|
+
then
|
|
2168
|
+
raise exception 'ext.depth and ext.parentRev must be numbers and ext.deleted a boolean'
|
|
2169
|
+
using errcode = '23514';
|
|
2170
|
+
end if;
|
|
2171
|
+
|
|
2172
|
+
if tg_op = 'INSERT' then
|
|
2173
|
+
if not trusted then
|
|
2174
|
+
-- Clients cannot write this column; the author is always the caller.
|
|
2175
|
+
new.creator_id := auth.uid();
|
|
2176
|
+
if new.creator_id is null then
|
|
2177
|
+
raise exception 'sign in to write notes' using errcode = '42501';
|
|
2178
|
+
end if;
|
|
2179
|
+
elsif new.creator_id is null then
|
|
2180
|
+
raise exception 'creator_id is required' using errcode = '23502';
|
|
2181
|
+
end if;
|
|
2182
|
+
new.rev := 1;
|
|
2183
|
+
else
|
|
2184
|
+
new.creator_id := old.creator_id;
|
|
2185
|
+
new.rev := old.rev + 1;
|
|
2186
|
+
-- A note's document key never changes: the stored one, or none, stays.
|
|
2187
|
+
a := case
|
|
2188
|
+
when old.annotation #> '{ext,documentKey}' is null then a #- '{ext,documentKey}'
|
|
2189
|
+
else jsonb_set(a, '{ext,documentKey}', old.annotation #> '{ext,documentKey}')
|
|
2190
|
+
end;
|
|
2191
|
+
end if;
|
|
2192
|
+
|
|
2193
|
+
if audience_kind is null or audience_kind not in ('private', 'public') then
|
|
2194
|
+
raise exception 'audience "%" is not supported here; use private or public', audience_kind
|
|
2195
|
+
using errcode = '23514';
|
|
2196
|
+
end if;
|
|
2197
|
+
|
|
2198
|
+
new.id := a ->> 'id';
|
|
2199
|
+
new.scope := a #>> '{ext,scope}';
|
|
2200
|
+
new.document := acornly_private.document_of(a);
|
|
2201
|
+
new.audience := audience_kind;
|
|
2202
|
+
new.thread_root_id := a #>> '{ext,threadRoot}';
|
|
2203
|
+
-- The document never changes, so neither does the parent; notes stored
|
|
2204
|
+
-- before this migration keep theirs (none) when edited.
|
|
2205
|
+
new.parent_id := case
|
|
2206
|
+
when tg_op = 'UPDATE' then old.parent_id
|
|
2207
|
+
else acornly_private.parent_id_of(a #>> '{target,source}')
|
|
2208
|
+
end;
|
|
2209
|
+
new.modified := (a ->> 'modified')::timestamptz;
|
|
2210
|
+
new.annotation := jsonb_set(a, '{ext,rev}', to_jsonb(new.rev));
|
|
2211
|
+
|
|
2212
|
+
if tg_op = 'INSERT' then
|
|
2213
|
+
if acornly_private.is_deleted(a) then
|
|
2214
|
+
raise exception 'a new annotation cannot be deleted' using errcode = 'CH001';
|
|
2215
|
+
end if;
|
|
2216
|
+
if new.parent_id is null then
|
|
2217
|
+
if new.thread_root_id is not null
|
|
2218
|
+
or a #> '{ext,depth}' is not null
|
|
2219
|
+
or a #> '{ext,parentRev}' is not null
|
|
2220
|
+
then
|
|
2221
|
+
raise exception 'annotation % has thread fields but targets a page', new.id
|
|
2222
|
+
using errcode = 'CH001';
|
|
2223
|
+
end if;
|
|
2224
|
+
else
|
|
2225
|
+
perform acornly_private.check_child(
|
|
2226
|
+
new.parent_id, a, new.scope, new.thread_root_id, new.audience
|
|
2227
|
+
);
|
|
2228
|
+
end if;
|
|
2229
|
+
return new;
|
|
2230
|
+
end if;
|
|
2231
|
+
|
|
2232
|
+
if new.id is distinct from old.id then
|
|
2233
|
+
raise exception 'id cannot change' using errcode = '23514';
|
|
2234
|
+
end if;
|
|
2235
|
+
-- `acornly_delete_node` writing a placeholder.
|
|
2236
|
+
if acornly_private.in_delete_node() then
|
|
2237
|
+
return new;
|
|
2238
|
+
end if;
|
|
2239
|
+
if acornly_private.is_deleted(a) is distinct from acornly_private.is_deleted(old.annotation) then
|
|
2240
|
+
raise exception 'annotation % is deleted or restored only by deleteNode', new.id
|
|
2241
|
+
using errcode = 'CH001';
|
|
2242
|
+
end if;
|
|
2243
|
+
if acornly_private.is_deleted(old.annotation) then
|
|
2244
|
+
raise exception 'annotation % is deleted', new.id using errcode = 'CH001';
|
|
2245
|
+
end if;
|
|
2246
|
+
if new.scope is distinct from old.scope
|
|
2247
|
+
or new.document is distinct from old.document
|
|
2248
|
+
or new.thread_root_id is distinct from old.thread_root_id
|
|
2249
|
+
or a #> '{ext,depth}' is distinct from old.annotation #> '{ext,depth}'
|
|
2250
|
+
or a #> '{ext,parentRev}' is distinct from old.annotation #> '{ext,parentRev}'
|
|
2251
|
+
or (old.parent_id is not null and a -> 'target' is distinct from old.annotation -> 'target')
|
|
2252
|
+
-- The document no longer says whether the source moved: a note on a PDF keeps its URL too.
|
|
2253
|
+
or a #> '{target,source}' is distinct from old.annotation #> '{target,source}'
|
|
2254
|
+
then
|
|
2255
|
+
raise exception 'annotation % cannot move: id, scope, target.source and threadRoot cannot change',
|
|
2256
|
+
new.id using errcode = 'CH001';
|
|
2257
|
+
end if;
|
|
2258
|
+
if a #> '{creator,id}' is distinct from old.annotation #> '{creator,id}'
|
|
2259
|
+
and acornly_private.has_children(new.id)
|
|
2260
|
+
then
|
|
2261
|
+
raise exception 'annotation % has replies; its author stays', new.id using errcode = 'CH001';
|
|
2262
|
+
end if;
|
|
2263
|
+
-- An unchanged or narrower audience is fine even if the parent was narrowed
|
|
2264
|
+
-- meanwhile. A wider one is checked against the parent, locked so a
|
|
2265
|
+
-- concurrent narrowing cannot slip past; a reply from before threads nested
|
|
2266
|
+
-- is checked against its root.
|
|
2267
|
+
if new.thread_root_id is not null
|
|
2268
|
+
and not acornly_private.audience_within(new.audience, old.audience)
|
|
2269
|
+
and not acornly_private.audience_within(
|
|
2270
|
+
new.audience,
|
|
2271
|
+
acornly_private.locked_audience(coalesce(old.parent_id, old.thread_root_id))
|
|
2272
|
+
)
|
|
2273
|
+
then
|
|
2274
|
+
raise exception 'annotation % cannot widen its parent''s audience', new.id
|
|
2275
|
+
using errcode = 'CH002';
|
|
2276
|
+
end if;
|
|
2277
|
+
return new;
|
|
2278
|
+
end
|
|
2279
|
+
$$;
|
|
2280
|
+
|
|
2281
|
+
-- Notes stored before this migration with a PDF key in their annotation (written
|
|
2282
|
+
-- after core gave notes keys) move to it. The write trigger would refuse the
|
|
2283
|
+
-- move, so it is off for this one statement.
|
|
2284
|
+
alter table public.acornly_annotations disable trigger acornly_annotations_prepare;
|
|
2285
|
+
update public.acornly_annotations
|
|
2286
|
+
set document = acornly_private.document_of(annotation)
|
|
2287
|
+
where document is distinct from acornly_private.document_of(annotation);
|
|
2288
|
+
alter table public.acornly_annotations enable trigger acornly_annotations_prepare;
|
|
2289
|
+
$acornly_migration$;
|
|
2290
|
+
insert into acornly_meta.migrations (version, name) values ('20261011170000', 'acornly_pdf_document_keys');
|
|
2291
|
+
end if;
|
|
2292
|
+
end
|
|
2293
|
+
$acornly_install$;
|
|
2294
|
+
|
|
2295
|
+
commit;
|