Manage all media uploads in a single place

It made no sense to have a file upload in each form that needs a media,
because to reuse an existing media users would need to upload the exact
same file again; this is very unusual and unfriendly.

A better option is to have a “centralized” media section, where people
can upload files there, and then have a picker to select from there.
Ideally, there would be an upload option in the picker, but i did not
add it yet.

I’ve split the content from the media because i want users to have the
option to update a media, for instance when they need to upload a
reduced or cropped version of the same photo, without an edit they would
need to upload the file as a new media and then update all places where
the old version was used.  And i did not want to trouble people that
uploads the same photo twice: without the separate relation, doing so
would throw a constraint error.

I do not believe there is any security problem to have all companies
link their media to the same file, as they were already readable by
everyone and could upload the data from a different company to their
own; in other words, it is not worse than it was now.
This commit is contained in:
jordi fita mas 2023-09-21 01:56:44 +02:00
parent afe77f2296
commit 97cf117da3
69 changed files with 1625 additions and 1086 deletions

View File

@ -20,7 +20,7 @@ po/%.po: $(POT_FILE)
$(POT_FILE): $(HTML_FILES) $(GO_FILES) $(POT_FILE): $(HTML_FILES) $(GO_FILES)
xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES) xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --keyword=Pgettext:1,2c --join-existing $(GO_FILES) xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --keyword=Pgettext:1,2c --keyword=PgettextNoop:1,2c --join-existing $(GO_FILES)
test-deploy: test-deploy:
sqitch deploy --db-name $(PGDATABASE) sqitch deploy --db-name $(PGDATABASE)

View File

