Skip to content

Commit 8ac51f7

Browse files
committed
moderation
1 parent e7c8e70 commit 8ac51f7

5 files changed

Lines changed: 167 additions & 55 deletions

File tree

postgres-language-server.jsonc

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"$schema": "https://pg-language-server.com/0.24.0/schema.json",
3+
"extends": [],
4+
"vcs": {
5+
"enabled": false,
6+
"clientKind": "git",
7+
"useIgnoreFile": false
8+
},
9+
"files": {
10+
"ignore": []
11+
},
12+
"linter": {
13+
"enabled": true,
14+
"rules": {
15+
"recommended": true
16+
}
17+
},
18+
"splinter": {
19+
"enabled": true
20+
},
21+
"format": {
22+
"enabled": false
23+
},
24+
"pglinter": {
25+
"enabled": true
26+
},
27+
"typecheck": {
28+
"enabled": true,
29+
"searchPath": ["*"]
30+
},
31+
"plpgsqlCheck": {
32+
"enabled": false
33+
},
34+
"db": {
35+
"host": "10.250.0.2",
36+
"port": 5432,
37+
"username": "postgres",
38+
"database": "httpg",
39+
"connTimeoutSecs": 10,
40+
"disableConnection": false
41+
// "allowStatementExecutionsAgainst": ["127.0.0.1/*", "localhost/*", "10.250.0.2/*"]
42+
}
43+
}

sql/blog/index.sql

Lines changed: 104 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,37 @@ using (published_at is not null);
2828
-- drop table if exists comment cascade;
2929
create table if not exists comment (
3030
comment_id uuid primary key default uuidv7(),
31-
author text not null check (trim(author) <> ''),
32-
content text not null check (length(content) <= 10000 and trim(content) <> ''),
31+
author text not null check (trim(author) <> '' and length(author) <= 100),
32+
content text not null check (trim(content) <> '' and length(content) <= 10000),
3333
post_id uuid not null references post (post_id) on delete cascade,
34-
published_at timestamptz default now()
34+
published_at timestamptz default now(),
35+
approved_at timestamptz default null
3536
);
3637

3738
create index if not exists post_id on comment (post_id);
3839

3940
grant select, insert on table comment to anon;
4041

