Add cover media to campsite types

This is the image that is shown at the home page, and maybe other pages
in the future.  We can not use a static file because this image can be
changed by the customer, not us; just like name and description.

I decided to keep the actual media content in the database, but to copy
this file out to the file system the first time it is accessed. This is
because we are going to replicate the database to a public instance that
must show exactly the same image, but the customer will update the image
from the private instance, behind a firewall.  We could also synchronize
the folder where they upload the images, the same way we will replicate,
but i thought that i would make the whole thing a little more brittle:
this way if it can replicate the update of the media, it is impossible
to not have its contents; dumping it to a file is to improve subsequent
requests to the same media.

I use the hex representation of the media’s hash as the URL to the
resource, because PostgreSQL’s base64 is not URL save (i.e., it uses
RFC2045’s charset that includes the forward slash[0]), and i did not
feel necessary write a new function just to slightly reduce the URLs’
length.

Before checking if the file exists, i make sure that the given hash is
an hex string, like i do for UUID, otherwise any other check is going
to fail for sure.  I moved out hex.Valid function from UUID to check for
valid hex values, but the actual hash check is inside app/media because
i doubt it will be used outside that module.

[0]: https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
This commit is contained in:
jordi fita mas 2023-09-10 03:04:18 +02:00
parent de0fac1368
commit da127124a1
31 changed files with 538 additions and 132 deletions

View File