@ -7,6 +7,7 @@ package main
import ( import (
"context" "context"
"errors"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -42,7 +43,7 @@ func main() {
go func() { go func() {
log.Printf("INFO - listening on %s\n", srv.Addr) log.Printf("INFO - listening on %s\n", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed { if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err) log.Fatalf("http server: %v", err)
} }
}() }()

View File

@ -24,24 +24,23 @@ values (52, 42, 'employee')
; ;
alter sequence media_media_id_seq restart with 62; alter sequence media_media_id_seq restart with 62;
insert into media (company_id, original_filename, media_type, content) select add_media(52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plots.avif]])', 'base64'));
values (52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plots.avif]])', 'base64')) select add_media(52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64'));
, (52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64')) select add_media(52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64'));
, (52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64')) select add_media(52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64'));
, (52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64')) select add_media(52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64'));
, (52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64')) select add_media(52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64'));
, (52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64')) select add_media(52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64'));
, (52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64')) select add_media(52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64'));
, (52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64')) select add_media(52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64'));
, (52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64')) select add_media(52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64'));
, (52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64')) select add_media(52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64'));
, (52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64')) select add_media(52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64'));
, (52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64')) select add_media(52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64'));
, (52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64')) select add_media(52, 'services_carousel0.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel0.avif]])', 'base64'));
, (52, 'services_carousel0.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel0.avif]])', 'base64')) select add_media(52, 'services_carousel1.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel1.avif]])', 'base64'));
, (52, 'services_carousel1.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel1.avif]])', 'base64')) select add_media(52, 'services_carousel2.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel2.avif]])', 'base64'));
, (52, 'services_carousel2.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel2.avif]])', 'base64')) select add_media(52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64'));
, (52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64'))
; ;
insert into home_carousel (media_id, caption) insert into home_carousel (media_id, caption)

View File

@ -2,6 +2,7 @@
-- requires: roles -- requires: roles
-- requires: schema_camper -- requires: schema_camper
-- requires: media -- requires: media
-- requires: media_content
-- requires: media_type -- requires: media_type
begin; begin;
@ -10,15 +11,26 @@ set search_path to camper, public;
create or replace function add_media(company integer, filename text, media_type media_type, content bytea) returns integer as create or replace function add_media(company integer, filename text, media_type media_type, content bytea) returns integer as
$$ $$
insert into media (company_id, original_filename, media_type, content) declare
values (company, filename, media_type, content) hash bytea;
on conflict (company_id, hash) do update mid integer;
set original_filename = excluded.original_filename begin
, media_type = excluded.media_type insert into media_content (media_type, bytes)
returning media_id values (media_type, content)
on conflict (content_hash) do update
set media_type = excluded.media_type
returning content_hash into hash
; ;
insert into media (company_id, original_filename, content_hash)
values (company, filename, hash)
returning media_id into mid
;
return mid;
end
$$ $$
language sql language plpgsql
; ;
revoke execute on function add_media(integer, text, media_type, bytea) from public; revoke execute on function add_media(integer, text, media_type, bytea) from public;

41
deploy/edit_media.sql Normal file
View File

@ -0,0 +1,41 @@
-- Deploy camper:edit_media to pg
-- requires: roles
-- requires: schema_camper
-- requires: media_content
-- requires: media
-- requires: media_type
begin;
set search_path to camper, public;
create or replace function edit_media(media_id integer, filename text, media_type media_type default null, content bytea default null) returns integer as
$$
declare
hash bytea;
begin
if content is not null then
insert into media_content (media_type, bytes)
values (media_type, content)
on conflict (content_hash) do update
set media_type = excluded.media_type
returning content_hash into hash
;
end if;
update media
set original_filename = filename
, content_hash = coalesce(hash, content_hash)
where media.media_id = edit_media.media_id
;
return media_id;
end
$$
language plpgsql
;
revoke execute on function edit_media(integer, text, media_type, bytea) from public;
grant execute on function edit_media(integer, text, media_type, bytea) to admin;
commit;

View File

@ -2,8 +2,8 @@
-- requires: roles -- requires: roles
-- requires: schema_camper -- requires: schema_camper
-- requires: company -- requires: company
-- requires: media_content
-- requires: user_profile -- requires: user_profile
-- requires: media_type
begin; begin;
@ -12,11 +12,8 @@ set search_path to camper, public;
create table media ( create table media (
media_id serial not null primary key, media_id serial not null primary key,
company_id integer not null references company, company_id integer not null references company,
hash bytea not null generated always as (sha256(content)) stored, content_hash bytea not null references media_content,
original_filename text not null constraint original_filename_not_empty check(length(trim(original_filename)) > 0), original_filename text not null constraint original_filename_not_empty check(length(trim(original_filename)) > 0)
media_type media_type not null,
content bytea not null,
unique (company_id, hash)
); );
grant select on table media to guest; grant select on table media to guest;

20
deploy/media_content.sql Normal file
View File

@ -0,0 +1,20 @@
-- Deploy camper:media_content to pg
-- requires: roles
-- requires: schema_camper
-- requires: media_type
begin;
set search_path to camper, public;
create table media_content (
content_hash bytea not null generated always as (sha256(bytes)) stored primary key,
media_type media_type not null,
bytes bytea not null
);
grant select on table media_content to guest;
grant select on table media_content to employee;
grant select, insert, delete, update on table media_content to admin;
commit;

View File

@ -9,7 +9,7 @@ set search_path to camper, public;
create or replace function path(media) returns text as create or replace function path(media) returns text as
$$ $$
select '/media/' || encode($1.hash, 'hex') || '/' || $1.original_filename; select '/media/' || encode($1.content_hash, 'hex') || '/' || $1.original_filename;
$$ $$
language sql language sql
stable stable

View File

@ -6,6 +6,7 @@
package app package app
import ( import (
"dev.tandem.ws/tandem/camper/pkg/media"
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
@ -24,15 +25,17 @@ type adminHandler struct {
campsite *campsite.AdminHandler campsite *campsite.AdminHandler
company *company.AdminHandler company *company.AdminHandler
home *home.AdminHandler home *home.AdminHandler
media *media.AdminHandler
season *season.AdminHandler season *season.AdminHandler
services *services.AdminHandler services *services.AdminHandler
} }
func newAdminHandler(locales locale.Locales) *adminHandler { func newAdminHandler(locales locale.Locales, mediaDir string) *adminHandler {
return &adminHandler{ return &adminHandler{
campsite: campsite.NewAdminHandler(locales), campsite: campsite.NewAdminHandler(locales),
company: company.NewAdminHandler(), company: company.NewAdminHandler(),
home: home.NewAdminHandler(locales), home: home.NewAdminHandler(locales),
media: media.NewAdminHandler(mediaDir),
season: season.NewAdminHandler(), season: season.NewAdminHandler(),
services: services.NewAdminHandler(locales), services: services.NewAdminHandler(locales),
} }
@ -60,6 +63,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
h.company.Handler(user, company, conn).ServeHTTP(w, r) h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "home": case "home":
h.home.Handler(user, company, conn).ServeHTTP(w, r) h.home.Handler(user, company, conn).ServeHTTP(w, r)
case "media":
h.media.Handler(user, company, conn).ServeHTTP(w, r)
case "seasons": case "seasons":
h.season.Handler(user, company, conn).ServeHTTP(w, r) h.season.Handler(user, company, conn).ServeHTTP(w, r)
case "services": case "services":

View File

@ -7,6 +7,7 @@ package app
import ( import (
"context" "context"
"dev.tandem.ws/tandem/camper/pkg/media"
"net/http" "net/http"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -23,7 +24,7 @@ type App struct {
profile *profileHandler profile *profileHandler
admin *adminHandler admin *adminHandler
public *publicHandler public *publicHandler
media *mediaHandler media *media.PublicHandler
locales locale.Locales locales locale.Locales
defaultLocale *locale.Locale defaultLocale *locale.Locale
languageMatcher language.Matcher languageMatcher language.Matcher
@ -39,7 +40,7 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err
if err != nil { if err != nil {
return nil, err return nil, err
} }
media, err := newMediaHandler(mediaDir) mediaHandler, err := media.NewPublicHandler(mediaDir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -47,9 +48,9 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err
db: db, db: db,
fileHandler: static, fileHandler: static,
profile: profile, profile: profile,
admin: newAdminHandler(locales), admin: newAdminHandler(locales, mediaDir),
public: newPublicHandler(), public: newPublicHandler(),
media: media, media: mediaHandler,
locales: locales, locales: locales,
defaultLocale: locales[language.Catalan], defaultLocale: locales[language.Catalan],
languageMatcher: language.NewMatcher(locales.Tags()), languageMatcher: language.NewMatcher(locales.Tags()),

View File

@ -7,7 +7,6 @@ package types
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -183,42 +182,29 @@ 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) { func addType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newTypeForm() f := newTypeForm()
processTypeForm(w, r, user, company, true, f, func(ctx context.Context) { processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia() conn.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, f.Media, f.Name, f.Description)
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) { func editType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) {
processTypeForm(w, r, user, company, false, f, func(ctx context.Context) { processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia() conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, f.Media, f.Name, f.Description, f.Active)
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)) { func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm, act func(ctx context.Context)) {
if err := f.Parse(w, r); err != nil { if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
defer f.Close()
if err := user.VerifyCSRFToken(r); err != nil { if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if !f.Valid(user.Locale, mediaRequired) { if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) { if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} }
@ -232,7 +218,7 @@ func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, co
type typeForm struct { type typeForm struct {
Slug string Slug string
Active *form.Checkbox Active *form.Checkbox
Media *form.File Media *form.Media
Name *form.Input Name *form.Input
Description *form.Input Description *form.Input
} }
@ -243,9 +229,12 @@ func newTypeForm() *typeForm {
Name: "active", Name: "active",
Checked: true, Checked: true,
}, },
Media: &form.File{ Media: &form.Media{
Input: &form.Input{
Name: "media", Name: "media",
MaxSize: 1 << 20, },
Label: locale.PgettextNoop("Cover image", "input"),
Prompt: locale.PgettextNoop("Set campsite type cover", "action"),
}, },
Name: &form.Input{ Name: &form.Input{
Name: "name", Name: "name",
@ -261,60 +250,36 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl
row := conn.QueryRow(ctx, ` row := conn.QueryRow(ctx, `
select name select name
, description , description
, media.path , media_id::text
, active , active
from campsite_type from campsite_type
join media using (media_id)
where slug = $1 where slug = $1
`, slug) `, slug)
return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.Active.Checked) return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.Active.Checked)
} }
func (f *typeForm) Parse(w http.ResponseWriter, r *http.Request) error { func (f *typeForm) Parse(r *http.Request) error {
maxSize := f.Media.MaxSize + 1024 if err := r.ParseForm(); err != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err return err
} }
f.Active.FillValue(r) f.Active.FillValue(r)
f.Name.FillValue(r) f.Name.FillValue(r)
f.Description.FillValue(r) f.Description.FillValue(r)
if err := f.Media.FillValue(r); err != nil { f.Media.FillValue(r)
return err
}
return nil return nil
} }
func (f *typeForm) Close() error { func (f *typeForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
return f.Media.Close()
}
func (f *typeForm) Valid(l *locale.Locale, mediaRequired bool) bool {
v := form.NewValidator(l) v := form.NewValidator(l)
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty.")) v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
if f.HasMediaFile() { if v.CheckRequired(f.Media.Input, l.GettextNoop("Cover image can not be empty.")) {
v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image.")) if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil {
} else { return false, err
v.Check(f.Media, !mediaRequired, l.GettextNoop("Cover image can not be empty."))
} }
return v.AllOK }
return v.AllOK, nil
} }
func (f *typeForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { 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) 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

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package home package carousel
import ( import (
"context" "context"
"io" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -21,7 +21,19 @@ import (
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { type AdminHandler struct {
name string
locales locale.Locales
}
func NewAdminHandler(name string, locales locale.Locales) *AdminHandler {
return &AdminHandler{
name: name,
locales: locales,
}
}
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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
@ -30,14 +42,14 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
case "": case "":
switch r.Method { switch r.Method {
case http.MethodPost: case http.MethodPost:
addSlide(w, r, user, company, conn) addSlide(w, r, user, company, conn, h.name)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
case "new": case "new":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
f := newSlideForm() f := newSlideForm(h.name)
f.MustRender(w, r, user, company) f.MustRender(w, r, user, company)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
@ -47,7 +59,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
} }
f := newSlideForm() f := newSlideForm(h.name)
if err := f.FillFromDatabase(r.Context(), conn, id); err != nil { if err := f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
http.NotFound(w, r) http.NotFound(w, r)
@ -67,7 +79,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
case http.MethodPut: case http.MethodPut:
editSlide(w, r, user, company, conn, f) editSlide(w, r, user, company, conn, f)
case http.MethodDelete: case http.MethodDelete:
deleteSlide(w, r, user, conn, id) h.deleteSlide(w, r, user, conn, id)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
} }
@ -94,28 +106,28 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
}) })
} }
type carouselSlide struct { type Slide struct {
Media string Media string
Caption string Caption string
} }
func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*carouselSlide { func MustCollectSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale, carouselName string) []*Slide {
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, fmt.Sprintf(`
select coalesce(i18n.caption, slide.caption) as l10_caption select coalesce(i18n.caption, slide.caption) as l10_caption
, media.path , media.path
from home_carousel as slide from %[1]s_carousel as slide
join media using (media_id) join media using (media_id)
left join home_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1 left join %[1]s_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1
where media.company_id = $2 where media.company_id = $2
`, loc.Language, company.ID) `, carouselName), loc.Language, company.ID)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer rows.Close() defer rows.Close()
var carousel []*carouselSlide var carousel []*Slide
for rows.Next() { for rows.Next() {
slide := &carouselSlide{} slide := &Slide{}
err = rows.Scan(&slide.Caption, &slide.Media) err = rows.Scan(&slide.Caption, &slide.Media)
if err != nil { if err != nil {
panic(err) panic(err)
@ -129,8 +141,8 @@ func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn
return carousel return carousel
} }
type slideEntry struct { type SlideEntry struct {
carouselSlide Slide
ID int ID int
Translations []*translation Translations []*translation
} }
@ -141,13 +153,13 @@ type translation struct {
Missing bool Missing bool
} }
func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) { func CollectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn, carouselName string) ([]*SlideEntry, error) {
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, fmt.Sprintf(`
select media_id select media_id
, media.path , media.path
, caption , caption
, array_agg((lang_tag, endonym, not exists (select 1 from home_carousel_i18n as i18n where i18n.media_id = home_carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym) , array_agg((lang_tag, endonym, not exists (select 1 from %[1]s_carousel_i18n as i18n where i18n.media_id = carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from home_carousel from %[1]s_carousel as carousel
join media using (media_id) join media using (media_id)
join company using (company_id) join company using (company_id)
, language , language
@ -158,15 +170,15 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab
, media.path , media.path
, caption , caption
order by caption order by caption
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID) `, carouselName), pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var slides []*slideEntry var slides []*SlideEntry
for rows.Next() { for rows.Next() {
slide := &slideEntry{} slide := &SlideEntry{}
var translations database.RecordArray var translations database.RecordArray
if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil { if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil {
return nil, err return nil, err
@ -184,46 +196,42 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab
return slides, nil return slides, nil
} }
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, carouselName string) {
f := newSlideForm() f := newSlideForm(carouselName)
editSlide(w, r, user, company, conn, f) editSlide(w, r, user, company, conn, f)
} }
func editSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *slideForm) { func editSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *slideForm) {
f.process(w, r, user, company, false, func(ctx context.Context) { f.process(w, r, user, company, conn, func(ctx context.Context) {
bytes := f.MustReadAllMedia() conn.MustExec(ctx, fmt.Sprintf("select add_%s_carousel_slide($1, $2)", f.CarouselName), f.Media, f.Caption)
if bytes == nil {
conn.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption)
} else {
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
f.ID = tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption)
tx.MustCommit(ctx)
}
}) })
} }
func deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) { func (h *AdminHandler) deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
if err := user.VerifyCSRFToken(r); err != nil { if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
conn.MustExec(r.Context(), "select remove_home_carousel_slide($1)", id) conn.MustExec(r.Context(), fmt.Sprintf("select remove_%s_carousel_slide($1)", h.name), id)
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) httplib.Redirect(w, r, "/admin/"+h.name, http.StatusSeeOther)
} }
type slideForm struct { type slideForm struct {
CarouselName string
ID int ID int
Media *form.File Media *form.Media
Caption *form.Input Caption *form.Input
} }
func newSlideForm() *slideForm { func newSlideForm(carouselName string) *slideForm {
return &slideForm{ return &slideForm{
Media: &form.File{ CarouselName: carouselName,
Media: &form.Media{
Input: &form.Input{
Name: "media", Name: "media",
MaxSize: 1 << 20, },
Label: locale.PgettextNoop("Cover image", "input"),
Prompt: locale.PgettextNoop("Set cover image", "action"),
}, },
Caption: &form.Input{ Caption: &form.Input{
Name: "caption", Name: "caption",
@ -233,27 +241,27 @@ func newSlideForm() *slideForm {
func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error { func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id f.ID = id
row := conn.QueryRow(ctx, ` row := conn.QueryRow(ctx, fmt.Sprintf(`
select caption select caption
, media.path , media_id::text
from home_carousel from %s_carousel
join media using (media_id)
where media_id = $1 where media_id = $1
`, id) `, f.CarouselName), id)
return row.Scan(&f.Caption.Val, &f.Media.Val) return row.Scan(&f.Caption.Val, &f.Media.Val)
} }
func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, act func(ctx context.Context)) { func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, act func(ctx context.Context)) {
if err := f.Parse(w, r); err != nil { if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
defer f.Close()
if err := user.VerifyCSRFToken(r); err != nil { if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
} }
if !f.Valid(user.Locale, mediaRequired) { if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) { if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} }
@ -261,51 +269,28 @@ func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.U
return return
} }
act(r.Context()) act(r.Context())
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) httplib.Redirect(w, r, "/admin/"+f.CarouselName, http.StatusSeeOther)
} }
func (f *slideForm) Parse(w http.ResponseWriter, r *http.Request) error { func (f *slideForm) Parse(r *http.Request) error {
maxSize := f.Media.MaxSize + 1024 if err := r.ParseForm(); err != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err return err
} }
f.Caption.FillValue(r) f.Caption.FillValue(r)
if err := f.Media.FillValue(r); err != nil { f.Media.FillValue(r)
return err
}
return nil return nil
} }
func (f *slideForm) Close() error { func (f *slideForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
return f.Media.Close()
}
func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool {
v := form.NewValidator(l) v := form.NewValidator(l)
if f.HasMediaFile() { if v.CheckRequired(f.Media.Input, l.GettextNoop("Slide image can not be empty.")) {
v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image.")) if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil {
} else { return false, err
v.Check(f.Media, !mediaRequired, l.GettextNoop("Slide image can not be empty."))
} }
return v.AllOK }
return v.AllOK, nil
} }
func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "home/carousel/form.gohtml", f) template.MustRenderAdmin(w, r, user, company, "carousel/form.gohtml", f)
}
func (f *slideForm) HasMediaFile() bool {
return f.Media.HasData()
}
func (f *slideForm) MustReadAllMedia() []byte {
if !f.HasMediaFile() {
return nil
}
bytes, err := io.ReadAll(f.Media)
if err != nil {
panic(err)
}
return bytes
} }

View File

@ -3,10 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package services package carousel
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
@ -19,6 +20,7 @@ import (
type slideL10nForm struct { type slideL10nForm struct {
Locale *locale.Locale Locale *locale.Locale
CarouselName string
ID int ID int
Caption *form.L10nInput Caption *form.L10nInput
} }
@ -26,23 +28,24 @@ type slideL10nForm struct {
func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm { func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm {
return &slideL10nForm{ return &slideL10nForm{
Locale: loc, Locale: loc,
CarouselName: f.CarouselName,
ID: f.ID, ID: f.ID,
Caption: f.Caption.L10nInput(), Caption: f.Caption.L10nInput(),
} }
} }
func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error { func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, ` row := conn.QueryRow(ctx, fmt.Sprintf(`
select coalesce(i18n.caption, '') as l10n_caption select coalesce(i18n.caption, '') as l10n_caption
from services_carousel from %[1]s_carousel as carousel
left join services_carousel_i18n as i18n on services_carousel.media_id = i18n.media_id and i18n.lang_tag = $1 left join %[1]s_carousel_i18n as i18n on carousel.media_id = i18n.media_id and i18n.lang_tag = $1
where services_carousel.media_id = $2 where carousel.media_id = $2
`, l10n.Locale.Language, l10n.ID) `, l10n.CarouselName), l10n.Locale.Language, l10n.ID)
return row.Scan(&l10n.Caption.Val) return row.Scan(&l10n.Caption.Val)
} }
func (l10n *slideL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (l10n *slideL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "services/carousel/l10n.gohtml", l10n) template.MustRenderAdmin(w, r, user, company, "carousel/l10n.gohtml", l10n)
} }
func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) { func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) {
@ -61,8 +64,8 @@ func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, comp
l10n.MustRender(w, r, user, company) l10n.MustRender(w, r, user, company)
return return
} }
conn.MustExec(r.Context(), "select translate_services_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption) conn.MustExec(r.Context(), fmt.Sprintf("select translate_%s_carousel_slide($1, $2, $3)", l10n.CarouselName), l10n.ID, l10n.Locale.Language, l10n.Caption)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther) httplib.Redirect(w, r, "/admin/"+l10n.CarouselName, http.StatusSeeOther)
} }
func (l10n *slideL10nForm) Parse(r *http.Request) error { func (l10n *slideL10nForm) Parse(r *http.Request) error {

12
pkg/form/media.go Normal file
View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package form
type Media struct {
*Input
Label string
Prompt string
}

View File

@ -11,6 +11,7 @@ import (
"net/mail" "net/mail"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
@ -94,6 +95,15 @@ 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)
} }
func (v *Validator) CheckImageMedia(ctx context.Context, conn *database.Conn, input *Input, message string) (bool, error) {
id, err := strconv.Atoi(input.Val)
if err != nil {
return v.Check(input, false, message), nil
}
exists, err := conn.GetBool(ctx, "select exists(select 1 from media join media_content using (content_hash) where media_id = $1 and media_type like 'image/%')", id)
return v.Check(input, err == nil && exists, message), err
}
type field interface { type field interface {
setError(error) setError(error)
} }

View File

@ -9,18 +9,25 @@ import (
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
const carouselName = "home"
type AdminHandler struct { type AdminHandler struct {
locales locale.Locales locales locale.Locales
carousel *carousel.AdminHandler
} }
func NewAdminHandler(locales locale.Locales) *AdminHandler { func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{locales} return &AdminHandler{
locales: locales,
carousel: carousel.NewAdminHandler(carouselName, locales),
}
} }
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
case "slides": case "slides":
h.carouselHandler(user, company, conn).ServeHTTP(w, r) h.carousel.Handler(user, company, conn).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -45,7 +52,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
} }
func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := collectSlideEntries(r.Context(), company, conn) slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com
} }
type homeIndex struct { type homeIndex struct {
Slides []*slideEntry Slides []*carousel.SlideEntry
} }
func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {

View File

@ -1,79 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
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"
)
type slideL10nForm struct {
Locale *locale.Locale
ID int
Caption *form.L10nInput
}
func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm {
return &slideL10nForm{
Locale: loc,
ID: f.ID,
Caption: f.Caption.L10nInput(),
}
}
func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, `
select coalesce(i18n.caption, '') as l10n_caption
from home_carousel
left join home_carousel_i18n as i18n on home_carousel.media_id = i18n.media_id and i18n.lang_tag = $1
where home_carousel.media_id = $2
`, l10n.Locale.Language, l10n.ID)
return row.Scan(&l10n.Caption.Val)
}
func (l10n *slideL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "home/carousel/l10n.gohtml", l10n)
}
func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) {
if err := l10n.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 !l10n.Valid(user.Locale) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
l10n.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select translate_home_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption)
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (l10n *slideL10nForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
l10n.Caption.FillValue(r)
return nil
}
func (l10n *slideL10nForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
return v.AllOK
}

View File

@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
@ -38,7 +39,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type homePage struct { type homePage struct {
*template.PublicPage *template.PublicPage
CampsiteTypes []*campsiteType CampsiteTypes []*campsiteType
Carousel []*carouselSlide Carousel []*carousel.Slide
} }
func newHomePage() *homePage { func newHomePage() *homePage {
@ -48,7 +49,7 @@ func newHomePage() *homePage {
func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { 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.Setup(r, user, company, conn)
p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale) p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale)
p.Carousel = mustCollectCarouselSlides(r.Context(), company, conn, user.Locale) p.Carousel = carousel.MustCollectSlides(r.Context(), company, conn, user.Locale, carouselName)
template.MustRenderPublic(w, r, user, company, "home.gohtml", p) template.MustRenderPublic(w, r, user, company, "home.gohtml", p)
} }

View File

@ -68,6 +68,10 @@ func (l *Locale) GettextNoop(str string) string {
return str return str
} }
func PgettextNoop(str string, ctx string) string {
return str
}
func Match(acceptLanguage string, locales Locales, matcher language.Matcher) *Locale { func Match(acceptLanguage string, locales Locales, matcher language.Matcher) *Locale {
t, _, err := language.ParseAcceptLanguage(acceptLanguage) t, _, err := language.ParseAcceptLanguage(acceptLanguage)
if err != nil { if err != nil {

341
pkg/media/admin.go Normal file
View File

@ -0,0 +1,341 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package media
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"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"
)
type AdminHandler struct {
mediaDir string
}
func NewAdminHandler(mediaDir string) *AdminHandler {
// mediaDir is already created in public handler
return &AdminHandler{
mediaDir: mediaDir,
}
}
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 "":
switch r.Method {
case http.MethodGet:
serveMediaIndex(w, r, user, company, conn)
case http.MethodPost:
uploadMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "picker":
switch r.Method {
case http.MethodGet:
serveMediaPicker(w, r, user, company, conn)
case http.MethodPost:
pickMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "upload":
switch r.Method {
case http.MethodGet:
f := newUploadForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
return
}
f := newMediaForm()
if err = f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editMedia(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "content":
switch r.Method {
case http.MethodGet:
httplib.Redirect(w, r, f.Path, http.StatusFound)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
})
}
func serveMediaIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &mediaIndex{
Media: media,
}
page.MustRender(w, r, user, company)
}
func collectMediaEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*mediaEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
from media
where company_id = $1
order by media_id
`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*mediaEntry
for rows.Next() {
entry := &mediaEntry{}
if err = rows.Scan(&entry.ID, &entry.Path); err != nil {
return nil, err
}
media = append(media, entry)
}
return media, nil
}
type mediaEntry struct {
ID uint
Path string
}
type mediaIndex struct {
Media []*mediaEntry
}
func (page *mediaIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/index.gohtml", page)
}
func serveMediaPicker(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
query := r.URL.Query()
page := &mediaPicker{
Media: media,
Field: &form.Media{
Input: &form.Input{
Name: query.Get("name"),
Val: query.Get("value"),
},
Label: query.Get("label"),
Prompt: query.Get("prompt"),
},
}
page.MustRender(w, r, user, company)
}
type mediaPicker struct {
Media []*mediaEntry
Field *form.Media
}
func (page *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/picker.gohtml", page)
}
func pickMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
input := &form.Media{
Input: &form.Input{
Name: strings.TrimSpace(r.FormValue("name")),
Val: strings.TrimSpace(r.FormValue("value")),
},
Label: strings.TrimSpace(r.FormValue("label")),
Prompt: strings.TrimSpace(r.FormValue("prompt")),
}
template.MustRenderNoLayout(w, r, user, company, "media/field.gohtml", input)
}
func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newUploadForm()
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 !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select add_media($1, $2, $3, $4)", company.ID, f.File.Filename(), f.File.ContentType, bytes)
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
type uploadForm struct {
File *form.File
}
func newUploadForm() *uploadForm {
return &uploadForm{
File: &form.File{
Name: "media",
MaxSize: 10 * 1 << 20,
},
}
}
func (f *uploadForm) Parse(w http.ResponseWriter, r *http.Request) error {
maxSize := f.File.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
if err := f.File.FillValue(r); err != nil {
return err
}
return nil
}
func (f *uploadForm) Close() error {
return f.File.Close()
}
func (f *uploadForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.Check(f.File, f.HasFile(), l.GettextNoop("Uploaded file can not be empty."))
return v.AllOK
}
func (f *uploadForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/upload.gohtml", f)
}
func (f *uploadForm) HasFile() bool {
return f.File.HasData()
}
func (f *uploadForm) MustReadAllFile() []byte {
if !f.HasFile() {
return nil
}
bytes, err := io.ReadAll(f.File)
if err != nil {
panic(err)
}
return bytes
}
type mediaForm struct {
*uploadForm
ID int
Path string
OriginalFilename *form.Input
}
func newMediaForm() *mediaForm {
return &mediaForm{
uploadForm: newUploadForm(),
OriginalFilename: &form.Input{
Name: "original_filename",
},
}
}
func (f *mediaForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, "select original_filename, media.path from media where media_id = $1", id)
return row.Scan(&f.OriginalFilename.Val, &f.Path)
}
func (f *mediaForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/form.gohtml", f)
}
func (f *mediaForm) Parse(w http.ResponseWriter, r *http.Request) error {
if err := f.uploadForm.Parse(w, r); err != nil {
return err
}
f.OriginalFilename.FillValue(r)
return nil
}
func (f *mediaForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(f.OriginalFilename, l.GettextNoop("Filename can not be empty."))
return v.AllOK
}
func editMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *mediaForm) {
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 !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
if f.HasFile() {
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select edit_media($1, $2, $3, $4)", f.ID, f.OriginalFilename, f.File.ContentType, bytes)
} else {
conn.MustExec(r.Context(), "select edit_media($1, $2)", f.ID, f.OriginalFilename)
}
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package app package media
import ( import (
"net/http" "net/http"
@ -18,45 +18,45 @@ import (
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
) )
type mediaHandler struct { type PublicHandler struct {
mediaDir string mediaDir string
fileHandler http.Handler fileHandler http.Handler
} }
func newMediaHandler(mediaDir string) (*mediaHandler, error) { func NewPublicHandler(mediaDir string) (*PublicHandler, error) {
if err := os.MkdirAll(mediaDir, 0755); err != nil { if err := os.MkdirAll(mediaDir, 0755); err != nil {
return nil, err return nil, err
} }
handler := &mediaHandler{ handler := &PublicHandler{
mediaDir: mediaDir, mediaDir: mediaDir,
fileHandler: http.FileServer(http.Dir(mediaDir)), fileHandler: http.FileServer(http.Dir(mediaDir)),
} }
return handler, nil return handler, nil
} }
func (h *mediaHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc { func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
if !mediaHashValid(head) { if !hashValid(head) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
h.serveMedia(w, r, company, conn, strings.ToLower(head)) h.serveMedia(w, r, conn, strings.ToLower(head))
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
} })
} }
func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, company *auth.Company, conn *database.Conn, hash string) { func (h *PublicHandler) serveMedia(w http.ResponseWriter, r *http.Request, conn *database.Conn, hash string) {
mediaPath := h.mediaPath(hash) mediaPath := h.mediaPath(hash)
var err error var err error
if _, err = os.Stat(mediaPath); err != nil { 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) bytes, err := conn.GetBytes(r.Context(), "select bytes from media_content where content_hash = decode($1, 'hex')", hash)
if err != nil { if err != nil {
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
http.NotFound(w, r) http.NotFound(w, r)
@ -78,11 +78,11 @@ func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, compan
h.fileHandler.ServeHTTP(w, r) h.fileHandler.ServeHTTP(w, r)
} }
func (h *mediaHandler) mediaPath(hash string) string { func (h *PublicHandler) mediaPath(hash string) string {
return filepath.Join(h.mediaDir, hash[:2], hash[2:]) return filepath.Join(h.mediaDir, hash[:2], hash[2:])
} }
func mediaHashValid(s string) bool { func hashValid(s string) bool {
if len(s) != 64 { if len(s) != 64 {
return false return false
} }

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package app package media
import ( import (
"strings" "strings"
@ -24,7 +24,7 @@ var tests = []test{
} }
func testValid(t *testing.T, in string, isHash bool) { func testValid(t *testing.T, in string, isHash bool) {
if ok := mediaHashValid(in); ok != isHash { if ok := hashValid(in); ok != isHash {
t.Errorf("Valid(%s) got %v expected %v", in, ok, isHash) t.Errorf("Valid(%s) got %v expected %v", in, ok, isHash)
} }
} }

View File

@ -9,18 +9,25 @@ import (
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
const carouselName = "services"
type AdminHandler struct { type AdminHandler struct {
locales locale.Locales locales locale.Locales
carousel *carousel.AdminHandler
} }
func NewAdminHandler(locales locale.Locales) *AdminHandler { func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{locales} return &AdminHandler{
locales: locales,
carousel: carousel.NewAdminHandler(carouselName, locales),
}
} }
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
case "slides": case "slides":
h.carouselHandler(user, company, conn).ServeHTTP(w, r) h.carousel.Handler(user, company, conn).ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -45,7 +52,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
} }
func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := collectSlideEntries(r.Context(), company, conn) slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com
} }
type servicesIndex struct { type servicesIndex struct {
Slides []*slideEntry Slides []*carousel.SlideEntry
} }
func (page *servicesIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (page *servicesIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {

View File

@ -1,311 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package services
import (
"context"
"io"
"net/http"
"strconv"
"github.com/jackc/pgx/v4"
"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"
)
func (h *AdminHandler) carouselHandler(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 "":
switch r.Method {
case http.MethodPost:
addSlide(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "new":
switch r.Method {
case http.MethodGet:
f := newSlideForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
}
f := newSlideForm()
if err := f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
var langTag string
langTag, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch langTag {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editSlide(w, r, user, company, conn, f)
case http.MethodDelete:
deleteSlide(w, r, user, conn, id)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
loc, ok := h.locales.Get(langTag)
if !ok {
http.NotFound(w, r)
return
}
l10n := newSlideL10nForm(f, loc)
if err := l10n.FillFromDatabase(r.Context(), conn); err != nil {
panic(err)
}
switch r.Method {
case http.MethodGet:
l10n.MustRender(w, r, user, company)
case http.MethodPut:
editSlideL10n(w, r, user, company, conn, l10n)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
}
}
})
}
type carouselSlide struct {
Media string
Caption string
}
func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*carouselSlide {
rows, err := conn.Query(ctx, `
select coalesce(i18n.caption, slide.caption) as l10_caption
, media.path
from services_carousel as slide
join media using (media_id)
left join services_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1
where media.company_id = $2
`, loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
var carousel []*carouselSlide
for rows.Next() {
slide := &carouselSlide{}
err = rows.Scan(&slide.Caption, &slide.Media)
if err != nil {
panic(err)
}
carousel = append(carousel, slide)
}
if rows.Err() != nil {
panic(rows.Err())
}
return carousel
}
type slideEntry struct {
carouselSlide
ID int
Translations []*translation
}
type translation struct {
Language string
Endonym string
Missing bool
}
func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
, caption
, array_agg((lang_tag, endonym, not exists (select 1 from services_carousel_i18n as i18n where i18n.media_id = services_carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from services_carousel
join media using (media_id)
join company using (company_id)
, language
where lang_tag <> default_lang_tag
and language.selectable
and media.company_id = $1
group by media_id
, media.path
, caption
order by caption
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var slides []*slideEntry
for rows.Next() {
slide := &slideEntry{}
var translations database.RecordArray
if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil {
return nil, err
}
for _, el := range translations.Elements {
slide.Translations = append(slide.Translations, &translation{
el.Fields[0].Get().(string),
el.Fields[1].Get().(string),
el.Fields[2].Get().(bool),
})
}
slides = append(slides, slide)
}
return slides, nil
}
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newSlideForm()
editSlide(w, r, user, company, conn, f)
}
func editSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *slideForm) {
f.process(w, r, user, company, false, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
if bytes == nil {
conn.MustExec(ctx, "select add_services_carousel_slide($1, $2)", f.ID, f.Caption)
} else {
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
f.ID = tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select add_services_carousel_slide($1, $2)", f.ID, f.Caption)
tx.MustCommit(ctx)
}
})
}
func deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn.MustExec(r.Context(), "select remove_services_carousel_slide($1)", id)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
}
type slideForm struct {
ID int
Media *form.File
Caption *form.Input
}
func newSlideForm() *slideForm {
return &slideForm{
Media: &form.File{
Name: "media",
MaxSize: 1 << 20,
},
Caption: &form.Input{
Name: "caption",
},
}
}
func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, `
select caption
, media.path
from services_carousel
join media using (media_id)
where media_id = $1
`, id)
return row.Scan(&f.Caption.Val, &f.Media.Val)
}
func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, 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, mediaRequired) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
act(r.Context())
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
}
func (f *slideForm) 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.Caption.FillValue(r)
if err := f.Media.FillValue(r); err != nil {
return err
}
return nil
}
func (f *slideForm) Close() error {
return f.Media.Close()
}
func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool {
v := form.NewValidator(l)
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("Slide image can not be empty."))
}
return v.AllOK
}
func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "services/carousel/form.gohtml", f)
}
func (f *slideForm) HasMediaFile() bool {
return f.Media.HasData()
}
func (f *slideForm) MustReadAllMedia() []byte {
if !f.HasMediaFile() {
return nil
}
bytes, err := io.ReadAll(f.Media)
if err != nil {
panic(err)
}
return bytes
}