42+
alter table comment enable row level security;
43+
44+
drop policy if exists "moderated_select" on comment;
45+
create policy "moderated_select" on comment for all to anon
46+
using ((
47+
with query (query) as (
48+
select nullif(current_setting('httpg.query', true), '')::jsonb
49+
)
50+
select case when query->'qs' ? 'include_unmoderated' or comment.comment_id = coalesce(query->'qs'->>'comment_id', query->'body'->'params'->>0)::uuid
51+
then true
52+
else approved_at is not null
53+
end
54+
from query
55+
))
56+
with check (true);
57+
58+
-- drop policy if exists "moderated_insert" on comment;
59+
-- create policy "moderated_insert" on comment as restrictive for insert to anon
60+
-- with check (true);
61+
4162
truncate post cascade;
4263
insert into post (title, content, published_at)
4364
select i::text, xmlelement(name h3, 'hello '||i)::text, case when i > 6 then null else now() end
@@ -62,53 +83,86 @@ from generate_series(1, 5) i, post;
6283
-- drop view if exists post_html cascade;
6384
create or replace view post_html (post_id, body)
6485
with (security_invoker)
65-
as with entry (post_id, xml) as (
86+
as with httpg (params, comment_id, include_unmoderated) as (
87+
with query (query) as (
88+
select nullif(current_setting('httpg.query', true), '')::jsonb
89+
)
90+
select query->'body'->'params', (query->'qs'->>'comment_id')::uuid, query->'qs' ? 'include_unmoderated'
91+
from query
92+
),
93+
entry (post_id, xml) as (
6694
select post_id, xmlelement(name div,
6795
xmlelement(name article, xmlattributes('card' as class),
68-
xmlelement(name h2, post.title),
96+
xmlelement(name a, xmlattributes(
97+
url('/blog/query', jsonb_build_object(
98+
'sql', 'select * from blog.head union all select body::text from blog.post_html where post_id = $1::uuid',
99+
'params[]', post_id
100+
)) as href
101+
),
102+
xmlelement(name h2, post.title)
103+
),
69104
post.content::xml,
70105
xmlelement(name hr),
106+
xmlelement(name h4, 'Comments'),
71107
xmlelement(name form, xmlattributes(
72108
'POST' as method,
73109
'/blog/query' as action
74110
),
75111
xmlelement(name input, xmlattributes(
76112
'hidden' as type,
77113
'sql' as name,
78-
'insert into blog.comment (author, content, post_id) values ($1, $2, $3::uuid)' as value
114+
$$
115+
insert into blog.comment (comment_id, author, content, post_id) values ($1::uuid, $2, $3, $4::uuid)
116+
returning 303 status, hstore('Location', url.url('/blog/query', jsonb_build_object(
117+
'sql', 'select * from blog.head union all select body::text from blog.post_html where post_id = $1::uuid',
118+
'params[0]', post_id,
119+
'comment_id', comment_id
120+
))) header
121+
$$ as value
79122
)),
80123
xmlelement(name input, xmlattributes(
81124
'hidden' as type,
82125
'on_error' as name,
83-
'select * from blog.blog' as value
126+
'select * from blog.head union all select body::text from blog.post_html where post_id = $4::uuid' as value
84127
)),
85128
xmlelement(name input, xmlattributes(
86129
'hidden' as type,
87-
'redirect' as name,
88-
url('/blog/query', jsonb_build_object('sql', 'select * from blog.blog')) as value
130+
'params[0]' as name,
131+
'author' as placeholder,
132+
coalesce(params->>0, uuidv7()::text) as value
89133
)),
90134
xmlelement(name input, xmlattributes(
91135
'text' as type,
92-
'params[0]' as name,
93-
'author' as placeholder
136+
'params[1]' as name,
137+
'author' as placeholder,
138+
params->>1 as value
94139
)),
95140
xmlelement(name textarea, xmlattributes(
96-
'params[1]' as name,
97-
'comment' as placeholder
98-
), ''),
141+
'params[2]' as name,
142+
'comment' as placeholder,
143+
7 as rows
144+
), coalesce(params->>2, '')),
99145
xmlelement(name input, xmlattributes(
100146
'hidden' as type,
101-
'params[2]' as name,
147+
'params[3]' as name,
102148
post_id as value
103149
)),
104150
xmlelement(name input, xmlattributes(
105151
'submit' as type,
106152
'Comment' as value
107153
))
108154
),
155+
xmlelement(name a, xmlattributes(
156+
url('/blog/query', jsonb_build_object(
157+
'sql', 'select * from blog.head union all select body::text from blog.post_html where post_id = $1::uuid',
158+
'params[]', post_id,
159+
'include_unmoderated', null
160+
)) as href
161+
), 'Include unmoderated'),
109162
xmlelement(name div, xmlattributes('messages' as class), xmlagg(
110163
xmlelement(name article, xmlattributes('card' as class),
111-
comment.content,
164+
xmlelement(name address, comment.author),
165+
comment.content
112166
-- (
113167
-- with recursive n (comment_id, n, i, ordinality) as (
114168
-- select comment_id, r.n, 0, ordinality
@@ -123,15 +177,14 @@ as with entry (post_id, xml) as (
123177
-- group by comment_id
124178
-- -- order by i, ordinality
125179
-- )),
126-
xmlelement(name address, comment.author)
127180
)
128-
order by comment.published_at
181+
order by comment.published_at desc
129182
))
130183
)
131184
)
132-
from post
185+
from httpg, post
133186
left join comment using (post_id)
134-
group by post_id
187+
group by post_id, params
135188
)
136189
select post_id, xml
137190
from entry
@@ -153,33 +206,45 @@ select $html$<!DOCTYPE html>
153206
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'self'; form-action 'self'; " />
154207
<link rel="stylesheet" href="/cpres/index.css?v=4" />
155208
</head>
156-
$html$;
157-
158-
grant select on table head to anon;
159-
160-
-- drop view if exists blog cascade;
161-
create or replace view blog (html)
162-
with (security_invoker)
163-
as
164-
with httpg (error) as (
165-
select nullif(current_setting('httpg.errors', true), '')::jsonb->>'error'
166-
)
167-
table head
209+
$html$
168210
union all
169-
select xmlelement(name h1, 'docteurklein''s blog')::text
211+
select xmlelement(name a, xmlattributes(
212+
url('/blog/query', jsonb_build_object(
213+
'sql', 'select * from blog.blog'
214+
)) as href
215+
),
216+
xmlelement(name h1, 'docteurklein''s blog')
217+
)::text
170218
union all
219+
(with httpg (error) as (
220+
select nullif(current_setting('httpg.errors', true), '')::jsonb->>'error'
221+
)
171222
select xmlelement(name article, xmlattributes(
172223
'card error' as class
173224
), coalesce(
174-
pg_get_constraintdef((
175-
select oid
176-
from pg_constraint
177-
where conname = substring(error, 'violates check constraint "(\w+)"')
178-
)),
225+
(
226+
with c (oid, name) as (
227+
select c.oid, a.attname
228+
from pg_constraint c
229+
join pg_attribute a on (a.attnum = any(c.conkey) and a.attrelid = c.conrelid)
230+
where conname = substring(error, 'violates check constraint "(\w+)"')
231+
and connamespace = to_regnamespace('blog')
232+
)
233+
select string_agg(format('%s: %s', name, pg_get_constraintdef(oid)), ', ')
234+
from c
235+
),
179236
error
180237
))::text
181238
from httpg
182-
where error is not null
239+
where error is not null)
240+
;
241+
242+
grant select on table head to anon;
243+
244+
-- drop view if exists blog cascade;
245+
create or replace view blog (html)
246+
with (security_invoker)
247+
as table head
183248
union all
184249
select body::text
185250
from post_html