@ -19,6 +19,7 @@ import (
)
var avatarsDir = "/var/lib/camper/avatars"
var mediaDir = "/var/lib/camper/media"
func main() {
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
@ -27,7 +28,7 @@ func main() {
}
defer db.Close()
handler, err := app.New(db, avatarsDir)
handler, err := app.New(db, avatarsDir, mediaDir)
if err != nil {
log.Fatal(err)
}

View File

@ -8,16 +8,16 @@ begin;
set search_path to camper, public;
create or replace function add_campsite_type(company integer, name text, description text) returns uuid as
create or replace function add_campsite_type(company integer, media_id integer, name text, description text) returns uuid as
$$
insert into campsite_type (company_id, name, description)
values (company, name, xmlparse (content description))
insert into campsite_type (company_id, media_id, name, description)
values (company, media_id, name, xmlparse (content description))
returning slug;
$$
language sql
;
revoke execute on function add_campsite_type(integer, text, text) from public;
grant execute on function add_campsite_type(integer, text, text) to admin;
revoke execute on function add_campsite_type(integer, integer, text, text) from public;
grant execute on function add_campsite_type(integer, integer, text, text) to admin;
commit;

View File

@ -3,6 +3,7 @@
-- requires: schema_camper
-- requires: company
-- requires: user_profile
-- requires: media
begin;
@ -13,6 +14,7 @@ create table campsite_type (
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
name text not null constraint name_not_empty check(length(trim(name)) > 0),
media_id integer not null references media,
description xml not null default ''::xml,
active boolean not null default true
);

View File

@ -8,11 +8,12 @@ begin;
set search_path to camper, public;
create or replace function edit_campsite_type(slug uuid, name text, description text, active boolean) returns uuid as
create or replace function edit_campsite_type(slug uuid, media_id integer, name text, description text, active boolean) returns uuid as
$$
update campsite_type
set name = edit_campsite_type.name
, description = xmlparse(content edit_campsite_type.description)
, media_id = coalesce(edit_campsite_type.media_id, campsite_type.media_id)
, active = edit_campsite_type.active
where slug = edit_campsite_type.slug
returning slug;
@ -20,7 +21,7 @@ $$
language sql
;
revoke execute on function edit_campsite_type(uuid, text, text, boolean) from public;
grant execute on function edit_campsite_type(uuid, text, text, boolean) to admin;
revoke execute on function edit_campsite_type(uuid, integer, text, text, boolean) from public;
grant execute on function edit_campsite_type(uuid, integer, text, text, boolean) to admin;
commit;

View File

@ -23,12 +23,13 @@ type App struct {
profile *profileHandler
admin *adminHandler
public *publicHandler
media *mediaHandler
locales locale.Locales
defaultLocale *locale.Locale
languageMatcher language.Matcher
}
func New(db *database.DB, avatarsDir string) (http.Handler, error) {
func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, error) {
locales, err := locale.GetAll(context.Background(), db)
if err != nil {
return nil, err
@ -38,12 +39,17 @@ func New(db *database.DB, avatarsDir string) (http.Handler, error) {
if err != nil {
return nil, err
}
media, err := newMediaHandler(mediaDir)
if err != nil {
return nil, err
}
app := &App{
db: db,
fileHandler: static,
profile: profile,
admin: newAdminHandler(),
public: newPublicHandler(),
media: media,
locales: locales,
defaultLocale: locales[language.Catalan],
languageMatcher: language.NewMatcher(locales.Tags()),
@ -104,6 +110,8 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
case "me":
h.profile.Handler(user, company, conn).ServeHTTP(w, r)
case "media":
h.media.Handler(user, company, conn).ServeHTTP(w, r)
default:
langTag, err := language.Parse(head)
if err != nil {

95
pkg/app/media.go Normal file
View File

@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app
import (
"net/http"
"os"
"path"
"path/filepath"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/hex"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
)
type mediaHandler struct {
mediaDir string
fileHandler http.Handler
}
func newMediaHandler(mediaDir string) (*mediaHandler, error) {
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return nil, err
}
handler := &mediaHandler{
mediaDir: mediaDir,
fileHandler: http.FileServer(http.Dir(mediaDir)),
}
return handler, nil
}
func (h *mediaHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
if !mediaHashValid(head) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
h.serveMedia(w, r, company, conn, strings.ToLower(head))
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
}
}
func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, company *auth.Company, conn *database.Conn, hash string) {
mediaPath := h.mediaPath(hash)
var err error
if _, err = os.Stat(mediaPath); err != nil {
bytes, err := conn.GetBytes(r.Context(), "select content from media where company_id = $1 and hash = decode($2, 'hex')", company.ID, hash)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
if err = os.MkdirAll(path.Dir(mediaPath), 0755); err != nil {
panic(err)
}
if err = os.WriteFile(mediaPath, bytes, 0644); err != nil {
panic(err)
}
}
r.URL.Path, err = filepath.Rel(h.mediaDir, mediaPath)
if err != nil {
panic(err)
}
h.fileHandler.ServeHTTP(w, r)
}
func (h *mediaHandler) mediaPath(hash string) string {
return filepath.Join(h.mediaDir, hash[:2], hash[2:])
}
func mediaHashValid(s string) bool {
if len(s) != 64 {
return false
}
for i := 0; i < 64; i += 2 {
if !hex.Valid(s[i], s[i+1]) {
return false
}
}
return true
}

37
pkg/app/media_test.go Normal file
View File

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app
import (
"strings"
"testing"
)
type test struct {
in string
isHash bool
}
var tests = []test{
{"6ccd4e641d6c52c11262b1a8140d656ff80c5c32e230ada565d5d46f11132ead", true},
{"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", true},
{"x6df42afe8fafbe77089309b732b5c20234d4b0ed429144ba5b8471b1b371310", false},
{"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde99", false},
{"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde", false},
}
func testValid(t *testing.T, in string, isHash bool) {
if ok := mediaHashValid(in); ok != isHash {
t.Errorf("Valid(%s) got %v expected %v", in, ok, isHash)
}
}
func TestMediaHash(t *testing.T) {
for _, tt := range tests {
testValid(t, tt.in, tt.isHash)
testValid(t, strings.ToUpper(tt.in), tt.isHash)
}
}

View File

@ -6,12 +6,14 @@
package app
import (
"context"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"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/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
@ -43,13 +45,46 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type homePage struct {
*template.PublicPage
CampsiteTypes []*campsiteType
}
func newHomePage() *homePage {
return &homePage{template.NewPublicPage()}
return &homePage{template.NewPublicPage(), nil}
}
func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale)
template.MustRenderPublic(w, r, user, company, "home.gohtml", p)
}
type campsiteType struct {
Label string
HRef string
Media string
}
func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType {
rows, err := conn.Query(ctx, "select name, '/campsites/types/' || slug, '/media/' || encode(hash, 'hex') from campsite_type join media using (media_id) where campsite_type.company_id = $1", company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
localePath := "/" + loc.Language.String()
var items []*campsiteType
for rows.Next() {
item := &campsiteType{}
err = rows.Scan(&item.Label, &item.HRef, &item.Media)
if err != nil {
panic(err)
}
item.HRef = localePath + item.HRef
items = append(items, item)
}
if rows.Err() != nil {
panic(rows.Err())
}
return items
}

View File

@ -7,6 +7,7 @@ package types
import (
"context"
"io"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
@ -115,48 +116,56 @@ func (page *typeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *
func addType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newTypeForm()
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
}
conn.MustExec(r.Context(), "select add_campsite_type($1, $2, $3)", company.ID, f.Name, f.Description)
httplib.Redirect(w, r, "/admin/campsites/types", http.StatusSeeOther)
processTypeForm(w, r, user, company, true, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, mediaID, f.Name, f.Description)
tx.MustCommit(ctx)
})
}
func editType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) {
if err := f.Parse(r); err != nil {
processTypeForm(w, r, user, company, false, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
if bytes == nil {
conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, nil, f.Name, f.Description, f.Active)
} else {
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, mediaID, f.Name, f.Description, f.Active)
tx.MustCommit(ctx)
}
})
}
func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, f *typeForm, act func(ctx context.Context)) {
if err := f.Parse(w, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer f.Close()
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !f.Valid(user.Locale) {
if !f.Valid(user.Locale, mediaRequired) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select edit_campsite_type($1, $2, $3, $4)", f.Slug, f.Name, f.Description, f.Active)
act(r.Context())
httplib.Redirect(w, r, "/admin/campsites/types", http.StatusSeeOther)
}
type typeForm struct {
Slug string
Active *form.Checkbox
Media *form.File
Name *form.Input
Description *form.Input
}
@ -167,6 +176,10 @@ func newTypeForm() *typeForm {
Name: "active",
Checked: true,
},
Media: &form.File{
Name: "media",
MaxSize: 1 << 20,
},
Name: &form.Input{
Name: "name",
},
@ -178,26 +191,63 @@ func newTypeForm() *typeForm {
func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error {
f.Slug = slug
row := conn.QueryRow(ctx, "select name, description, active from campsite_type where slug = $1", slug)
return row.Scan(&f.Name.Val, &f.Description.Val, &f.Active.Checked)
row := conn.QueryRow(ctx, `
select name
, description
, encode(hash, 'hex')
, active
from campsite_type
join media using (media_id)
where slug = $1
`, slug)
return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.Active.Checked)
}
func (f *typeForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
func (f *typeForm) Parse(w http.ResponseWriter, r *http.Request) error {
maxSize := f.Media.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
f.Active.FillValue(r)
f.Name.FillValue(r)
f.Description.FillValue(r)
if err := f.Media.FillValue(r); err != nil {
return err
}
return nil
}
func (f *typeForm) Valid(l *locale.Locale) bool {
func (f *typeForm) Close() error {
return f.Media.Close()
}
func (f *typeForm) Valid(l *locale.Locale, mediaRequired bool) bool {
v := form.NewValidator(l)
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
if f.HasMediaFile() {
v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image."))
} else {
v.Check(f.Media, !mediaRequired, l.GettextNoop("Cover image can not be empty."))
}
return v.AllOK
}
func (f *typeForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "campsite/type/form.gohtml", f)
}
func (f *typeForm) HasMediaFile() bool {
return f.Media.HasData()
}
func (f *typeForm) MustReadAllMedia() []byte {
if !f.HasMediaFile() {
return nil
}
bytes, err := io.ReadAll(f.Media)
if err != nil {
panic(err)
}
return bytes
}

View File

@ -63,6 +63,14 @@ type Conn struct {
*pgxpool.Conn
}
func (c *Conn) MustBegin(ctx context.Context) *Tx {
tx, err := c.Begin(ctx)
if err != nil {
panic(err)
}
return &Tx{tx}
}
func (c *Conn) MustExec(ctx context.Context, sql string, args ...interface{}) pgconn.CommandTag {
tag, err := c.Conn.Exec(ctx, sql, args...)
if err != nil {
@ -94,3 +102,9 @@ func (c *Conn) GetBool(ctx context.Context, sql string, args ...interface{}) (bo
}
return result, nil
}
func (c *Conn) GetBytes(ctx context.Context, sql string, args ...interface{}) ([]byte, error) {
var result []byte
err := c.QueryRow(ctx, sql, args...).Scan(&result)
return result, err
}

44
pkg/database/tx.go Normal file
View File

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package database
import (
"context"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
)
type Tx struct {
pgx.Tx
}
func (tx *Tx) MustCommit(ctx context.Context) {
if err := tx.Tx.Commit(ctx); err != nil {
panic(err)
}
}
func (tx *Tx) MustExec(ctx context.Context, sql string, args ...interface{}) pgconn.CommandTag {
tag, err := tx.Tx.Exec(ctx, sql, args...)
if err != nil {
panic(err)
}
return tag
}
func (tx *Tx) GetInt(ctx context.Context, sql string, args ...interface{}) (int, error) {
var result int
err := tx.QueryRow(ctx, sql, args...).Scan(&result)
return result, err
}
func (tx *Tx) MustGetInt(ctx context.Context, sql string, args ...interface{}) int {
if result, err := tx.GetInt(ctx, sql, args...); err == nil {
return result
} else {
panic(err)
}
}

View File

@ -43,6 +43,13 @@ func (f *File) FillValue(r *http.Request) error {
return nil
}
func (f *File) Filename() string {
if f.header == nil {
return ""
}
return f.header.Filename
}
func (f *File) Close() error {
if !f.HasData() {
return nil

View File

@ -29,21 +29,21 @@ func NewValidator(l *locale.Locale) *Validator {
}
func (v *Validator) CheckRequired(input *Input, message string) bool {
return v.check(input, input.Val != "", message)
return v.Check(input, input.Val != "", message)
}
func (v *Validator) CheckMinLength(input *Input, min int, message string) bool {
return v.check(input, len(input.Val) >= min, message)
return v.Check(input, len(input.Val) >= min, message)
}
func (v *Validator) CheckValidEmail(input *Input, message string) bool {
_, err := mail.ParseAddress(input.Val)
return v.check(input, err == nil, message)
return v.Check(input, err == nil, message)
}
func (v *Validator) CheckValidURL(input *Input, message string) bool {
_, err := url.Parse(input.Val)
return v.check(input, err == nil, message)
return v.Check(input, err == nil, message)
}
func (v *Validator) CheckValidVATIN(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
@ -51,7 +51,7 @@ func (v *Validator) CheckValidVATIN(ctx context.Context, conn *database.Conn, in
if err != nil {
return false, err
}
return v.check(input, b, message), nil
return v.Check(input, b, message), nil
}
func (v *Validator) CheckValidPhone(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
@ -59,7 +59,7 @@ func (v *Validator) CheckValidPhone(ctx context.Context, conn *database.Conn, in
if err != nil {
return false, err
}
return v.check(input, b, message), nil
return v.Check(input, b, message), nil
}
func (v *Validator) CheckValidColor(ctx context.Context, conn *database.Conn, input *Input, message string) (bool, error) {
@ -67,7 +67,7 @@ func (v *Validator) CheckValidColor(ctx context.Context, conn *database.Conn, in
if err != nil {
return false, err
}
return v.check(input, b, message), nil
return v.Check(input, b, message), nil
}
func (v *Validator) CheckValidPostalCode(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
@ -79,26 +79,26 @@ func (v *Validator) CheckValidPostalCode(ctx context.Context, conn *database.Con
if err != nil {
return false, err
}
return v.check(input, match, message), nil
return v.Check(input, match, message), nil
}
func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool {
return v.check(confirm, password.Val == confirm.Val, message)
return v.Check(confirm, password.Val == confirm.Val, message)
}
func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
return v.check(field, field.validOptionsSelected(), message)
return v.Check(field, field.validOptionsSelected(), message)
}
func (v *Validator) CheckImageFile(field *File, message string) bool {
return v.check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
return v.Check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
}
type field interface {
setError(error)
}
func (v *Validator) check(field field, ok bool, message string) bool {
func (v *Validator) Check(field field, ok bool, message string) bool {
if !ok {
field.setError(errors.New(v.l.Get(message)))
v.AllOK = false

30
pkg/hex/hex.go Normal file
View File

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package hex
// xvalues returns the value of a byte as a hexadecimal digit or 255.
var xvalues = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
}
func Valid(x1, x2 byte) bool {
return xvalues[x1] != 255 && xvalues[x2] != 255
}

46
pkg/hex/hex_test.go Normal file
View File

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package hex
import (
"strings"
"testing"
)
type test struct {
in string
isHex bool
}
var tests = []test{
{"f4", true},
{"2b", true},
{"19", true},
{"aa", true},
{"ff", true},
{"00", true},
{"bc", true},
{"de", true},
{"ef", true},
{"fe", true},
{"1g", false},
{"gb", false},
{"zb", false},
{"2x", false},
}
func testValid(t *testing.T, in string, isHex bool) {
if ok := Valid(in[0], in[1]); ok != isHex {
t.Errorf("Valid(%s) got %v expected %v", in, ok, isHex)
}
}
func TestHex(t *testing.T) {
for _, tt := range tests {
testValid(t, tt.in, tt.isHex)
testValid(t, strings.ToUpper(tt.in), tt.isHex)
}
}

View File

@ -5,6 +5,8 @@
package uuid
import "dev.tandem.ws/tandem/camper/pkg/hex"
func Valid(s string) bool {
if len(s) != 36 {
return false
@ -19,33 +21,9 @@ func Valid(s string) bool {
14, 16,
19, 21,
24, 26, 28, 30, 32, 34} {
if !validHex(s[x], s[x+1]) {
if !hex.Valid(s[x], s[x+1]) {
return false
}
}
return true
}
// xvalues returns the value of a byte as a hexadecimal digit or 255.
var xvalues = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
}
func validHex(x1, x2 byte) bool {
return xvalues[x1] != 255 && xvalues[x2] != 255
}

View File

@ -2,6 +2,6 @@
begin;
drop function if exists camper.add_campsite_type(integer, text, text);
drop function if exists camper.add_campsite_type(integer, integer, text, text);
commit;

View File

@ -2,6 +2,6 @@
begin;
drop function if exists camper.edit_campsite_type(uuid, text, text, boolean);
drop function if exists camper.edit_campsite_type(uuid, integer, text, text, boolean);
commit;

View File

@ -39,7 +39,10 @@ current_company_id [roles schema_camper] 2023-08-07T10:44:36Z jordi fita mas <jo
user_profile [roles schema_camper user company_user current_user_email current_user_cookie current_company_id] 2023-07-21T23:47:36Z jordi fita mas <jordi@tandem.blog> # Add view for user profile
policies_company [company user_profile] 2023-08-07T20:04:26Z jordi fita mas <jordi@tandem.blog> # Add row-level security profiles to company
change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jordi fita mas <jordi@tandem.blog> # Add function to change the current users password
campsite_type [roles schema_camper company user_profile] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type
media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blog> # Add domain for media type
media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media
add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media
campsite_type [roles schema_camper company media 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
campsite [roles schema_camper company campsite_type user_profile] 2023-08-14T10:11:51Z jordi fita mas <jordi@tandem.blog> # Add campsite relation
@ -53,6 +56,3 @@ to_color [roles schema_camper color] 2023-08-16T13:11:32Z jordi fita mas <jordi@
season [roles schema_camper company user_profile] 2023-08-16T13:21:28Z jordi fita mas <jordi@tandem.blog> # Add relation of (tourist) season
add_season [roles schema_camper season color to_integer] 2023-08-16T16:59:17Z jordi fita mas <jordi@tandem.blog> # Add function to create seasons
edit_season [roles schema_camper season color to_integer] 2023-08-16T17:09:02Z jordi fita mas <jordi@tandem.blog> # Add function to update seasons
media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blog> # Add domain for media type
media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media
add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media

View File

@ -23,6 +23,7 @@ select function_privs_are('camper', 'add_campsite', array ['integer', 'text'], '
set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
@ -32,10 +33,15 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into campsite_type (campsite_type_id, company_id, name)
values (11, 1, 'A')
, (12, 1, 'B')
, (21, 2, 'C')
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)
values (11, 1, 3, 'A')
, (12, 1, 3, 'B')
, (21, 2, 4, 'C')
;
select lives_ok(

View File

@ -9,19 +9,20 @@ set search_path to camper, public;
select plan(12);
select has_function('camper', 'add_campsite_type', array ['integer', 'text', 'text']);
select function_lang_is('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'sql');
select function_returns('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'uuid');
select isnt_definer('camper', 'add_campsite_type', array ['integer', 'text', 'text']);
select volatility_is('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'volatile');
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'text', 'text'], 'authenticator', array[]::text[]);
select has_function('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text']);
select function_lang_is('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'sql');
select function_returns('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'uuid');
select isnt_definer('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text']);
select volatility_is('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'volatile');
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
@ -31,20 +32,25 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
select lives_ok(
$$ select add_campsite_type(1, 'Type A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>') $$,
$$ select add_campsite_type(1, 3, 'Type A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>') $$,
'Should be able to add a campsite type to the first company'
);
select lives_ok(
$$ select add_campsite_type(2, 'Type B', '') $$,
$$ select add_campsite_type(2, 4, 'Type B', '') $$,
'Should be able to add a campsite type to the second company'
);
select bag_eq(
$$ select company_id, name, description::text, active from campsite_type $$,
$$ values (1, 'Type A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>', true)
, (2, 'Type B', '', true)
$$ select company_id, media_id, name, description::text, active from campsite_type $$,
$$ values (1, 3, 'Type A', '<!-- block --><h2>This is what, exactly?</h2><!-- /block --><p>Dunno</p>', true)
, (2, 4, 'Type B', '', true)
$$,
'Should have added all two campsite type'
);

View File

@ -65,6 +65,7 @@ select col_default_is('campsite', 'active', 'true');
set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -92,9 +93,14 @@ values (2, 'co2')
, (4, 'co4')
;
insert into campsite_type (campsite_type_id, company_id, name)
values (22, 2, 'Wooden lodge')
, (44, 4, 'Bungalow')
insert into media (media_id, company_id, original_filename, media_type, content)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)
values (22, 2, 6, 'Wooden lodge')
, (44, 4, 8, 'Bungalow')
;
insert into campsite (company_id, campsite_type_id, label)

View File

@ -5,7 +5,7 @@ reset client_min_messages;
begin;
select plan(56);
select plan(62);
set search_path to camper, public;
@ -48,6 +48,13 @@ select col_type_is('campsite_type', 'name', 'text');
select col_not_null('campsite_type', 'name');
select col_hasnt_default('campsite_type', 'name');
select has_column('campsite_type', 'media_id');
select col_is_fk('campsite_type', 'media_id');
select fk_ok('campsite_type', 'media_id', 'media', 'media_id');
select col_type_is('campsite_type', 'media_id', 'integer');
select col_not_null('campsite_type', 'media_id');
select col_hasnt_default('campsite_type', 'media_id');
select has_column('campsite_type', 'description');
select col_type_is('campsite_type', 'description', 'xml');
select col_not_null('campsite_type', 'description');
@ -63,6 +70,7 @@ select col_default_is('campsite_type', 'active', 'true');
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -89,9 +97,14 @@ values (2, 'co2')
, (4, 'co4')
;
insert into campsite_type (company_id, name)
values (2, 'Wooden lodge')
, (4, 'Bungalow')
insert into media (media_id, company_id, original_filename, media_type, content)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into campsite_type (company_id, name, media_id)
values (2, 'Wooden lodge', 6)
, (4, 'Bungalow', 8)
;
prepare campsite_type_data as
@ -112,7 +125,7 @@ reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2');
select lives_ok(
$$ insert into campsite_type(company_id, name) values (2, 'Another type' ) $$,
$$ insert into campsite_type(company_id, name, media_id) values (2, 'Another type', 6) $$,
'Admin from company 2 should be able to insert a new campsite type to that company.'
);
@ -153,7 +166,7 @@ select bag_eq(
);
select throws_ok(
$$ insert into campsite_type (company_id, name) values (4, 'Another type' ) $$,
$$ insert into campsite_type (company_id, name, media_id) values (4, 'Another type', 6) $$,
'42501', 'new row violates row-level security policy for table "campsite_type"',
'Admin from company 2 should NOT be able to insert new campsite types to company 4.'
);
@ -191,7 +204,7 @@ select bag_eq(
);
select throws_ok(
$$ insert into campsite_type (company_id, name) values (2, ' ' ) $$,
$$ insert into campsite_type (company_id, name, media_id) values (2, ' ', 6) $$,
'23514', 'new row for relation "campsite_type" violates check constraint "name_not_empty"',
'Should not be able to insert campsite types with a blank name.'
);

View File

@ -22,6 +22,7 @@ select function_privs_are('camper', 'edit_campsite', array ['uuid', 'integer', '
set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
@ -30,10 +31,14 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into campsite_type (campsite_type_id, company_id, name)
values (11, 1, 'Type A')
, (12, 1, 'Type B')
, (13, 1, 'Type C')
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)
values (11, 1, 3, 'Type A')
, (12, 1, 3, 'Type B')
, (13, 1, 3, 'Type C')
;
insert into campsite (company_id, campsite_type_id, slug, label, active)

View File

@ -9,18 +9,19 @@ set search_path to camper, public;
select plan(12);
select has_function('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean']);
select function_lang_is('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'sql');
select function_returns('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'uuid');
select isnt_definer('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean']);
select volatility_is('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'volatile');
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'employee', array[]::text[]);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'text', 'text', 'boolean'], 'authenticator', array[]::text[]);
select has_function('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean']);
select function_lang_is('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'sql');
select function_returns('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'uuid');
select isnt_definer('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean']);
select volatility_is('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'volatile');
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'employee', array[]::text[]);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'integer', 'text', 'text', 'boolean'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
@ -29,25 +30,31 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into campsite_type (company_id, slug, name, description, active)
values (1, '87452b88-b48f-48d3-bb6c-0296de64164e', 'Type A', '<p>A</p>', true)
, (1, '9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Type B', '<p>B</p>', false)
insert into media (media_id, company_id, original_filename, media_type, content)
values (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (3, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
, (4, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
;
insert into campsite_type (company_id, slug, media_id, name, description, active)
values (1, '87452b88-b48f-48d3-bb6c-0296de64164e', 2, 'Type A', '<p>A</p>', true)
, (1, '9b6370f7-f941-46f2-bc6e-de455675bd0a', 3, 'Type B', '<p>B</p>', false)
;
select lives_ok(
$$ select edit_campsite_type('87452b88-b48f-48d3-bb6c-0296de64164e', 'Type 1', '<p>1</p>', false) $$,
$$ select edit_campsite_type('87452b88-b48f-48d3-bb6c-0296de64164e', 4, 'Type 1', '<p>1</p>', false) $$,
'Should be able to edit the first type'
);
select lives_ok(
$$ select edit_campsite_type('9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Type 2', '<p>2</p>', true) $$,
$$ select edit_campsite_type('9b6370f7-f941-46f2-bc6e-de455675bd0a', null, 'Type 2', '<p>2</p>', true) $$,
'Should be able to edit the second type'
);
select bag_eq(
$$ select slug::text, name, description::text, active from campsite_type $$,
$$ values ('87452b88-b48f-48d3-bb6c-0296de64164e', 'Type 1', '<p>1</p>', false)
, ('9b6370f7-f941-46f2-bc6e-de455675bd0a', 'Type 2', '<p>2</p>', true)
$$ select slug::text, media_id, name, description::text, active from campsite_type $$,
$$ values ('87452b88-b48f-48d3-bb6c-0296de64164e', 4, 'Type 1', '<p>1</p>', false)
, ('9b6370f7-f941-46f2-bc6e-de455675bd0a', 3, 'Type 2', '<p>2</p>', true)
$$,
'Should have updated all campsite types.'
);

View File

@ -2,6 +2,6 @@
begin;
select has_function_privilege('camper.add_campsite_type(integer, text, text)', 'execute');
select has_function_privilege('camper.add_campsite_type(integer, integer, text, text)', 'execute');
rollback;

View File

@ -6,6 +6,7 @@ select campsite_type_id
, company_id
, slug
, name
, media_id
, description
, active
from camper.campsite_type

View File

@ -2,6 +2,6 @@
begin;
select has_function_privilege('camper.edit_campsite_type(uuid, text, text, boolean)', 'execute');
select has_function_privilege('camper.edit_campsite_type(uuid, integer, text, text, boolean)', 'execute');
rollback;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@ -15,6 +15,7 @@
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form
enctype="multipart/form-data"
{{ if .Slug }}
data-hx-put="/admin/campsites/types/{{ .Slug }}"
{{ else }}
@ -50,6 +51,18 @@
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Media -}}
{{ if .Val -}}
<img src="/media/{{ .Val }}" alt="">
{{- end }}
<label>
{{( pgettext "Cover image" "input" )}}
<input type="file" name="{{ .Name }}"
{{ if not $.Slug }}required{{ end }}
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Description -}}
<label>
{{( pgettext "Description" "input")}}<br>

View File

@ -11,15 +11,16 @@
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.homePage*/ -}}
<section class="nature">
<div style="--background-image:url('/static/images/todd-trapani-5LHzBpiTuzQ-unsplash.jpg')">
<h2>{{ (gettext "The pleasure of camping in the middle of nature…")}}</h2>
<a href="/{{ currentLocale }}/reservation">{{( pgettext "Booking" "link" )}} <span>→</span></a>
</div>
{{ with .Menu.CampsiteTypes -}}
{{ with .CampsiteTypes -}}
<div>
{{ range . -}}
<section style="--background-image:url('/static/images/camping_montagut_acampada_tenda.jpg')">
<section style="--background-image:url('{{ .Media }}')">
<h3><a href="{{ .HRef }}"><span>{{ .Label }}</span></a></h3>
</section>
{{- end }}