View File

@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
@ -45,7 +46,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type servicesPage struct { type servicesPage struct {
*template.PublicPage *template.PublicPage
Services []*service Services []*service
Carousel []*carouselSlide Carousel []*carousel.Slide
} }
func newServicesPage() *servicesPage { func newServicesPage() *servicesPage {
@ -55,7 +56,7 @@ func newServicesPage() *servicesPage {
func (p *servicesPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func (p *servicesPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn) p.Setup(r, user, company, conn)
p.Services = mustCollectServices(r.Context(), company, conn, user.Locale) p.Services = mustCollectServices(r.Context(), company, conn, user.Locale)
p.Carousel = mustCollectCarouselSlides(r.Context(), company, conn, user.Locale) p.Carousel = carousel.MustCollectSlides(r.Context(), company, conn, user.Locale, carouselName)
template.MustRenderPublic(w, r, user, company, "services.gohtml", p) template.MustRenderPublic(w, r, user, company, "services.gohtml", p)
} }

30
pkg/template/humanize.go Normal file
View File

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 20122015 Dustin Sallings <dustin@spy.net>
* SPDX-License-Identifier: MIT
*/
package template
import (
"fmt"
"math"
)
func humanizeBytes(s int64) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
base := 1024.0
e := math.Floor(logn(float64(s), base))
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return fmt.Sprintf(f, val, sizes[int(e)])
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}

View File

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 20122015 Dustin Sallings <dustin@spy.net>
* SPDX-License-Identifier: MIT
*/
package template
import "testing"
func testHumanizeBytes(t *testing.T, in int64, exp string) {
if actual := humanizeBytes(in); actual != exp {
t.Errorf("humanizeBytes(%d) got %v expected %v", in, actual, exp)
}
}
func TestBytes(t *testing.T) {
const (
IByte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
tests := []struct {
in int64
exp string
}{
{0, "0 B"},
{1, "1 B"},
{803, "803 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{MiByte - IByte, "1024 KiB"},
{1024 * 1024, "1.0 MiB"},
{GiByte - KiByte, "1024 MiB"},
{GiByte, "1.0 GiB"},
{TiByte - MiByte, "1024 GiB"},
{TiByte, "1.0 TiB"},
{PiByte - TiByte, "1023 TiB"},
{PiByte, "1.0 PiB"},
{EiByte - PiByte, "1023 PiB"},
{EiByte, "1.0 EiB"},
{5.5 * GiByte, "5.5 GiB"},
}
for _, tt := range tests {
testHumanizeBytes(t, tt.in, tt.exp)
}
}

View File

@ -10,6 +10,7 @@ import (
"html/template" "html/template"
"io" "io"
"net/http" "net/http"
"path"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
@ -28,16 +29,20 @@ func MustRenderAdmin(w io.Writer, r *http.Request, user *auth.User, company *aut
if httplib.IsHTMxRequest(r) { if httplib.IsHTMxRequest(r) {
layout = "htmx.gohtml" layout = "htmx.gohtml"
} }
mustRenderLayout(w, user, company, adminTemplateFile, layout, filename, data) mustRenderLayout(w, user, company, adminTemplateFile, data, layout, filename)
}
func MustRenderNoLayout(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
mustRenderLayout(w, user, company, adminTemplateFile, data, filename)
} }
func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) { func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
layout := "layout.gohtml" layout := "layout.gohtml"
mustRenderLayout(w, user, company, publicTemplateFile, layout, filename, data) mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename)
} }
func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templateFile func(string) string, layout string, filename string, data interface{}) { func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templateFile func(string) string, data interface{}, templates ...string) {
t := template.New(filename) t := template.New(templates[len(templates)-1])
t.Funcs(template.FuncMap{ t.Funcs(template.FuncMap{
"gettext": user.Locale.Get, "gettext": user.Locale.Get,
"pgettext": user.Locale.GetC, "pgettext": user.Locale.GetC,
@ -57,14 +62,22 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"raw": func(s string) template.HTML { "raw": func(s string) template.HTML {
return template.HTML(s) return template.HTML(s)
}, },
"humanizeBytes": func(bytes int64) string {
return humanizeBytes(bytes)
},
}) })
if _, err := t.ParseFiles(templateFile(layout), templateFile("form.gohtml"), templateFile(filename)); err != nil { templates = append(templates, "form.gohtml")
files := make([]string, len(templates))
for i, tmpl := range templates {
files[i] = templateFile(tmpl)
}
if _, err := t.ParseFiles(files...); err != nil {
panic(err) panic(err)
} }
if rw, ok := w.(http.ResponseWriter); ok { if rw, ok := w.(http.ResponseWriter); ok {
rw.Header().Set("Content-Type", "text/html; charset=utf-8") rw.Header().Set("Content-Type", "text/html; charset=utf-8")
} }
if err := t.ExecuteTemplate(w, layout, data); err != nil { if err := t.ExecuteTemplate(w, path.Base(templates[0]), data); err != nil {
panic(err) panic(err)
} }
} }