sql/view.sql

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
set search_path to cpres, url, pg_catalog, public;
44

5+
grant execute on function url to anon, person, httpg;
6+
57
create or replace function _(id_ text, lang_ text = null)
68
returns text
79
immutable parallel safe -- leakproof
@@ -24,7 +26,7 @@ begin atomic
2426
);
2527
end;
2628

27-
grant execute on function _ to anon, httpg;
29+
grant execute on function _ to anon, person, httpg;
2830

2931
create or replace function max_interest_price(good good) returns xml
3032
language sql
@@ -45,7 +47,7 @@ begin atomic;
4547
where price > 0;
4648
end;
4749

48-
grant execute on function max_interest_price to anon, httpg;
50+
grant execute on function max_interest_price to anon, person, httpg;
4951

5052
create or replace function interest_control(good good, interest interest) returns xml
5153
language sql
@@ -125,7 +127,7 @@ begin atomic;
125127
);
126128
end;
127129

128-
grant execute on function interest_control to anon;
130+
grant execute on function interest_control to anon, person;
129131

130132
drop view if exists route cascade;
131133
create or replace view route (geom, node, cost, id, "group", style, tooltip, popup)

src/extract/query.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,11 @@ where
254254
let sql = qs.sql.or(body.sql).unwrap_or("".into());
255255
let sql = match Parser::parse_sql(&PostgreSqlDialect{}, sql.as_str()) {
256256
Ok(mut statements) => {
257-
let mut whitelist = Whitelist(None);
257+
let mut whitelist = Whitelist(Err(HttpgError::RefusedSql { query: sql.clone() }));
258258
let _ = statements.visit(&mut whitelist);
259259

260-
if let Some(query) = whitelist.0 {
261-
return Err(HttpgError::RefusedSql {query}.into_response());
260+
if whitelist.0.is_err() {
261+
return Err(whitelist.0.into_response());
262262
}
263263

264264
if let Some(order) = order.to_owned() {

src/sql/mod.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
use sqlparser::{ast::{Expr, Function, Ident, OrderBy, OrderByExpr, Query, SetExpr, Spanned, Statement, TableFactor, TableWithJoins, VisitorMut}};
22
use std::{collections::BTreeMap, ops::{ControlFlow, Not}};
33

4+
use crate::error::HttpgError;
5+
46
pub struct VisitOrderBy(pub BTreeMap<String, serde_json::Value>);
57

68
#[derive(Debug)]
7-
pub struct Whitelist(pub Option<String>);
9+
pub struct Whitelist(pub Result<(), HttpgError>);
810

911
impl VisitorMut for Whitelist {
1012
type Break = ();
1113

1214
fn pre_visit_expr(&mut self, expr: &mut Expr) -> ControlFlow<Self::Break> {
1315
self.0 = match expr {
14-
Expr::Function(Function { name, ..}) if name.to_string() == "set_config" => Some(expr.to_string()),
15-
_ => None,
16+
Expr::Function(Function { name, ..}) if name.to_string() == "set_config" => Err(HttpgError::RefusedSql {query: expr.to_string()}),
17+
_ => Ok(()),
1618
};
17-
if self.0.is_some() {
19+
if self.0.is_err() {
1820
return ControlFlow::Break(());
1921
}
2022
ControlFlow::Continue(())
@@ -28,11 +30,11 @@ impl VisitorMut for Whitelist {
2830
| Statement::Update(_)
2931
| Statement::Delete(_)
3032
) {
31-
None
33+
Ok(())
3234
} else {
33-
Some(statement.to_string())
35+
Err(HttpgError::RefusedSql { query: statement.to_string() })
3436
};
35-
if self.0.is_some() {
37+
if self.0.is_err() {
3638
return ControlFlow::Break(());
3739
}
3840
ControlFlow::Continue(())
@@ -49,11 +51,11 @@ impl VisitorMut for Whitelist {
4951
| SetExpr::Table(_)
5052
| SetExpr::SetOperation {..}
5153
) {
52-
None
54+
Ok(())
5355
} else {
54-
Some(query.to_string())
56+
Err(HttpgError::RefusedSql { query: query.to_string() })
5557
};
56-
if self.0.is_some() {
58+
if self.0.is_err() {
5759
return ControlFlow::Break(());
5860
}
5961
ControlFlow::Continue(())

0 commit comments

Comments
 (0)