Add the pages section

For now, this is almost identical to the campsite types, but this
section is for purely informational pages that have no other relation
to the database than “belongs to the same company”.

Part of #33.
This commit is contained in:
jordi fita mas 2023-08-08 20:09:57 +02:00
parent d6d2a9b843
commit c0f532df4e
22 changed files with 974 additions and 27 deletions

22
deploy/add_page.sql Normal file
View File

@ -0,0 +1,22 @@
-- Deploy camper:add_page to pg
-- requires: roles
-- requires: schema_camper
-- requires: page
begin;
set search_path to camper, public;
create or replace function add_page(company integer, title text, content text) returns uuid as
$$
insert into page (company_id, title, content)
values (company, title, xmlparse (content content))
returning slug;
$$
language sql
;
revoke execute on function add_page(integer, text, text) from public;
grant execute on function add_page(integer, text, text) to admin;
commit;

24
deploy/edit_page.sql Normal file
View File

@ -0,0 +1,24 @@
-- Deploy camper:edit_page to pg
-- requires: roles
-- requires: schema_camper
-- requires: page
begin;
set search_path to camper, public;
create or replace function edit_page(slug uuid, title text, content text) returns uuid as
$$
update page
set title = edit_page.title
, content = xmlparse(content edit_page.content)
where slug = edit_page.slug
returning slug;
$$
language sql
;
revoke execute on function edit_page(uuid, text, text) from public;
grant execute on function edit_page(uuid, text, text) to admin;
commit;

57
deploy/page.sql Normal file
View File

@ -0,0 +1,57 @@
-- Deploy camper:page to pg
-- requires: roles
-- requires: schema_camper
-- requires: company
-- requires: user_profile
begin;
set search_path to camper, public;
create table page (
page_id serial primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
title text not null constraint title_not_empty check(length(trim(title)) > 0),
content xml not null default ''
);
grant select on table page to guest;
grant select on table page to employee;
grant select, insert, update, delete on table page to admin;
grant usage on sequence page_page_id_seq to admin;
alter table page enable row level security;
create policy guest_ok
on page
for select
using (true)
;
create policy insert_to_company
on page
for insert
with check (
company_id in (select company_id from user_profile)
)
;
create policy update_company
on page
for update
using (
company_id in (select company_id from user_profile)
)
;
create policy delete_from_company
on page
for delete
using (
company_id in (select company_id from user_profile)
)
;
commit;

View File