259
po/ca.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-17 03:28+0200\n" "POT-Creation-Date: 2023-09-21 01:41+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -28,7 +28,7 @@ msgstr "Serveis"
msgid "The campsite offers many different services." msgid "The campsite offers many different services."
msgstr "El càmping disposa de diversos serveis." msgstr "El càmping disposa de diversos serveis."
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:29 #: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title" msgctxt "title"
msgid "Home" msgid "Home"
msgstr "Inici" msgstr "Inici"
@ -131,8 +131,8 @@ msgstr "Caiac"
msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…." msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…."
msgstr "Hi ha diversos punts on poder anar amb caiac, des de trams del riu Ter com també a la costa…." msgstr "Hi ha diversos punts on poder anar amb caiac, des de trams del riu Ter com també a la costa…."
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:24 #: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:59 #: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut" msgid "Campsite Montagut"
msgstr "Càmping Montagut" msgstr "Càmping Montagut"
@ -140,10 +140,70 @@ msgstr "Càmping Montagut"
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Salta al contingut principal" msgstr "Salta al contingut principal"
#: web/templates/public/layout.gohtml:33 #: web/templates/public/layout.gohtml:32
msgid "Singular Lodges" msgid "Singular Lodges"
msgstr "Allotjaments singulars" msgstr "Allotjaments singulars"
#: web/templates/admin/carousel/form.gohtml:8
#: web/templates/admin/carousel/form.gohtml:26
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edició de la diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:10
#: web/templates/admin/carousel/form.gohtml:28
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nova diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:38
#: web/templates/admin/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Llegenda"
#: web/templates/admin/carousel/form.gohtml:48
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:67
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/media/form.gohtml:36
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/carousel/form.gohtml:50
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:69
#: web/templates/admin/season/form.gohtml:67
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/carousel/l10n.gohtml:7
#: web/templates/admin/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducció de la diapositiva del carrusel a %s"
#: web/templates/admin/carousel/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/carousel/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
msgctxt "input"
msgid "Translation:"
msgstr "Traducció:"
#: web/templates/admin/carousel/l10n.gohtml:33
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Tradueix"
#: web/templates/admin/campsite/form.gohtml:8 #: web/templates/admin/campsite/form.gohtml:8
#: web/templates/admin/campsite/form.gohtml:26 #: web/templates/admin/campsite/form.gohtml:26
msgctxt "title" msgctxt "title"
@ -176,24 +236,6 @@ msgctxt "input"
msgid "Label" msgid "Label"
msgstr "Etiqueta" msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/services/carousel/form.gohtml:58
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/services/carousel/form.gohtml:60
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/campsite/index.gohtml:6 #: web/templates/admin/campsite/index.gohtml:6
#: web/templates/admin/campsite/index.gohtml:13 #: web/templates/admin/campsite/index.gohtml:13
#: web/templates/admin/layout.gohtml:70 #: web/templates/admin/layout.gohtml:70
@ -233,24 +275,24 @@ msgid "No campsites added yet."
msgstr "No sha afegit cap allotjament encara." msgstr "No sha afegit cap allotjament encara."
#: web/templates/admin/campsite/type/form.gohtml:8 #: web/templates/admin/campsite/type/form.gohtml:8
#: web/templates/admin/campsite/type/form.gohtml:27 #: web/templates/admin/campsite/type/form.gohtml:26
msgctxt "title" msgctxt "title"
msgid "Edit Campsite Type" msgid "Edit Campsite Type"
msgstr "Edició del tipus dallotjament" msgstr "Edició del tipus dallotjament"
#: web/templates/admin/campsite/type/form.gohtml:10 #: web/templates/admin/campsite/type/form.gohtml:10
#: web/templates/admin/campsite/type/form.gohtml:29 #: web/templates/admin/campsite/type/form.gohtml:28
msgctxt "title" msgctxt "title"
msgid "New Campsite Type" msgid "New Campsite Type"
msgstr "Nou tipus dallotjament" msgstr "Nou tipus dallotjament"
#: web/templates/admin/campsite/type/form.gohtml:39 #: web/templates/admin/campsite/type/form.gohtml:38
#: web/templates/admin/campsite/type/index.gohtml:20 #: web/templates/admin/campsite/type/index.gohtml:20
msgctxt "campsite type" msgctxt "campsite type"
msgid "Active" msgid "Active"
msgstr "Actiu" msgstr "Actiu"
#: web/templates/admin/campsite/type/form.gohtml:48 #: web/templates/admin/campsite/type/form.gohtml:47
#: web/templates/admin/campsite/type/l10n.gohtml:21 #: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47 #: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26 #: web/templates/admin/profile.gohtml:26
@ -258,14 +300,7 @@ msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: web/templates/admin/campsite/type/form.gohtml:59 #: web/templates/admin/campsite/type/form.gohtml:58
#: web/templates/admin/services/carousel/form.gohtml:39
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input"
msgid "Cover image"
msgstr "Imatge de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/l10n.gohtml:33 #: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
@ -306,28 +341,6 @@ msgctxt "title"
msgid "Translate Campsite Type to %s" msgid "Translate Campsite Type to %s"
msgstr "Traducció del tipus dallotjament a %s" msgstr "Traducció del tipus dallotjament a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
#: web/templates/admin/services/carousel/l10n.gohtml:22
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/services/carousel/l10n.gohtml:24
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input"
msgid "Translation:"
msgstr "Traducció:"
#: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/services/carousel/l10n.gohtml:33
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action"
msgid "Translate"
msgstr "Tradueix"
#: web/templates/admin/season/form.gohtml:8 #: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26 #: web/templates/admin/season/form.gohtml:26
msgctxt "title" msgctxt "title"
@ -399,43 +412,11 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
#: web/templates/admin/services/carousel/form.gohtml:8
#: web/templates/admin/services/carousel/form.gohtml:27
#: web/templates/admin/home/carousel/form.gohtml:8
#: web/templates/admin/home/carousel/form.gohtml:27
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edició de la diapositiva del carrusel"
#: web/templates/admin/services/carousel/form.gohtml:10
#: web/templates/admin/services/carousel/form.gohtml:29
#: web/templates/admin/home/carousel/form.gohtml:10
#: web/templates/admin/home/carousel/form.gohtml:29
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nova diapositiva del carrusel"
#: web/templates/admin/services/carousel/form.gohtml:48
#: web/templates/admin/services/carousel/l10n.gohtml:21
#: web/templates/admin/home/carousel/form.gohtml:48
#: web/templates/admin/home/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Llegenda"
#: web/templates/admin/services/carousel/l10n.gohtml:7
#: web/templates/admin/services/carousel/l10n.gohtml:15
#: web/templates/admin/home/carousel/l10n.gohtml:7
#: web/templates/admin/home/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducció de la diapositiva del carrusel a %s"
#: web/templates/admin/services/index.gohtml:6 #: web/templates/admin/services/index.gohtml:6
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 #: web/templates/admin/layout.gohtml:79
msgctxt "title" msgctxt "title"
msgid "Home Page" msgid "Services Page"
msgstr "Pàgina dinici" msgstr "Pàgina de serveis"
#: web/templates/admin/services/index.gohtml:12 #: web/templates/admin/services/index.gohtml:12
#: web/templates/admin/home/index.gohtml:12 #: web/templates/admin/home/index.gohtml:12
@ -598,27 +579,59 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" msgstr "Surt"
#: web/templates/admin/layout.gohtml:79 #: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
#, fuzzy
msgctxt "title" msgctxt "title"
msgid "Services Page" msgid "Home Page"
msgstr "Serveis" msgstr "Pàgina dinici"
#: web/templates/admin/layout.gohtml:82
#: web/templates/admin/media/index.gohtml:6 #: web/templates/admin/media/index.gohtml:6
#: web/templates/admin/media/index.gohtml:12 #: web/templates/admin/media/index.gohtml:12
msgctxt "title" msgctxt "title"
msgid "Media" msgid "Media"
msgstr "Mèdia" msgstr "Mèdia"
#: web/templates/admin/media/picker.gohtml:7
msgctxt "title"
msgid "Media Picker"
msgstr "Selector de mèdia"
#: web/templates/admin/media/picker.gohtml:26
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No sha pujat cap mèdia encara."
#: web/templates/admin/media/picker.gohtml:29
msgctxt "action"
msgid "Cancel"
msgstr "Canceŀla"
#: web/templates/admin/media/form.gohtml:6
#: web/templates/admin/media/form.gohtml:13
msgctxt "title"
msgid "Edit Media"
msgstr "Edició de mèdia"
#: web/templates/admin/media/form.gohtml:19
msgctxt "input"
msgid "Updated file"
msgstr "Fitxer actualitzat"
#: web/templates/admin/media/form.gohtml:23
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Mida màxima del fitxer a pujar: %s"
#: web/templates/admin/media/form.gohtml:28
msgctxt "input"
msgid "Filename"
msgstr "Nom del fitxer"
#: web/templates/admin/media/index.gohtml:13 #: web/templates/admin/media/index.gohtml:13
msgctxt "action" msgctxt "action"
msgid "Upload media" msgid "Upload media"
msgstr "Puja mèdia" msgstr "Puja mèdia"
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No sha pujat cap mèdia encara."
#: web/templates/admin/media/upload.gohtml:6 #: web/templates/admin/media/upload.gohtml:6
#: web/templates/admin/media/upload.gohtml:13 #: web/templates/admin/media/upload.gohtml:13
msgctxt "title" msgctxt "title"
@ -630,15 +643,29 @@ msgctxt "input"
msgid "File" msgid "File"
msgstr "Fitxer" msgstr "Fitxer"
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Mida màxima del fitxer a pujar: %s"
#: web/templates/admin/media/upload.gohtml:27 #: web/templates/admin/media/upload.gohtml:27
msgctxt "action" msgctxt "action"
msgid "Upload" msgid "Upload"
msgstr "Puja" msgstr "Puja"
#: pkg/carousel/admin.go:233 pkg/campsite/types/admin.go:236
msgctxt "input"
msgid "Cover image"
msgstr "Imatge de portada"
#: pkg/carousel/admin.go:234
msgctxt "action"
msgid "Set cover image"
msgstr "Estableix la imatge de portada"
#: pkg/carousel/admin.go:286
msgid "Slide image can not be empty."
msgstr "No podeu deixar la imatge de la diapositiva en blanc."
#: pkg/carousel/admin.go:287 pkg/campsite/types/admin.go:276
msgid "Cover image must be a image media."
msgstr "La imatge de portada ha de ser un mèdia dimatge."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc." msgstr "No podeu deixar el correu-e en blanc."
@ -661,7 +688,7 @@ msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82
#: pkg/campsite/types/admin.go:294 pkg/season/admin.go:203 #: pkg/campsite/types/admin.go:274 pkg/season/admin.go:203
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
@ -673,16 +700,20 @@ msgstr "La confirmació no es correspon amb la contrasenya."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid." msgstr "Lidioma escollit no és vàlid."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:296 #: pkg/app/user.go:253
#: pkg/services/carousel.go:287 pkg/home/carousel.go:287
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/app/admin.go:50 #: pkg/app/admin.go:53
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Accés prohibit" msgstr "Accés prohibit"
#: pkg/campsite/types/admin.go:298 #: pkg/campsite/types/admin.go:237
msgctxt "action"
msgid "Set campsite type cover"
msgstr "Estableix la portada del tipus dallotjament"
#: pkg/campsite/types/admin.go:275
msgid "Cover image can not be empty." msgid "Cover image can not be empty."
msgstr "No podeu deixar la imatge de portada en blanc." msgstr "No podeu deixar la imatge de portada en blanc."
@ -702,10 +733,6 @@ msgstr "No podeu deixar el color en blanc."
msgid "This color is not valid. It must be like #123abc." msgid "This color is not valid. It must be like #123abc."
msgstr "Aquest color no és vàlid. Hauria de ser similar a #123abc." msgstr "Aquest color no és vàlid. Hauria de ser similar a #123abc."
#: pkg/services/carousel.go:289 pkg/home/carousel.go:289
msgid "Slide image can not be empty."
msgstr "No podeu deixar la imatge de la diapositiva en blanc."
#: pkg/company/admin.go:186 #: pkg/company/admin.go:186
msgid "Selected country is not valid." msgid "Selected country is not valid."
msgstr "El país escollit no és vàlid." msgstr "El país escollit no és vàlid."
@ -770,10 +797,14 @@ msgstr "No podeu deixar el format del número de factura en blanc."
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats." msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."
#: pkg/media/admin.go:164 #: pkg/media/admin.go:255
msgid "Uploaded file can not be empty." msgid "Uploaded file can not be empty."
msgstr "No podeu deixar el fitxer del mèdia en blanc." msgstr "No podeu deixar el fitxer del mèdia en blanc."
#: pkg/media/admin.go:314
msgid "Filename can not be empty."
msgstr "No podeu deixar el nom del fitxer."
#~ msgid "Surroundings" #~ msgid "Surroundings"
#~ msgstr "Entorn" #~ msgstr "Entorn"

260
po/es.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-17 03:28+0200\n" "POT-Creation-Date: 2023-09-21 01:41+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -28,7 +28,7 @@ msgstr "Servicios"
msgid "The campsite offers many different services." msgid "The campsite offers many different services."
msgstr "El camping dispone de varios servicios." msgstr "El camping dispone de varios servicios."
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:29 #: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title" msgctxt "title"
msgid "Home" msgid "Home"
msgstr "Inicio" msgstr "Inicio"
@ -79,7 +79,6 @@ msgid "What to Do Outside the Campsite?"
msgstr "¿Qué hacer desde el camping?" msgstr "¿Qué hacer desde el camping?"
#: web/templates/public/surroundings.gohtml:15 #: web/templates/public/surroundings.gohtml:15
#, fuzzy
msgid "Campsite Montagut is an ideal starting point for quiet outings, climbing, swimming in the river and gorges, volcanoes, the Fageda den Jordà, cycle tours for all ages…." msgid "Campsite Montagut is an ideal starting point for quiet outings, climbing, swimming in the river and gorges, volcanoes, the Fageda den Jordà, cycle tours for all ages…."
msgstr "El Camping Montagut es ideal como punto de salida de excursiones tranquilas, escalada, bañarse en el río y piletones, volcanes, la Fageda den Jordà, salidas en bicicleta para todos los niveles…." msgstr "El Camping Montagut es ideal como punto de salida de excursiones tranquilas, escalada, bañarse en el río y piletones, volcanes, la Fageda den Jordà, salidas en bicicleta para todos los niveles…."
@ -132,8 +131,8 @@ msgstr "Kayak"
msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…." msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…."
msgstr "Hay diversos puntos dónde podéis ir en kayak, desde tramos del río Ter como también en la costa…." msgstr "Hay diversos puntos dónde podéis ir en kayak, desde tramos del río Ter como también en la costa…."
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:24 #: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:59 #: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut" msgid "Campsite Montagut"
msgstr "Camping Montagut" msgstr "Camping Montagut"
@ -141,10 +140,70 @@ msgstr "Camping Montagut"
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Saltar al contenido principal" msgstr "Saltar al contenido principal"
#: web/templates/public/layout.gohtml:33 #: web/templates/public/layout.gohtml:32
msgid "Singular Lodges" msgid "Singular Lodges"
msgstr "Alojamientos singulares" msgstr "Alojamientos singulares"
#: web/templates/admin/carousel/form.gohtml:8
#: web/templates/admin/carousel/form.gohtml:26
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edición de la diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:10
#: web/templates/admin/carousel/form.gohtml:28
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nueva diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:38
#: web/templates/admin/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Leyenda"
#: web/templates/admin/carousel/form.gohtml:48
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:67
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/media/form.gohtml:36
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/templates/admin/carousel/form.gohtml:50
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:69
#: web/templates/admin/season/form.gohtml:67
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/carousel/l10n.gohtml:7
#: web/templates/admin/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducción de la diapositiva de carrusel a %s"
#: web/templates/admin/carousel/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/carousel/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
msgctxt "input"
msgid "Translation:"
msgstr "Traducción"
#: web/templates/admin/carousel/l10n.gohtml:33
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Traducir"
#: web/templates/admin/campsite/form.gohtml:8 #: web/templates/admin/campsite/form.gohtml:8
#: web/templates/admin/campsite/form.gohtml:26 #: web/templates/admin/campsite/form.gohtml:26
msgctxt "title" msgctxt "title"
@ -177,24 +236,6 @@ msgctxt "input"
msgid "Label" msgid "Label"
msgstr "Etiqueta" msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/services/carousel/form.gohtml:58
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/services/carousel/form.gohtml:60
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/campsite/index.gohtml:6 #: web/templates/admin/campsite/index.gohtml:6
#: web/templates/admin/campsite/index.gohtml:13 #: web/templates/admin/campsite/index.gohtml:13
#: web/templates/admin/layout.gohtml:70 #: web/templates/admin/layout.gohtml:70
@ -234,24 +275,24 @@ msgid "No campsites added yet."
msgstr "No se ha añadido ningún alojamiento todavía." msgstr "No se ha añadido ningún alojamiento todavía."
#: web/templates/admin/campsite/type/form.gohtml:8 #: web/templates/admin/campsite/type/form.gohtml:8
#: web/templates/admin/campsite/type/form.gohtml:27 #: web/templates/admin/campsite/type/form.gohtml:26
msgctxt "title" msgctxt "title"
msgid "Edit Campsite Type" msgid "Edit Campsite Type"
msgstr "Edición del tipo de alojamientos" msgstr "Edición del tipo de alojamientos"
#: web/templates/admin/campsite/type/form.gohtml:10 #: web/templates/admin/campsite/type/form.gohtml:10
#: web/templates/admin/campsite/type/form.gohtml:29 #: web/templates/admin/campsite/type/form.gohtml:28
msgctxt "title" msgctxt "title"
msgid "New Campsite Type" msgid "New Campsite Type"
msgstr "Nuevo tipo de alojamiento" msgstr "Nuevo tipo de alojamiento"
#: web/templates/admin/campsite/type/form.gohtml:39 #: web/templates/admin/campsite/type/form.gohtml:38
#: web/templates/admin/campsite/type/index.gohtml:20 #: web/templates/admin/campsite/type/index.gohtml:20
msgctxt "campsite type" msgctxt "campsite type"
msgid "Active" msgid "Active"
msgstr "Activo" msgstr "Activo"
#: web/templates/admin/campsite/type/form.gohtml:48 #: web/templates/admin/campsite/type/form.gohtml:47
#: web/templates/admin/campsite/type/l10n.gohtml:21 #: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47 #: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26 #: web/templates/admin/profile.gohtml:26
@ -259,14 +300,7 @@ msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: web/templates/admin/campsite/type/form.gohtml:59 #: web/templates/admin/campsite/type/form.gohtml:58
#: web/templates/admin/services/carousel/form.gohtml:39
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input"
msgid "Cover image"
msgstr "Imagen de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/l10n.gohtml:33 #: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input" msgctxt "input"
msgid "Description" msgid "Description"
@ -307,28 +341,6 @@ msgctxt "title"
msgid "Translate Campsite Type to %s" msgid "Translate Campsite Type to %s"
msgstr "Traducción de tipo de alojamiento a %s" msgstr "Traducción de tipo de alojamiento a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
#: web/templates/admin/services/carousel/l10n.gohtml:22
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/services/carousel/l10n.gohtml:24
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input"
msgid "Translation:"
msgstr "Traducción"
#: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/services/carousel/l10n.gohtml:33
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action"
msgid "Translate"
msgstr "Traducir"
#: web/templates/admin/season/form.gohtml:8 #: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26 #: web/templates/admin/season/form.gohtml:26
msgctxt "title" msgctxt "title"
@ -400,43 +412,11 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
#: web/templates/admin/services/carousel/form.gohtml:8
#: web/templates/admin/services/carousel/form.gohtml:27
#: web/templates/admin/home/carousel/form.gohtml:8
#: web/templates/admin/home/carousel/form.gohtml:27
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edición de la diapositiva del carrusel"
#: web/templates/admin/services/carousel/form.gohtml:10
#: web/templates/admin/services/carousel/form.gohtml:29
#: web/templates/admin/home/carousel/form.gohtml:10
#: web/templates/admin/home/carousel/form.gohtml:29
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nueva diapositiva del carrusel"
#: web/templates/admin/services/carousel/form.gohtml:48
#: web/templates/admin/services/carousel/l10n.gohtml:21
#: web/templates/admin/home/carousel/form.gohtml:48
#: web/templates/admin/home/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Leyenda"
#: web/templates/admin/services/carousel/l10n.gohtml:7
#: web/templates/admin/services/carousel/l10n.gohtml:15
#: web/templates/admin/home/carousel/l10n.gohtml:7
#: web/templates/admin/home/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducción de la diapositiva de carrusel a %s"
#: web/templates/admin/services/index.gohtml:6 #: web/templates/admin/services/index.gohtml:6
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 #: web/templates/admin/layout.gohtml:79
msgctxt "title" msgctxt "title"
msgid "Home Page" msgid "Services Page"
msgstr "Página de inicio" msgstr "Página de servicios"
#: web/templates/admin/services/index.gohtml:12 #: web/templates/admin/services/index.gohtml:12
#: web/templates/admin/home/index.gohtml:12 #: web/templates/admin/home/index.gohtml:12
@ -599,27 +579,59 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: web/templates/admin/layout.gohtml:79 #: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
#, fuzzy
msgctxt "title" msgctxt "title"
msgid "Services Page" msgid "Home Page"
msgstr "Servicios" msgstr "Página de inicio"
#: web/templates/admin/layout.gohtml:82
#: web/templates/admin/media/index.gohtml:6 #: web/templates/admin/media/index.gohtml:6
#: web/templates/admin/media/index.gohtml:12 #: web/templates/admin/media/index.gohtml:12
msgctxt "title" msgctxt "title"
msgid "Media" msgid "Media"
msgstr "Medios" msgstr "Medios"
#: web/templates/admin/media/picker.gohtml:7
msgctxt "title"
msgid "Media Picker"
msgstr "Selector de medio"
#: web/templates/admin/media/picker.gohtml:26
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No se ha subido ningún medio todavía."
#: web/templates/admin/media/picker.gohtml:29
msgctxt "action"
msgid "Cancel"
msgstr "Cancelar"
#: web/templates/admin/media/form.gohtml:6
#: web/templates/admin/media/form.gohtml:13
msgctxt "title"
msgid "Edit Media"
msgstr "Edición de medio"
#: web/templates/admin/media/form.gohtml:19
msgctxt "input"
msgid "Updated file"
msgstr "Archivo actualizado"
#: web/templates/admin/media/form.gohtml:23
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Tamaño máximo del archivos a subir: %s"
#: web/templates/admin/media/form.gohtml:28
msgctxt "input"
msgid "Filename"
msgstr "Nombre de archivo"
#: web/templates/admin/media/index.gohtml:13 #: web/templates/admin/media/index.gohtml:13
msgctxt "action" msgctxt "action"
msgid "Upload media" msgid "Upload media"
msgstr "Subir medio" msgstr "Subir medio"
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No se ha subido ningún medio todavía."
#: web/templates/admin/media/upload.gohtml:6 #: web/templates/admin/media/upload.gohtml:6
#: web/templates/admin/media/upload.gohtml:13 #: web/templates/admin/media/upload.gohtml:13
msgctxt "title" msgctxt "title"
@ -631,15 +643,29 @@ msgctxt "input"
msgid "File" msgid "File"
msgstr "Archivo" msgstr "Archivo"
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Tamaño máximo del archivos a subir: %s"
#: web/templates/admin/media/upload.gohtml:27 #: web/templates/admin/media/upload.gohtml:27
msgctxt "action" msgctxt "action"
msgid "Upload" msgid "Upload"
msgstr "Subir" msgstr "Subir"
#: pkg/carousel/admin.go:233 pkg/campsite/types/admin.go:236
msgctxt "input"
msgid "Cover image"
msgstr "Imagen de portada"
#: pkg/carousel/admin.go:234
msgctxt "action"
msgid "Set cover image"
msgstr "Establecer la imagen de portada"
#: pkg/carousel/admin.go:286
msgid "Slide image can not be empty."
msgstr "No podéis dejar la imagen de la diapositiva en blanco."
#: pkg/carousel/admin.go:287 pkg/campsite/types/admin.go:276
msgid "Cover image must be a image media."
msgstr "La imagen de portada tiene que ser un medio de imagen."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." msgstr "No podéis dejar el correo-e en blanco."
@ -662,7 +688,7 @@ msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82
#: pkg/campsite/types/admin.go:294 pkg/season/admin.go:203 #: pkg/campsite/types/admin.go:274 pkg/season/admin.go:203
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
@ -674,16 +700,20 @@ msgstr "La confirmación no se corresponde con la contraseña."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido." msgstr "El idioma escogido no es válido."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:296 #: pkg/app/user.go:253
#: pkg/services/carousel.go:287 pkg/home/carousel.go:287
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/app/admin.go:50 #: pkg/app/admin.go:53
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Acceso prohibido" msgstr "Acceso prohibido"
#: pkg/campsite/types/admin.go:298 #: pkg/campsite/types/admin.go:237
msgctxt "action"
msgid "Set campsite type cover"
msgstr "Establecer la portada del tipo de alojamiento"
#: pkg/campsite/types/admin.go:275
msgid "Cover image can not be empty." msgid "Cover image can not be empty."
msgstr "No podéis dejar la imagen de portada en blanco." msgstr "No podéis dejar la imagen de portada en blanco."
@ -703,10 +733,6 @@ msgstr "No podéis dejar el color en blanco."
msgid "This color is not valid. It must be like #123abc." msgid "This color is not valid. It must be like #123abc."
msgstr "Este color no es válido. Tiene que ser parecido a #123abc." msgstr "Este color no es válido. Tiene que ser parecido a #123abc."
#: pkg/services/carousel.go:289 pkg/home/carousel.go:289
msgid "Slide image can not be empty."
msgstr "No podéis dejar la imagen de la diapositiva en blanco."
#: pkg/company/admin.go:186 #: pkg/company/admin.go:186
msgid "Selected country is not valid." msgid "Selected country is not valid."
msgstr "El país escogido no es válido." msgstr "El país escogido no es válido."
@ -771,10 +797,14 @@ msgstr "No podéis dejar el formato de número de factura en blanco."
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados." msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
#: pkg/media/admin.go:164 #: pkg/media/admin.go:255
msgid "Uploaded file can not be empty." msgid "Uploaded file can not be empty."
msgstr "No podéis dejar el archivo del medio en blanco." msgstr "No podéis dejar el archivo del medio en blanco."
#: pkg/media/admin.go:314
msgid "Filename can not be empty."
msgstr "No podéis dejar el nombre del archivo en blanco."
#~ msgid "Surroundings" #~ msgid "Surroundings"
#~ msgstr "Entorno" #~ msgstr "Entorno"