@ -12,16 +12,19 @@ import (
"dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/page"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type adminHandler struct {
campsite *campsite.AdminHandler
page *page.AdminHandler
}
func newAdminHandler() *adminHandler {
return &adminHandler{
campsite: campsite.NewAdminHandler(),
page: page.NewAdminHandler(),
}
}
@ -43,6 +46,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
switch head {
case "campsites":
h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "pages":
h.page.Handler(user, company, conn).ServeHTTP(w, r)
case "":
switch r.Method {
case http.MethodGet:

View File

@ -12,16 +12,19 @@ import (
"dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/page"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type publicHandler struct {
campsite *campsite.PublicHandler
page *page.PublicHandler
}
func newPublicHandler() *publicHandler {
return &publicHandler{
campsite: campsite.NewPublicHandler(),
page: page.NewPublicHandler(),
}
}
@ -31,10 +34,12 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
page := newHomePage()
page.MustRender(w, r, user, company, conn)
home := newHomePage()
home.MustRender(w, r, user, company, conn)
case "campsites":
h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "pages":
h.page.Handler(user, company, conn).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}

183
pkg/page/admin.go Normal file
View File

@ -0,0 +1,183 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package page
import (
"context"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
type AdminHandler struct {
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "new":
switch r.Method {
case http.MethodGet:
f := newPageForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "":
switch r.Method {
case http.MethodGet:
servePageIndex(w, r, user, company, conn)
case http.MethodPost:
f := newPageForm()
f.Handle(w, r, user, company, func(ctx context.Context) {
conn.MustExec(ctx, "select add_page($1, $2, $3)", company.ID, f.Title, f.Content)
})
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
default:
if !uuid.Valid(head) {
http.NotFound(w, r)
return
}
f := newPageForm()
if err := f.FillFromDatabase(r.Context(), conn, head); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
f.Handle(w, r, user, company, func(ctx context.Context) {
conn.MustExec(ctx, "select edit_page($1, $2, $3)", f.Slug, f.Title, f.Content)
})
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
}
})
}
func servePageIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
pages, err := collectPageEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &pageIndex{
Pages: pages,
}
page.MustRender(w, r, user, company)
}
type pageEntry struct {
Slug string
Title string
}
func collectPageEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*pageEntry, error) {
rows, err := conn.Query(ctx, "select slug, title from page where company_id = $1", company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var types []*pageEntry
for rows.Next() {
entry := &pageEntry{}
if err = rows.Scan(&entry.Slug, &entry.Title); err != nil {
return nil, err
}
types = append(types, entry)
}
return types, nil
}
type pageIndex struct {
Pages []*pageEntry
}
func (index *pageIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "page/index.gohtml", index)
}
type pageForm struct {
Slug string
Title *form.Input
Content *form.Input
}
func newPageForm() *pageForm {
return &pageForm{
Title: &form.Input{
Name: "title",
},
Content: &form.Input{
Name: "content",
},
}
}
func (f *pageForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error {
f.Slug = slug
row := conn.QueryRow(ctx, "select title, content from page where slug = $1", slug)
return row.Scan(&f.Title.Val, &f.Content.Val)
}
func (f *pageForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Title.FillValue(r)
f.Content.FillValue(r)
return nil
}
func (f *pageForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(f.Title, l.GettextNoop("Title can not be empty."))
return v.AllOK
}
func (f *pageForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "page/form.gohtml", f)
}
func (f *pageForm) Handle(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, exec func(ctx context.Context)) {
if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !f.Valid(user.Locale) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
exec(r.Context())
httplib.Redirect(w, r, "/admin/pages", http.StatusSeeOther)
}

72
pkg/page/public.go Normal file
View File

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package page
import (
"context"
gotemplate "html/template"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
type PublicHandler struct {
}
func NewPublicHandler() *PublicHandler {
return &PublicHandler{}
}
func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch r.Method {
case http.MethodGet:
if !uuid.Valid(head) {
http.NotFound(w, r)
return
}
page, err := newPublicPage(r.Context(), conn, head)
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
} else if err != nil {
panic(err)
}
page.MustRender(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
})
}
type publicPage struct {
*template.PublicPage
Title string
Content gotemplate.HTML
}
func newPublicPage(ctx context.Context, conn *database.Conn, slug string) (*publicPage, error) {
page := &publicPage{
PublicPage: template.NewPublicPage(),
}
row := conn.QueryRow(ctx, "select title, content::text from page where slug = $1", slug)
if err := row.Scan(&page.Title, &page.Content); err != nil {
return nil, err
}
return page, nil
}
func (p *publicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
template.MustRenderPublic(w, r, user, company, "page.gohtml", p)
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-08-08 02:43+0200\n"
"POT-Creation-Date: 2023-08-08 20:04+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -35,6 +35,60 @@ msgstr "Salta al contingut principal"
msgid "Singular Lodges"
msgstr "Allotjaments singulars"
#: web/templates/admin/page/form.gohtml:16
#: web/templates/admin/page/form.gohtml:33
msgctxt "title"
msgid "Edit Page"
msgstr "Edició de pàgina"
#: web/templates/admin/page/form.gohtml:18
#: web/templates/admin/page/form.gohtml:35
msgctxt "title"
msgid "New Page"
msgstr "Nova pàgina"
#: web/templates/admin/page/form.gohtml:42
msgctxt "input"
msgid "Title"
msgstr "Títol"
#: web/templates/admin/page/form.gohtml:50
msgctxt "input"
msgid "Content"
msgstr "Contingut"
#: web/templates/admin/page/form.gohtml:59
#: web/templates/admin/campsite/type/new.gohtml:59
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/page/form.gohtml:61
#: web/templates/admin/campsite/type/new.gohtml:61
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/page/index.gohtml:6
#: web/templates/admin/page/index.gohtml:12
msgctxt "title"
msgid "Pages"
msgstr "Pàgines"
#: web/templates/admin/page/index.gohtml:11
msgctxt "action"
msgid "Add Page"
msgstr "Afegeix pàgina"
#: web/templates/admin/page/index.gohtml:17
msgctxt "header"
msgid "Title"
msgstr "Títol"
#: web/templates/admin/page/index.gohtml:29
msgid "No pages added yet."
msgstr "No sha afegit cap pàgina encara."
#: web/templates/admin/campsite/type/new.gohtml:16
#: web/templates/admin/campsite/type/new.gohtml:33
msgctxt "title"
@ -58,16 +112,6 @@ msgctxt "input"
msgid "Description"
msgstr "Descripció"
#: web/templates/admin/campsite/type/new.gohtml:59
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/campsite/type/new.gohtml:61
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/campsite/type/index.gohtml:6
#: web/templates/admin/campsite/type/index.gohtml:12
msgctxt "title"
@ -155,9 +199,13 @@ msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: pkg/page/admin.go:157
msgid "Title can not be empty."
msgstr "No podeu deixar el títol en blanc."
#: pkg/app/login.go:56 pkg/app/user.go:246
msgid "Email can not be empty."
msgstr "No podeu deixar el correu en blanc."
msgstr "No podeu deixar el correu-e en blanc."
#: pkg/app/login.go:57 pkg/app/user.go:247
msgid "This email is not valid. It should be like name@domain.com."
@ -192,7 +240,7 @@ msgstr "Lidioma escollit no és vàlid."
msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/app/admin.go:37
#: pkg/app/admin.go:40
msgid "Access forbidden"
msgstr "Accés prohibit"

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-08-08 02:43+0200\n"
"POT-Creation-Date: 2023-08-08 20:04+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -35,6 +35,60 @@ msgstr "Saltar al contenido principal"
msgid "Singular Lodges"
msgstr "Alojamientos singulares"
#: web/templates/admin/page/form.gohtml:16
#: web/templates/admin/page/form.gohtml:33
msgctxt "title"
msgid "Edit Page"
msgstr "Edición de página"
#: web/templates/admin/page/form.gohtml:18
#: web/templates/admin/page/form.gohtml:35
msgctxt "title"
msgid "New Page"
msgstr "Nueva página"
#: web/templates/admin/page/form.gohtml:42
msgctxt "input"
msgid "Title"
msgstr "Título"
#: web/templates/admin/page/form.gohtml:50
msgctxt "input"
msgid "Content"
msgstr "Contenido"
#: web/templates/admin/page/form.gohtml:59
#: web/templates/admin/campsite/type/new.gohtml:59
msgctxt "action"
msgid "Update"
msgstr "Actualitzar"
#: web/templates/admin/page/form.gohtml:61
#: web/templates/admin/campsite/type/new.gohtml:61
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/page/index.gohtml:6
#: web/templates/admin/page/index.gohtml:12
msgctxt "title"
msgid "Pages"
msgstr "Páginas"
#: web/templates/admin/page/index.gohtml:11
msgctxt "action"
msgid "Add Page"
msgstr "Añadir página"
#: web/templates/admin/page/index.gohtml:17
msgctxt "header"
msgid "Title"
msgstr "Título"
#: web/templates/admin/page/index.gohtml:29
msgid "No pages added yet."
msgstr "No se ha añadido ninguna página todavía."
#: web/templates/admin/campsite/type/new.gohtml:16
#: web/templates/admin/campsite/type/new.gohtml:33
msgctxt "title"
@ -58,16 +112,6 @@ msgctxt "input"
msgid "Description"
msgstr "Descripción"
#: web/templates/admin/campsite/type/new.gohtml:59
msgctxt "action"
msgid "Update"
msgstr "Actualitzar"
#: web/templates/admin/campsite/type/new.gohtml:61
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/campsite/type/index.gohtml:6
#: web/templates/admin/campsite/type/index.gohtml:12
msgctxt "title"
@ -155,6 +199,10 @@ msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: pkg/page/admin.go:157
msgid "Title can not be empty."
msgstr "No podéis dejar el título en blanco."
#: pkg/app/login.go:56 pkg/app/user.go:246
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
@ -192,7 +240,7 @@ msgstr "El idioma escogido no es válido."
msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/app/admin.go:37
#: pkg/app/admin.go:40
msgid "Access forbidden"
msgstr "Acceso prohibido"

7
revert/add_page.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:add_page from pg
begin;
drop function if exists camper.add_page(integer, text, text);
commit;

7
revert/edit_page.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:edit_page from pg
begin;
drop function if exists camper.edit_page(uuid, text, text);
commit;

7
revert/page.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:page from pg
begin;
drop table if exists camper.page;
commit;

View File

@ -42,3 +42,6 @@ change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jord
campsite_type [roles schema_camper company user_profile] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type
add_campsite_type [roles schema_camper campsite_type company] 2023-08-04T16:14:48Z jordi fita mas <jordi@tandem.blog> # Add function to create campsite types
edit_campsite_type [roles schema_camper campsite_type company] 2023-08-07T22:21:34Z jordi fita mas <jordi@tandem.blog> # Add function to edit campsite types
page [roles schema_camper company user_profile] 2023-08-08T15:50:26Z jordi fita mas <jordi@tandem.blog> # Add relation for public page
add_page [roles schema_camper page] 2023-08-08T17:20:15Z jordi fita mas <jordi@tandem.blog> # Add function to create pages
edit_page [roles schema_camper page] 2023-08-08T17:26:51Z jordi fita mas <jordi@tandem.blog> # Add function to edit pages

56
test/add_page.sql Normal file
View File

@ -0,0 +1,56 @@
-- Test add_page
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to camper, public;
select plan(12);
select has_function('camper', 'add_page', array ['integer', 'text', 'text']);
select function_lang_is('camper', 'add_page', array ['integer', 'text', 'text'], 'sql');
select function_returns('camper', 'add_page', array ['integer', 'text', 'text'], 'uuid');
select isnt_definer('camper', 'add_page', array ['integer', 'text', 'text']);
select volatility_is('camper', 'add_page', array ['integer', 'text', 'text'], 'volatile');
select function_privs_are('camper', 'add_page', array ['integer', 'text', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'add_page', array ['integer', 'text', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'add_page', array ['integer', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'add_page', array ['integer', 'text', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate page cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
select lives_ok(
$$ select add_page(1, 'Page A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>') $$,
'Should be able to add a page to the first company'
);
select lives_ok(
$$ select add_page(2, 'Page B', '') $$,
'Should be able to add a page to the second company'
);
select bag_eq(
$$ select company_id, title, content::text from page $$,
$$ values (1, 'Page A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>')
, (2, 'Page B', '')
$$,
'Should have added all two pages'
);
select *
from finish();
rollback;

60
test/edit_page.sql Normal file
View File

@ -0,0 +1,60 @@
-- Test edit_page
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to camper, public;
select plan(12);
select has_function('camper', 'edit_page', array ['uuid', 'text', 'text']);
select function_lang_is('camper', 'edit_page', array ['uuid', 'text', 'text'], 'sql');
select function_returns('camper', 'edit_page', array ['uuid', 'text', 'text'], 'uuid');
select isnt_definer('camper', 'edit_page', array ['uuid', 'text', 'text']);
select volatility_is('camper', 'edit_page', array ['uuid', 'text', 'text'], 'volatile');
select function_privs_are('camper', 'edit_page', array ['uuid', 'text', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_page', array ['uuid', 'text', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'edit_page', array ['uuid', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_page', array ['uuid', 'text', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate page cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into page (company_id, slug, title, content)
values (1, '87452b88-b48f-48d3-bb6c-0296de64164e', 'Page A', '<p>A</p>')
, (1, '9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Page B', '<p>B</p>')
;
select lives_ok(
$$ select edit_page('87452b88-b48f-48d3-bb6c-0296de64164e', 'Page 1', '<p>1</p>') $$,
'Should be able to edit the first type'
);
select lives_ok(
$$ select edit_page('9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Page 2', '<p>2</p>') $$,
'Should be able to edit the second type'
);
select bag_eq(
$$ select slug::text, title, content::text from page $$,
$$ values ('87452b88-b48f-48d3-bb6c-0296de64164e', 'Page 1', '<p>1</p>')
, ('9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Page 2', '<p>2</p>')
$$,
'Should have updated all pages.'
);
select *
from finish();
rollback;

199
test/page.sql Normal file
View File

@ -0,0 +1,199 @@
-- Test page
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(51);
set search_path to camper, public;
select has_table('page');
select has_pk('page');
select table_privs_are('page', 'guest', array['SELECT']);
select table_privs_are('page', 'employee', array['SELECT']);
select table_privs_are('page', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('page', 'authenticator', array[]::text[]);
select has_sequence('page_page_id_seq');
select sequence_privs_are('page_page_id_seq', 'guest', array[]::text[]);
select sequence_privs_are('page_page_id_seq', 'employee', array[]::text[]);
select sequence_privs_are('page_page_id_seq', 'admin', array['USAGE']);
select sequence_privs_are('page_page_id_seq', 'authenticator', array[]::text[]);
select has_column('page', 'page_id');
select col_is_pk('page', 'page_id');
select col_type_is('page', 'page_id', 'integer');
select col_not_null('page', 'page_id');
select col_has_default('page', 'page_id');
select col_default_is('page', 'page_id', 'nextval(''page_page_id_seq''::regclass)');
select has_column('page', 'company_id');
select col_is_fk('page', 'company_id');
select fk_ok('page', 'company_id', 'company', 'company_id');
select col_type_is('page', 'company_id', 'integer');
select col_not_null('page', 'company_id');
select col_hasnt_default('page', 'company_id');
select has_column('page', 'slug');
select col_is_unique('page', 'slug');
select col_type_is('page', 'slug', 'uuid');
select col_not_null('page', 'slug');
select col_has_default('page', 'slug');
select col_default_is('page', 'slug', 'gen_random_uuid()');
select has_column('page', 'title');
select col_type_is('page', 'title', 'text');
select col_not_null('page', 'title');
select col_hasnt_default('page', 'title');
select has_column('page', 'content');
select col_type_is('page', 'content', 'xml');
select col_not_null('page', 'content');
select col_has_default('page', 'content');
--select col_default_is('page', 'description', '');
set client_min_messages to warning;
truncate page cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
, (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into company_user (company_id, user_id, role)
values (2, 1, 'admin')
, (4, 5, 'admin')
;
insert into company_host (company_id, host)
values (2, 'co2')
, (4, 'co4')
;
insert into page (company_id, title)
values (2, 'Front')
, (4, 'Back')
;
prepare page_data as
select company_id, title
from page
order by company_id, title;
set role guest;
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (4, 'Back')
$$,
'Everyone should be able to list all pages across all companies'
);
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2');
select lives_ok(
$$ insert into page(company_id, title) values (2, 'Another page' ) $$,
'Admin from company 2 should be able to insert a new page to that company.'
);
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (2, 'Another page')
, (4, 'Back')
$$,
'The new row should have been added'
);
select lives_ok(
$$ update page set title = 'Another' where company_id = 2 and title = 'Another page' $$,
'Admin from company 2 should be able to update pages of that company.'
);
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (2, 'Another')
, (4, 'Back')
$$,
'The row should have been updated.'
);
select lives_ok(
$$ delete from page where company_id = 2 and title = 'Another' $$,
'Admin from company 2 should be able to delete pages from that company.'
);
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (4, 'Back')
$$,
'The row should have been deleted.'
);
select throws_ok(
$$ insert into page (company_id, title) values (4, 'Another page' ) $$,
'42501', 'new row violates row-level security policy for table "page"',
'Admin from company 2 should NOT be able to insert new pages to company 4.'
);
select lives_ok(
$$ update page set title = 'Nope' where company_id = 4 $$,
'Admin from company 2 should not be able to update new pages of company 4, but no error if company_id is not changed.'
);
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (4, 'Back')
$$,
'No row should have been changed.'
);
select throws_ok(
$$ update page set company_id = 4 where company_id = 2 $$,
'42501', 'new row violates row-level security policy for table "page"',
'Admin from company 2 should NOT be able to move pages to company 4'
);
select lives_ok(
$$ delete from page where company_id = 4 $$,
'Admin from company 2 should NOT be able to delete pages from company 4, but not error is thrown'
);
select bag_eq(
'page_data',
$$ values (2, 'Front')
, (4, 'Back')
$$,
'No row should have been changed'
);
select throws_ok(
$$ insert into page (company_id, title) values (2, ' ' ) $$,
'23514', 'new row for relation "page" violates check constraint "title_not_empty"',
'Should not be able to insert pages with a blank title.'
);
reset role;
select *
from finish();
rollback;

7
verify/add_page.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify camper:add_page on pg
begin;
select has_function_privilege('camper.add_page(integer, text, text)', 'execute');
rollback;

7
verify/edit_page.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify camper:edit_page on pg
begin;
select has_function_privilege('camper.edit_page(uuid, text, text)', 'execute');
rollback;

19
verify/page.sql Normal file
View File

@ -0,0 +1,19 @@
-- Verify camper:page on pg
begin;
select page_id
, company_id
, slug
, title
, content
from camper.page
where false;
select 1 / count(*) from pg_class where oid = 'camper.page'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'guest_ok' and polrelid = 'camper.page'::regclass;
select 1 / count(*) from pg_policy where polname = 'insert_to_company' and polrelid = 'camper.page'::regclass;
select 1 / count(*) from pg_policy where polname = 'update_company' and polrelid = 'camper.page'::regclass;
select 1 / count(*) from pg_policy where polname = 'delete_from_company' and polrelid = 'camper.page'::regclass;
rollback;

View File

@ -0,0 +1,66 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "head" -}}
<link rel="stylesheet" media="screen" href="/static/isolated-block-editor@2.26.0.css"/>
<link rel="stylesheet" media="screen" href="/static/isolated-block-editor-core@2.26.0.css"/>
<script src="/static/react@18.2.0.min.js"></script>
<script src="/static/react-dom@18.2.0.min.js"></script>
<script src="/static/isolated-block-editor@2.26.0.min.js"></script>
{{- end }}
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/page.pageForm*/ -}}
{{ if .Slug}}
{{( pgettext "Edit Page" "title" )}}
{{ else }}
{{( pgettext "New Page" "title" )}}
{{ end }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/page.pageForm*/ -}}
<form
{{ if .Slug }}
data-hx-put="/admin/pages/{{ .Slug }}"
{{ else }}
action="/admin/pages" method="post"
{{ end }}
>
<h2>
{{ if .Slug }}
{{( pgettext "Edit Page" "title" )}}
{{ else }}
{{( pgettext "New Page" "title" )}}
{{ end }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Title -}}
<label>
{{( pgettext "Title" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Content -}}
<label>
{{( pgettext "Content" "input")}}<br>
<textarea class="html" name="{{ .Name }}" {{ template "error-attrs" . }}>{{ .Val }}</textarea><br>
{{ template "error-message" . }}
</label>
{{- end }}
</fieldset>
<footer>
<button type="submit">
{{ if .Slug }}
{{( pgettext "Update" "action" )}}
{{ else }}
{{( pgettext "Add" "action" )}}
{{ end }}
</button>
</footer>
</form>
{{- end }}

View File

@ -0,0 +1,31 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Pages" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/page.pageIndex*/ -}}
<a href="/admin/pages/new">{{( pgettext "Add Page" "action" )}}</a>
<h2>{{( pgettext "Pages" "title" )}}</h2>
{{ if .Pages -}}
<table>
<thead>
<tr>
<th scope="col">{{( pgettext "Title" "header" )}}</th>
</tr>
</thead>
<tbody>
{{ range .Pages -}}
<tr>
<td><a href="/admin/pages/{{ .Slug }}">{{ .Title }}</a></td>
</tr>
{{- end }}
</tbody>
</table>
{{ else -}}
<p>{{( gettext "No pages added yet." )}}</p>
{{- end }}
{{- end }}

View File

@ -0,0 +1,14 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/page.publicPage*/ -}}
{{ .Title }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/page.publicPage*/ -}}
<h2>{{ .Title }}</h2>
{{ .Content }}
{{- end }}