7
revert/edit_media.sql Normal file
View File

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

7
revert/media_content.sql Normal file
View File

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

View File

@ -40,8 +40,10 @@ user_profile [roles schema_camper user company_user current_user_email current_u
policies_company [company user_profile] 2023-08-07T20:04:26Z jordi fita mas <jordi@tandem.blog> # Add row-level security profiles to company 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 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
media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blog> # Add domain for media 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 media_content [roles schema_camper media_type] 2023-09-19T23:21:22Z jordi fita mas <jordi@tandem.blog> # Add relation for media content bytes
add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media media [roles schema_camper company media_content user_profile] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media
add_media [roles schema_camper media media_content media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media
edit_media [roles schema_camper media_content media media_type] 2023-09-20T15:46:53Z jordi fita mas <jordi@tandem.blog> # Add function to edit a media
media_path [roles schema_camper media] 2023-09-13T22:50:14Z jordi fita mas <jordi@tandem.blog> # Add function to get the URL path of a media media_path [roles schema_camper media] 2023-09-13T22:50:14Z jordi fita mas <jordi@tandem.blog> # Add function to get the URL path of a 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 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
campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas <jordi@tandem.blog> # Add relation for campsite_type translations campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas <jordi@tandem.blog> # Add relation for campsite_type translations

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate campsite cascade; truncate campsite cascade;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -33,9 +34,13 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca') , (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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 media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (4, 2, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into campsite_type (campsite_type_id, company_id, media_id, name) insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate campsite_type_i18n cascade; truncate campsite_type_i18n cascade;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -33,9 +34,13 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca') , (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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 media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (4, 2, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
select lives_ok( select lives_ok(

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade; truncate home_carousel_i18n cascade;
truncate home_carousel cascade; truncate home_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -30,10 +31,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into home_carousel (media_id, caption) insert into home_carousel (media_id, caption)

View File

@ -10,7 +10,7 @@ set search_path to camper, public;
select plan(13); select plan(13);
select has_function('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']); select has_function('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']);
select function_lang_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'sql'); select function_lang_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'plpgsql');
select function_returns('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'integer'); select function_returns('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'integer');
select isnt_definer('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']); select isnt_definer('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']);
select volatility_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'volatile'); select volatility_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'volatile');
@ -22,6 +22,7 @@ select function_privs_are('camper', 'add_media', array ['integer', 'text', 'medi
set client_min_messages to warning; set client_min_messages to warning;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -46,7 +47,7 @@ select lives_ok(
); );
select bag_eq( select bag_eq(
$$ select company_id, hash, original_filename, media_type, convert_from(content, 'utf-8') from media $$, $$ select company_id, content_hash, original_filename, media_type, convert_from(bytes, 'utf-8') from media join media_content using (content_hash) $$,
$$ values (1, sha256('hello, world!'), 'text.txt', 'text/plain', 'hello, world!') $$ values (1, sha256('hello, world!'), 'text.txt', 'text/plain', 'hello, world!')
, (2, sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'), 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , (2, sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'), 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (2, sha256('hello, world!'), 'world.txt', 'text/plain', 'hello, world!') , (2, sha256('hello, world!'), 'world.txt', 'text/plain', 'hello, world!')

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade; truncate services_carousel_i18n cascade;
truncate services_carousel cascade; truncate services_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -31,10 +32,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into services_carousel (media_id, caption) insert into services_carousel (media_id, caption)

View File

@ -66,6 +66,7 @@ set client_min_messages to warning;
truncate campsite cascade; truncate campsite cascade;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company_host cascade; truncate company_host cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
@ -93,9 +94,13 @@ values (2, 'co2')
, (4, 'co4') , (4, 'co4')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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 media (media_id, company_id, original_filename, content_hash)
values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (8, 4, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into campsite_type (campsite_type_id, company_id, media_id, name) insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -71,6 +71,7 @@ select col_default_is('campsite_type', 'active', 'true');
set client_min_messages to warning; set client_min_messages to warning;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company_host cascade; truncate company_host cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
@ -97,9 +98,13 @@ values (2, 'co2')
, (4, 'co4') , (4, 'co4')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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 media (media_id, company_id, original_filename, content_hash)
values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (8, 4, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into campsite_type (company_id, name, media_id) insert into campsite_type (company_id, name, media_id)

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate campsite cascade; truncate campsite cascade;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -31,8 +32,12 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into campsite_type (campsite_type_id, company_id, media_id, name) insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -22,6 +22,7 @@ select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'intege
set client_min_messages to warning; set client_min_messages to warning;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -30,10 +31,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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"};') , ('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"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffff00","a"};'))
; ;
insert into campsite_type (company_id, slug, media_id, name, description, active) insert into campsite_type (company_id, slug, media_id, name, description, active)

71
test/edit_media.sql Normal file
View File

@ -0,0 +1,71 @@
-- Test edit_media
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(13);
set search_path to camper, public;
select has_function('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea']);
select function_lang_is('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'plpgsql');
select function_returns('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'integer');
select isnt_definer('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea']);
select volatility_is('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'volatile');
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'employee', array[]::text[]);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate media cascade;
truncate media_content 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 media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
;
select lives_ok(
$$ select edit_media(2, 'cover_2.xpm') $$,
'Should be able to change the filename of the first media'
);
select lives_ok(
$$ select edit_media(3, 'cover_3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') $$,
'Should be able to change the filename and hash of the second media to point to a different content'
);
select lives_ok(
$$ select edit_media(4, 'cover4.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') $$,
'Should be able to change the filename and hash of the thirs media to point to a new content'
);
select bag_eq(
$$ select media_id, content_hash, original_filename, media_type, convert_from(bytes, 'utf-8') from media join media_content using (content_hash) $$,
$$ values (2, sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'), 'cover_2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (3, sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'), 'cover_3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'), 'cover4.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
$$,
'Should have added all three media'
);
select *
from finish();
rollback;

View File

@ -32,6 +32,7 @@ select col_hasnt_default('home_carousel', 'caption');
set client_min_messages to warning; set client_min_messages to warning;
truncate home_carousel cascade; truncate home_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company_host cascade; truncate company_host cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
@ -59,11 +60,18 @@ values (2, 'co2')
, (4, 'co4') , (4, 'co4')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values ( 7, 2, 'text2.txt', 'text/plain', 'content2') values ('text/plain', 'content2')
, ( 8, 2, 'text3.txt', 'text/plain', 'content3') , ('text/plain', 'content3')
, ( 9, 4, 'text4.txt', 'text/plain', 'content4') , ('text/plain', 'content4')
, (10, 4, 'text5.txt', 'text/plain', 'content5') , ('text/plain', 'content5')
;
insert into media (media_id, company_id, original_filename, content_hash)
values ( 7, 2, 'text2.txt', sha256('content2'))
, ( 8, 2, 'text3.txt', sha256('content3'))
, ( 9, 4, 'text4.txt', sha256('content4'))
, (10, 4, 'text5.txt', sha256('content5'))
; ;
insert into home_carousel (media_id, caption) insert into home_carousel (media_id, caption)

View File

@ -5,13 +5,12 @@ reset client_min_messages;
begin; begin;
select plan(55); select plan(47);
set search_path to camper, public; set search_path to camper, public;
select has_table('media'); select has_table('media');
select has_pk('media'); select has_pk('media');
select col_is_unique('media', array['company_id', 'hash']);
select table_privs_are('media', 'guest', array['SELECT']); select table_privs_are('media', 'guest', array['SELECT']);
select table_privs_are('media', 'employee', array['SELECT']); select table_privs_are('media', 'employee', array['SELECT']);
select table_privs_are('media', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); select table_privs_are('media', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
@ -37,30 +36,22 @@ select col_type_is('media', 'company_id', 'integer');
select col_not_null('media', 'company_id'); select col_not_null('media', 'company_id');
select col_hasnt_default('media', 'company_id'); select col_hasnt_default('media', 'company_id');
select has_column('media', 'hash'); select has_column('media', 'content_hash');
select col_type_is('media', 'hash', 'bytea'); select col_is_fk('media', 'content_hash');
select col_not_null('media', 'hash'); select fk_ok('media', 'content_hash', 'media_content', 'content_hash');
select col_has_default('media', 'hash'); select col_type_is('media', 'content_hash', 'bytea');
select col_default_is('media', 'hash', 'sha256(content)'); select col_not_null('media', 'content_hash');
select col_hasnt_default('media', 'content_hash');
select has_column('media', 'original_filename'); select has_column('media', 'original_filename');
select col_type_is('media', 'original_filename', 'text'); select col_type_is('media', 'original_filename', 'text');
select col_not_null('media', 'original_filename'); select col_not_null('media', 'original_filename');
select col_hasnt_default('media', 'original_filename'); select col_hasnt_default('media', 'original_filename');
select has_column('media', 'media_type');
select col_type_is('media', 'media_type', 'media_type');
select col_not_null('media', 'media_type');
select col_hasnt_default('media', 'media_type');
select has_column('media', 'content');
select col_type_is('media', 'content', 'bytea');
select col_not_null('media', 'content');
select col_hasnt_default('media', 'content');
set client_min_messages to warning; set client_min_messages to warning;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company_host cascade; truncate company_host cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
@ -87,9 +78,15 @@ values (2, 'co2')
, (4, 'co4') , (4, 'co4')
; ;
insert into media (company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (2, 'text2.txt', 'text/plain', 'content2') values ('text/plain', 'content2')
, (4, 'text4.txt', 'text/plain', 'content4') , ('text/plain', 'content4')
, ('text/plain', 'content6')
;
insert into media (company_id, original_filename, content_hash)
values (2, 'text2.txt', sha256('content2'))
, (4, 'text4.txt', sha256('content4'))
; ;
prepare media_data as prepare media_data as
@ -110,36 +107,36 @@ reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2');
select lives_ok( select lives_ok(
$$ insert into media(company_id, original_filename, media_type, content) $$ insert into media(company_id, original_filename, content_hash)
values (2, 'text2-2.txt', 'text/plain', sha256('Another media')) $$, values (2, 'text2-6.txt', sha256('content6')) $$,
'Admin from company 2 should be able to insert a new media to that company.' 'Admin from company 2 should be able to insert a new media to that company.'
); );
select bag_eq( select bag_eq(
'media_data', 'media_data',
$$ values (2, 'text2.txt') $$ values (2, 'text2.txt')
, (2, 'text2-2.txt') , (2, 'text2-6.txt')
, (4, 'text4.txt') , (4, 'text4.txt')
$$, $$,
'The new row should have been added' 'The new row should have been added'
); );
select lives_ok( select lives_ok(
$$ update media set original_filename = 'text2_2.txt' where company_id = 2 and original_filename = 'text2-2.txt' $$, $$ update media set original_filename = 'text2_6.txt' where company_id = 2 and original_filename = 'text2-6.txt' $$,
'Admin from company 2 should be able to update media of that company.' 'Admin from company 2 should be able to update media of that company.'
); );
select bag_eq( select bag_eq(
'media_data', 'media_data',
$$ values (2, 'text2.txt') $$ values (2, 'text2.txt')
, (2, 'text2_2.txt') , (2, 'text2_6.txt')
, (4, 'text4.txt') , (4, 'text4.txt')
$$, $$,
'The row should have been updated.' 'The row should have been updated.'
); );
select lives_ok( select lives_ok(
$$ delete from media where company_id = 2 and original_filename = 'text2_2.txt' $$, $$ delete from media where company_id = 2 and original_filename = 'text2_6.txt' $$,
'Admin from company 2 should be able to delete media from that company.' 'Admin from company 2 should be able to delete media from that company.'
); );
@ -152,8 +149,8 @@ select bag_eq(
); );
select throws_ok( select throws_ok(
$$ insert into media (company_id, original_filename, media_type, content) $$ insert into media (company_id, original_filename, content_hash)
values (4, 'text4-2.txt', 'text/plain', 'Another media') $$, values (4, 'text4-2.txt', sha256('content6')) $$,
'42501', 'new row violates row-level security policy for table "media"', '42501', 'new row violates row-level security policy for table "media"',
'Admin from company 2 should NOT be able to insert new media to company 4.' 'Admin from company 2 should NOT be able to insert new media to company 4.'
); );
@ -191,7 +188,7 @@ select bag_eq(
); );
select throws_ok( select throws_ok(
$$ insert into media (company_id, original_filename, media_type, content) values (2, ' ', 'text/plain', 'content') $$, $$ insert into media (company_id, original_filename, content_hash) values (2, ' ', sha256('content6')) $$,
'23514', 'new row for relation "media" violates check constraint "original_filename_not_empty"', '23514', 'new row for relation "media" violates check constraint "original_filename_not_empty"',
'Should not be able to insert media with a blank filename.' 'Should not be able to insert media with a blank filename.'
); );

59
test/media_content.sql Normal file
View File

@ -0,0 +1,59 @@
-- Test media_content
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(21);
set search_path to camper, public;
select has_table('media_content');
select has_pk('media_content');
select table_privs_are('media_content', 'guest', array['SELECT']);
select table_privs_are('media_content', 'employee', array['SELECT']);
select table_privs_are('media_content', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('media_content', 'authenticator', array[]::text[]);
select has_column('media_content', 'content_hash');
select col_is_pk('media_content', 'content_hash');
select col_type_is('media_content', 'content_hash', 'bytea');
select col_not_null('media_content', 'content_hash');
select col_has_default('media_content', 'content_hash');
select col_default_is('media_content', 'content_hash', 'sha256(bytes)');
select has_column('media_content', 'media_type');
select col_type_is('media_content', 'media_type', 'media_type');
select col_not_null('media_content', 'media_type');
select col_hasnt_default('media_content', 'media_type');
select has_column('media_content', 'bytes');
select col_type_is('media_content', 'bytes', 'bytea');
select col_not_null('media_content', 'bytes');
select col_hasnt_default('media_content', 'bytes');
set client_min_messages to warning;
truncate media_content cascade;
reset client_min_messages;
insert into media_content (media_type, bytes)
values ('text/plain', 'content2')
, ('text/plain', 'content4')
;
select bag_eq(
$$ select encode(content_hash, 'hex'), convert_from(bytes, 'utf-8') from media_content $$,
$$ values ('dab741b6289e7dccc1ed42330cae1accc2b755ce8079c2cd5d4b5366c9f769a6', 'content2')
, ('b04813d4f04a27cbd8a5d7828344a0c7d206a486343f503cb7e3d53e1d8e95a0', 'content4')
$$,
'Should automatically compute the content_hash'
);
select *
from finish();
rollback;

View File

@ -21,6 +21,7 @@ select function_privs_are('camper', 'path', array['media'], 'authenticator', arr
set client_min_messages to warning; set client_min_messages to warning;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -28,12 +29,20 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (3, 1, 'cover1.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};') values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};')
, (4, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};')
, (5, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'text.txt', 'text/plain', 'hello, world!') , ('text/plain', 'hello, world!')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover1.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff0000","a"};'))
, (4, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","b c #00ff00","b"};'))
, (5, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","c c #0000ff","c"};'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'text.txt', sha256('hello, world!'))
; ;
select bag_eq( select bag_eq(

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade; truncate home_carousel_i18n cascade;
truncate home_carousel cascade; truncate home_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -30,10 +31,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into home_carousel (media_id, caption) insert into home_carousel (media_id, caption)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade; truncate services_carousel_i18n cascade;
truncate services_carousel cascade; truncate services_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -31,10 +32,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into services_carousel (media_id, caption) insert into services_carousel (media_id, caption)

View File

@ -32,6 +32,7 @@ select col_hasnt_default('services_carousel', 'caption');
set client_min_messages to warning; set client_min_messages to warning;
truncate services_carousel cascade; truncate services_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company_host cascade; truncate company_host cascade;
truncate company_user cascade; truncate company_user cascade;
truncate company cascade; truncate company cascade;
@ -58,11 +59,18 @@ values (2, 'co2')
, (4, 'co4') , (4, 'co4')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values ( 7, 2, 'text2.txt', 'text/plain', 'content2') values ('text/plain', 'content2')
, ( 8, 2, 'text3.txt', 'text/plain', 'content3') , ('text/plain', 'content3')
, ( 9, 4, 'text4.txt', 'text/plain', 'content4') , ('text/plain', 'content4')
, (10, 4, 'text5.txt', 'text/plain', 'content5') , ('text/plain', 'content5')
;
insert into media (media_id, company_id, original_filename, content_hash)
values ( 7, 2, 'text2.txt', sha256('content2'))
, ( 8, 2, 'text3.txt', sha256('content3'))
, ( 9, 4, 'text4.txt', sha256('content4'))
, (10, 4, 'text5.txt', sha256('content5'))
; ;
insert into services_carousel (media_id, caption) insert into services_carousel (media_id, caption)

View File

@ -23,6 +23,7 @@ select function_privs_are('camper', 'translate_campsite_type', array['uuid', 'te
set client_min_messages to warning; set client_min_messages to warning;
truncate campsite_type cascade; truncate campsite_type cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -30,10 +31,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') values ('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"};') , ('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"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffff00","a"};'))
; ;
insert into campsite_type (company_id, slug, media_id, name, description, active) insert into campsite_type (company_id, slug, media_id, name, description, active)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade; truncate home_carousel_i18n cascade;
truncate home_carousel cascade; truncate home_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -31,10 +32,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into home_carousel (media_id, caption) insert into home_carousel (media_id, caption)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade; truncate services_carousel_i18n cascade;
truncate services_carousel cascade; truncate services_carousel cascade;
truncate media cascade; truncate media cascade;
truncate media_content cascade;
truncate company cascade; truncate company cascade;
reset client_min_messages; reset client_min_messages;
@ -31,10 +32,16 @@ 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') values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
; ;
insert into media (media_id, company_id, original_filename, media_type, content) insert into media_content (media_type, bytes)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!') values ('text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') , ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
; ;
insert into services_carousel (media_id, caption) insert into services_carousel (media_id, caption)

7
verify/edit_media.sql Normal file
View File

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

View File

@ -4,10 +4,8 @@ begin;
select media_id select media_id
, company_id , company_id
, hash , content_hash
, original_filename , original_filename
, media_type
, content
from camper.media from camper.media
where false; where false;

11
verify/media_content.sql Normal file
View File

@ -0,0 +1,11 @@
-- Verify camper:media_content on pg
begin;
select content_hash
, media_type
, bytes
from camper.media_content
where false;
rollback;

View File

@ -48,3 +48,22 @@ p, h1, h2, h3, h4, h5, h6 {
a.missing-translation { a.missing-translation {
color: #ff0000; color: #ff0000;
} }
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr));
grid-auto-rows: 1fr;
list-style: none;
gap: 1rem;
padding: 0;
}
.media-grid img, .media-grid button {
width: 100%;
height: 100%;
max-height: 26rem;
}
.media-grid img {
object-fit: cover;
}

View File

@ -15,7 +15,6 @@
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }} {{ template "settings-tabs" "campsiteTypes" }}
<form <form
enctype="multipart/form-data"
{{ if .Slug }} {{ if .Slug }}
data-hx-put="/admin/campsites/types/{{ .Slug }}" data-hx-put="/admin/campsites/types/{{ .Slug }}"
{{ else }} {{ else }}
@ -52,16 +51,7 @@
{{ template "error-message" . }} {{ template "error-message" . }}
{{- end }} {{- end }}
{{ with .Media -}} {{ with .Media -}}
{{ if .Val -}} {{ template "media-picker" . }}
<img src="{{ .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 }} {{- end }}
{{ with .Description -}} {{ with .Description -}}
<label> <label>

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-only SPDX-License-Identifier: AGPL-3.0-only
--> -->
{{ define "title" -}} {{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/carousel.slideForm*/ -}}
{{ if .ID}} {{ if .ID}}
{{( pgettext "Edit Carousel Slide" "title" )}} {{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }} {{ else }}
@ -12,14 +12,13 @@
{{- end }} {{- end }}
{{ define "content" -}} {{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/carousel.slideForm*/ -}}
{{ template "settings-tabs" "services" }} {{ template "settings-tabs" .CarouselName }}
<form <form
enctype="multipart/form-data"
{{ if .ID }} {{ if .ID }}
data-hx-put="/admin/services/slides/{{ .ID }}" data-hx-put="/admin/{{ .CarouselName }}/slides/{{ .ID }}"
{{ else }} {{ else }}
action="/admin/services/slides" method="post" action="/admin/{{ .CarouselName }}/slides" method="post"
{{ end }} {{ end }}
> >
<h2> <h2>
@ -32,16 +31,7 @@
{{ CSRFInput }} {{ CSRFInput }}
<fieldset> <fieldset>
{{ with .Media -}} {{ with .Media -}}
{{ if .Val -}} {{ template "media-picker" . }}
<img src="{{ .Val }}" alt="">
{{- end }}
<label>
{{( pgettext "Cover image" "input" )}}
<input type="file" name="{{ .Name }}"
{{ if not $.ID }}required{{ end }}
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }} {{- end }}
{{ with .Caption -}} {{ with .Caption -}}
<label> <label>

View File

@ -15,3 +15,19 @@
<option value="{{ .Value }}" {{ if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option> <option value="{{ .Value }}" {{ if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }} {{- end }}
{{- end }} {{- end }}
{{ define "media-picker" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/form.Media*/ -}}
<fieldset data-hx-target="this" data-hx-swap="outerHTML" data-hx-vals='{"name": "{{ .Name }}", "value": "{{ .Val }}", "label": "{{ .Label }}", "prompt": "{{ .Prompt }}"}'>
<legend>{{( pgettext .Label "input" )}}</legend>
<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">
<button class="media-picker" type="button" data-hx-get="/admin/media/picker">
{{ if .Val -}}
<img src="/admin/media/{{ .Val }}/content" alt="">
{{ else -}}
{{( pgettext .Prompt "action" )}}
{{- end }}
</button>
{{ template "error-message" . }}
</fieldset>
{{- end }}

View File

@ -1,65 +0,0 @@
<!--
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/home.slideForm*/ -}}
{{ if .ID}}
{{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }}
{{( pgettext "New Carousel Slide" "title" )}}
{{ end }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.slideForm*/ -}}
{{ template "settings-tabs" "home" }}
<form
enctype="multipart/form-data"
{{ if .ID }}
data-hx-put="/admin/home/slides/{{ .ID }}"
{{ else }}
action="/admin/home/slides" method="post"
{{ end }}
>
<h2>
{{ if .ID }}
{{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }}
{{( pgettext "New Carousel Slide" "title" )}}
{{ end }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Media -}}
{{ if .Val -}}
<img src="{{ .Val }}" alt="">
{{- end }}
<label>
{{( pgettext "Cover image" "input" )}}
<input type="file" name="{{ .Name }}"
{{ if not $.ID }}required{{ end }}
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Caption -}}
<label>
{{( pgettext "Caption" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">
{{ if .ID }}
{{( pgettext "Update" "action" )}}
{{ else }}
{{( pgettext "Add" "action" )}}
{{ end }}
</button>
</footer>
</form>
{{- end }}

View File

@ -78,6 +78,9 @@
<li> <li>
<a {{ if ne . "services"}}href="/admin/services"{{ end }}>{{( pgettext "Services Page" "title" )}}</a> <a {{ if ne . "services"}}href="/admin/services"{{ end }}>{{( pgettext "Services Page" "title" )}}</a>
</li> </li>
<li>
<a {{ if ne . "media"}}href="/admin/media"{{ end }}>{{( pgettext "Media" "title" )}}</a>
</li>
</ul> </ul>
</nav> </nav>
{{- end }} {{- end }}

View File

@ -0,0 +1,5 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ template "media-picker" . }}

View File

@ -0,0 +1,60 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Edit Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaForm*/ -}}
{{ template "settings-tabs" "media" }}
<form id="upload" enctype="multipart/form-data" data-hx-put="/admin/media/{{ .ID }}">
<h2>{{( pgettext "Edit Media" "title" )}}</h2>
<img src="{{ .Path }}" alt="">
{{ CSRFInput }}
<fieldset>
{{ with .File -}}
<label>
{{( pgettext "Updated file" "input" )}}
<input type="file" name="{{ .Name }}"
{{ template "error-attrs" . }}><br>
</label>
<p>{{ printf (gettext "Maximum upload file size: %s") (.MaxSize | humanizeBytes) }}</p>
{{ template "error-message" . }}
{{- end }}
{{ with .OriginalFilename -}}
<label>
{{( pgettext "Filename" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Update" "action" )}}</button>
<progress value="0" max="100"></progress>
</footer>
</form>
<script>
htmx.on('#upload', 'drop', function (evt) {
evt.preventDefault();
[...evt.dataTransfer.items].forEach(function (i) {
console.log(i);
i.getAsString(console.log)
});
});
htmx.on('#upload', 'dragover', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'dragleave', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'htmx:xhr:progress', function (evt) {
if (evt.detail.lengthComputable) {
htmx.find('progress').setAttribute('value', evt.detail.loaded / evt.detail.total * 100);
}
});
</script>
{{- end }}

View File

@ -0,0 +1,23 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaIndex*/ -}}
{{ template "settings-tabs" "media" }}
<h2>{{( pgettext "Media" "title" )}}</h2>
<a href="/admin/media/upload">{{( pgettext "Upload media" "action" )}}</a>
{{ if .Media -}}
<ul class="media-grid">
{{ range .Media -}}
<li><a href="/admin/media/{{ .ID }}"><img src="{{ .Path }}" alt=""></a></li>
{{- end }}
</ul>
{{ else -}}
<p>{{( gettext "No media uploaded yet." )}}</p>
{{- end }}
{{- end }}

View File

@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<div data-hx-target="this" data-hx-swap="outerHTML">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaPicker*/ -}}
<h2>{{( pgettext "Media Picker" "title" )}}</h2>
<form action="/admin/media/picker" method="post" data-hx-boost="true">
{{ with .Field -}}
<input type="hidden" name="name" value="{{ .Name }}"/>
<input type="hidden" name="label" value="{{ .Label }}"/>
<input type="hidden" name="prompt" value="{{ .Prompt }}"/>
{{- end }}
{{ if .Media -}}
<fieldset>
<ul class="media-grid">
{{ range .Media -}}
<li>
<button name="value" value="{{ .ID }}" type="submit"><img src="{{ .Path }}" alt="">
</button>
</li>
{{- end }}
</ul>
</fieldset>
{{ else -}}
<p>{{( gettext "No media uploaded yet." )}}</p>
{{- end }}
<footer>
<button name="value" value="{{ .Field.Val }}" type="submit">{{( pgettext "Cancel" "action" )}}</button>
</footer>
</form>
</div>

View File

@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Upload Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.uploadForm*/ -}}
{{ template "settings-tabs" "media" }}
<form id="upload" enctype="multipart/form-data" action="/admin/media" method="post" data-hx-boost="true">
<h2>{{( pgettext "Upload Media" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .File -}}
<label>
{{( pgettext "File" "input" )}}
<input type="file" name="{{ .Name }}"
required {{ template "error-attrs" . }}><br>
</label>
<p>{{ printf (gettext "Maximum upload file size: %s") (.MaxSize | humanizeBytes) }}</p>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Upload" "action" )}}</button>
<progress value="0" max="100"></progress>
</footer>
</form>
<script>
htmx.on('#upload', 'drop', function (evt) {
evt.preventDefault();
[...evt.dataTransfer.items].forEach(function (i) {
console.log(i);
i.getAsString(console.log)
});
});
htmx.on('#upload', 'dragover', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'dragleave', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'htmx:xhr:progress', function (evt) {
if (evt.detail.lengthComputable) {
htmx.find('progress').setAttribute('value', evt.detail.loaded / evt.detail.total * 100);
}
});
</script>
{{- end }}

View File

@ -1,36 +0,0 @@
<!--
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/services.slideL10nForm*/ -}}
{{printf (pgettext "Translate Carousel Slide to %s" "title") .Locale.Endonym }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideL10nForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form data-hx-put="/admin/services/slides/{{ .ID }}/{{ .Locale.Language }}">
<h2>
{{printf (pgettext "Translate Carousel Slide to %s" "title") .Locale.Endonym }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Caption -}}
<fieldset>
<legend>{{( pgettext "Caption" "input")}}</legend>
{{( gettext "Source:" )}} {{ .Source }}<br>
<label>
{{( pgettext "Translation:" "input" )}}
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
</fieldset>
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Translate" "action" )}}</button>
</footer>
</form>
{{- end }}