From 97cf117da39ba15244c497ec4d789831b86efacd Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Thu, 21 Sep 2023 01:56:44 +0200 Subject: [PATCH] Manage all media uploads in a single place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Makefile | 2 +- cmd/camper/main.go | 3 +- demo/demo.sql | 35 +- deploy/add_media.sql | 26 +- deploy/edit_media.sql | 41 +++ deploy/media.sql | 9 +- deploy/media_content.sql | 20 + deploy/media_path.sql | 2 +- pkg/app/admin.go | 7 +- pkg/app/app.go | 9 +- pkg/campsite/types/admin.go | 87 ++--- pkg/{home/carousel.go => carousel/admin.go} | 165 ++++----- pkg/{services => carousel}/l10n.go | 33 +- pkg/form/media.go | 12 + pkg/form/validator.go | 10 + pkg/home/admin.go | 17 +- pkg/home/l10n.go | 79 ---- pkg/home/public.go | 5 +- pkg/locale/locale.go | 4 + pkg/media/admin.go | 341 ++++++++++++++++++ pkg/{app/media.go => media/public.go} | 26 +- .../media_test.go => media/public_test.go} | 4 +- pkg/services/admin.go | 17 +- pkg/services/carousel.go | 311 ---------------- pkg/services/public.go | 5 +- pkg/template/humanize.go | 30 ++ pkg/template/humanize_test.go | 59 +++ pkg/template/render.go | 25 +- po/ca.po | 259 +++++++------ po/es.po | 260 +++++++------ revert/edit_media.sql | 7 + revert/media_content.sql | 7 + sqitch.plan | 6 +- test/add_campsite.sql | 11 +- test/add_campsite_type.sql | 11 +- test/add_home_carousel_slide.sql | 15 +- test/add_media.sql | 5 +- test/add_services_carousel_slide.sql | 15 +- test/campsite.sql | 11 +- test/campsite_type.sql | 11 +- test/edit_campsite.sql | 9 +- test/edit_campsite_type.sql | 15 +- test/edit_media.sql | 71 ++++ test/home_carousel.sql | 18 +- test/media.sql | 55 ++- test/media_content.sql | 59 +++ test/media_path.sql | 21 +- test/remove_home_carousel_slide.sql | 15 +- test/remove_services_carousel_slide.sql | 15 +- test/services_carousel.sql | 18 +- test/translate_campsite_type.sql | 15 +- test/translate_home_carousel_slide.sql | 15 +- test/translate_services_carousel_slide.sql | 15 +- verify/edit_media.sql | 7 + verify/media.sql | 4 +- verify/media_content.sql | 11 + web/static/camper.css | 19 + web/templates/admin/campsite/type/form.gohtml | 12 +- .../admin/{services => }/carousel/form.gohtml | 24 +- .../admin/{home => }/carousel/l10n.gohtml | 0 web/templates/admin/form.gohtml | 16 + web/templates/admin/home/carousel/form.gohtml | 65 ---- web/templates/admin/layout.gohtml | 3 + web/templates/admin/media/field.gohtml | 5 + web/templates/admin/media/form.gohtml | 60 +++ web/templates/admin/media/index.gohtml | 23 ++ web/templates/admin/media/picker.gohtml | 32 ++ web/templates/admin/media/upload.gohtml | 51 +++ .../admin/services/carousel/l10n.gohtml | 36 -- 69 files changed, 1625 insertions(+), 1086 deletions(-) create mode 100644 deploy/edit_media.sql create mode 100644 deploy/media_content.sql rename pkg/{home/carousel.go => carousel/admin.go} (58%) rename pkg/{services => carousel}/l10n.go (67%) create mode 100644 pkg/form/media.go delete mode 100644 pkg/home/l10n.go create mode 100644 pkg/media/admin.go rename pkg/{app/media.go => media/public.go} (66%) rename pkg/{app/media_test.go => media/public_test.go} (93%) delete mode 100644 pkg/services/carousel.go create mode 100644 pkg/template/humanize.go create mode 100644 pkg/template/humanize_test.go create mode 100644 revert/edit_media.sql create mode 100644 revert/media_content.sql create mode 100644 test/edit_media.sql create mode 100644 test/media_content.sql create mode 100644 verify/edit_media.sql create mode 100644 verify/media_content.sql rename web/templates/admin/{services => }/carousel/form.gohtml (64%) rename web/templates/admin/{home => }/carousel/l10n.gohtml (100%) delete mode 100644 web/templates/admin/home/carousel/form.gohtml create mode 100644 web/templates/admin/media/field.gohtml create mode 100644 web/templates/admin/media/form.gohtml create mode 100644 web/templates/admin/media/index.gohtml create mode 100644 web/templates/admin/media/picker.gohtml create mode 100644 web/templates/admin/media/upload.gohtml delete mode 100644 web/templates/admin/services/carousel/l10n.gohtml diff --git a/Makefile b/Makefile index adf1328..d90681d 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ po/%.po: $(POT_FILE) $(POT_FILE): $(HTML_FILES) $(GO_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: sqitch deploy --db-name $(PGDATABASE) diff --git a/cmd/camper/main.go b/cmd/camper/main.go index 04040bb..49453b1 100644 --- a/cmd/camper/main.go +++ b/cmd/camper/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "errors" "log" "net/http" "os" @@ -42,7 +43,7 @@ func main() { go func() { 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) } }() diff --git a/demo/demo.sql b/demo/demo.sql index 2cf9080..a032502 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -24,24 +24,23 @@ values (52, 42, 'employee') ; alter sequence media_media_id_seq restart with 62; -insert into media (company_id, original_filename, media_type, content) -values (52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plots.avif]])', 'base64')) - , (52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64')) - , (52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64')) - , (52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64')) - , (52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64')) - , (52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64')) - , (52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64')) - , (52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64')) - , (52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64')) - , (52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64')) - , (52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64')) - , (52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64')) - , (52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64')) - , (52, 'services_carousel0.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel0.avif]])', 'base64')) - , (52, 'services_carousel1.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel1.avif]])', 'base64')) - , (52, 'services_carousel2.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel2.avif]])', 'base64')) - , (52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64')) +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(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')); +select add_media(52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64')); ; insert into home_carousel (media_id, caption) diff --git a/deploy/add_media.sql b/deploy/add_media.sql index a8ed451..2bd0cdf 100644 --- a/deploy/add_media.sql +++ b/deploy/add_media.sql @@ -2,6 +2,7 @@ -- requires: roles -- requires: schema_camper -- requires: media +-- requires: media_content -- requires: media_type 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 $$ - insert into media (company_id, original_filename, media_type, content) - values (company, filename, media_type, content) - on conflict (company_id, hash) do update - set original_filename = excluded.original_filename - , media_type = excluded.media_type - returning media_id +declare + hash bytea; + mid integer; +begin + 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 ; + + 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; diff --git a/deploy/edit_media.sql b/deploy/edit_media.sql new file mode 100644 index 0000000..921bc7e --- /dev/null +++ b/deploy/edit_media.sql @@ -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; diff --git a/deploy/media.sql b/deploy/media.sql index 5fb1091..dc34838 100644 --- a/deploy/media.sql +++ b/deploy/media.sql @@ -2,8 +2,8 @@ -- requires: roles -- requires: schema_camper -- requires: company +-- requires: media_content -- requires: user_profile --- requires: media_type begin; @@ -12,11 +12,8 @@ set search_path to camper, public; create table media ( media_id serial not null primary key, company_id integer not null references company, - hash bytea not null generated always as (sha256(content)) stored, - 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) + content_hash bytea not null references media_content, + original_filename text not null constraint original_filename_not_empty check(length(trim(original_filename)) > 0) ); grant select on table media to guest; diff --git a/deploy/media_content.sql b/deploy/media_content.sql new file mode 100644 index 0000000..e42a66d --- /dev/null +++ b/deploy/media_content.sql @@ -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; diff --git a/deploy/media_path.sql b/deploy/media_path.sql index d506d4c..cf411bb 100644 --- a/deploy/media_path.sql +++ b/deploy/media_path.sql @@ -9,7 +9,7 @@ set search_path to camper, public; 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 stable diff --git a/pkg/app/admin.go b/pkg/app/admin.go index e3b8bf2..c1ec7a8 100644 --- a/pkg/app/admin.go +++ b/pkg/app/admin.go @@ -6,6 +6,7 @@ package app import ( + "dev.tandem.ws/tandem/camper/pkg/media" "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" @@ -24,15 +25,17 @@ type adminHandler struct { campsite *campsite.AdminHandler company *company.AdminHandler home *home.AdminHandler + media *media.AdminHandler season *season.AdminHandler services *services.AdminHandler } -func newAdminHandler(locales locale.Locales) *adminHandler { +func newAdminHandler(locales locale.Locales, mediaDir string) *adminHandler { return &adminHandler{ campsite: campsite.NewAdminHandler(locales), company: company.NewAdminHandler(), home: home.NewAdminHandler(locales), + media: media.NewAdminHandler(mediaDir), season: season.NewAdminHandler(), 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) case "home": h.home.Handler(user, company, conn).ServeHTTP(w, r) + case "media": + h.media.Handler(user, company, conn).ServeHTTP(w, r) case "seasons": h.season.Handler(user, company, conn).ServeHTTP(w, r) case "services": diff --git a/pkg/app/app.go b/pkg/app/app.go index 4b00427..b1961c4 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -7,6 +7,7 @@ package app import ( "context" + "dev.tandem.ws/tandem/camper/pkg/media" "net/http" "golang.org/x/text/language" @@ -23,7 +24,7 @@ type App struct { profile *profileHandler admin *adminHandler public *publicHandler - media *mediaHandler + media *media.PublicHandler locales locale.Locales defaultLocale *locale.Locale languageMatcher language.Matcher @@ -39,7 +40,7 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err if err != nil { return nil, err } - media, err := newMediaHandler(mediaDir) + mediaHandler, err := media.NewPublicHandler(mediaDir) if err != nil { return nil, err } @@ -47,9 +48,9 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err db: db, fileHandler: static, profile: profile, - admin: newAdminHandler(locales), + admin: newAdminHandler(locales, mediaDir), public: newPublicHandler(), - media: media, + media: mediaHandler, locales: locales, defaultLocale: locales[language.Catalan], languageMatcher: language.NewMatcher(locales.Tags()), diff --git a/pkg/campsite/types/admin.go b/pkg/campsite/types/admin.go index d2c2eb7..cc293bb 100644 --- a/pkg/campsite/types/admin.go +++ b/pkg/campsite/types/admin.go @@ -7,7 +7,6 @@ package types import ( "context" - "io" "net/http" "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) { f := newTypeForm() - processTypeForm(w, r, user, company, true, f, func(ctx context.Context) { - bytes := f.MustReadAllMedia() - tx := conn.MustBegin(ctx) - defer tx.Rollback(ctx) - mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes) - tx.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, mediaID, f.Name, f.Description) - tx.MustCommit(ctx) + processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) { + conn.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, f.Media, f.Name, f.Description) }) } 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) { - bytes := f.MustReadAllMedia() - if bytes == nil { - conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, nil, f.Name, f.Description, f.Active) - } else { - tx := conn.MustBegin(ctx) - defer tx.Rollback(ctx) - mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes) - tx.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, mediaID, f.Name, f.Description, f.Active) - tx.MustCommit(ctx) - } + processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) { + conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, f.Media, f.Name, f.Description, f.Active) }) } -func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, f *typeForm, act func(ctx context.Context)) { - if err := f.Parse(w, r); err != nil { +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(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 ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil { + panic(err) + } else if !ok { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } @@ -232,7 +218,7 @@ func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, co type typeForm struct { Slug string Active *form.Checkbox - Media *form.File + Media *form.Media Name *form.Input Description *form.Input } @@ -243,9 +229,12 @@ func newTypeForm() *typeForm { Name: "active", Checked: true, }, - Media: &form.File{ - Name: "media", - MaxSize: 1 << 20, + Media: &form.Media{ + Input: &form.Input{ + Name: "media", + }, + Label: locale.PgettextNoop("Cover image", "input"), + Prompt: locale.PgettextNoop("Set campsite type cover", "action"), }, Name: &form.Input{ Name: "name", @@ -261,60 +250,36 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl row := conn.QueryRow(ctx, ` select name , description - , media.path + , media_id::text , active from campsite_type - join media using (media_id) where slug = $1 `, slug) return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.Active.Checked) } -func (f *typeForm) Parse(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 { +func (f *typeForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { return err } f.Active.FillValue(r) f.Name.FillValue(r) f.Description.FillValue(r) - if err := f.Media.FillValue(r); err != nil { - return err - } + f.Media.FillValue(r) return nil } -func (f *typeForm) Close() error { - return f.Media.Close() -} - -func (f *typeForm) Valid(l *locale.Locale, mediaRequired bool) bool { +func (f *typeForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { v := form.NewValidator(l) v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty.")) - if f.HasMediaFile() { - v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image.")) - } else { - v.Check(f.Media, !mediaRequired, l.GettextNoop("Cover image can not be empty.")) + if v.CheckRequired(f.Media.Input, l.GettextNoop("Cover image can not be empty.")) { + if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil { + return false, err + } } - return v.AllOK + return v.AllOK, nil } func (f *typeForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdmin(w, r, user, company, "campsite/type/form.gohtml", f) } - -func (f *typeForm) HasMediaFile() bool { - return f.Media.HasData() -} - -func (f *typeForm) MustReadAllMedia() []byte { - if !f.HasMediaFile() { - return nil - } - bytes, err := io.ReadAll(f.Media) - if err != nil { - panic(err) - } - return bytes -} diff --git a/pkg/home/carousel.go b/pkg/carousel/admin.go similarity index 58% rename from pkg/home/carousel.go rename to pkg/carousel/admin.go index c558a58..90a5d7a 100644 --- a/pkg/home/carousel.go +++ b/pkg/carousel/admin.go @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package home +package carousel import ( "context" - "io" + "fmt" "net/http" "strconv" @@ -21,7 +21,19 @@ import ( "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) { var head string 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 "": switch r.Method { case http.MethodPost: - addSlide(w, r, user, company, conn) + addSlide(w, r, user, company, conn, h.name) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } case "new": switch r.Method { case http.MethodGet: - f := newSlideForm() + f := newSlideForm(h.name) f.MustRender(w, r, user, company) default: httplib.MethodNotAllowed(w, r, http.MethodGet) @@ -47,7 +59,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c if err != nil { http.NotFound(w, r) } - f := newSlideForm() + f := newSlideForm(h.name) if err := f.FillFromDatabase(r.Context(), conn, id); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) @@ -67,7 +79,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c case http.MethodPut: editSlide(w, r, user, company, conn, f) case http.MethodDelete: - deleteSlide(w, r, user, conn, id) + h.deleteSlide(w, r, user, conn, id) default: 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 Caption string } -func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*carouselSlide { - rows, err := conn.Query(ctx, ` +func MustCollectSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale, carouselName string) []*Slide { + rows, err := conn.Query(ctx, fmt.Sprintf(` select coalesce(i18n.caption, slide.caption) as l10_caption , media.path - from home_carousel as slide + from %[1]s_carousel as slide 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 - `, loc.Language, company.ID) + `, carouselName), loc.Language, company.ID) if err != nil { panic(err) } defer rows.Close() - var carousel []*carouselSlide + var carousel []*Slide for rows.Next() { - slide := &carouselSlide{} + slide := &Slide{} err = rows.Scan(&slide.Caption, &slide.Media) if err != nil { panic(err) @@ -129,8 +141,8 @@ func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn return carousel } -type slideEntry struct { - carouselSlide +type SlideEntry struct { + Slide ID int Translations []*translation } @@ -141,13 +153,13 @@ type translation struct { Missing bool } -func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) { - rows, err := conn.Query(ctx, ` +func CollectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn, carouselName string) ([]*SlideEntry, error) { + rows, err := conn.Query(ctx, fmt.Sprintf(` select media_id , media.path , 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) - from home_carousel + , 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 %[1]s_carousel as carousel join media using (media_id) join company using (company_id) , language @@ -158,15 +170,15 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab , media.path , caption order by caption - `, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID) + `, carouselName), pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID) if err != nil { return nil, err } defer rows.Close() - var slides []*slideEntry + var slides []*SlideEntry for rows.Next() { - slide := &slideEntry{} + slide := &SlideEntry{} var translations database.RecordArray if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil { return nil, err @@ -184,46 +196,42 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab return slides, nil } -func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { - f := newSlideForm() +func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, carouselName string) { + f := newSlideForm(carouselName) 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_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) - } + f.process(w, r, user, company, conn, func(ctx context.Context) { + conn.MustExec(ctx, fmt.Sprintf("select add_%s_carousel_slide($1, $2)", f.CarouselName), f.Media, f.Caption) }) } -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 { http.Error(w, err.Error(), http.StatusForbidden) return } - conn.MustExec(r.Context(), "select remove_home_carousel_slide($1)", id) - httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) + conn.MustExec(r.Context(), fmt.Sprintf("select remove_%s_carousel_slide($1)", h.name), id) + httplib.Redirect(w, r, "/admin/"+h.name, http.StatusSeeOther) } type slideForm struct { - ID int - Media *form.File - Caption *form.Input + CarouselName string + ID int + Media *form.Media + Caption *form.Input } -func newSlideForm() *slideForm { +func newSlideForm(carouselName string) *slideForm { return &slideForm{ - Media: &form.File{ - Name: "media", - MaxSize: 1 << 20, + CarouselName: carouselName, + Media: &form.Media{ + Input: &form.Input{ + Name: "media", + }, + Label: locale.PgettextNoop("Cover image", "input"), + Prompt: locale.PgettextNoop("Set cover image", "action"), }, Caption: &form.Input{ Name: "caption", @@ -233,27 +241,27 @@ func newSlideForm() *slideForm { func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error { f.ID = id - row := conn.QueryRow(ctx, ` + row := conn.QueryRow(ctx, fmt.Sprintf(` select caption - , media.path - from home_carousel - join media using (media_id) + , media_id::text + from %s_carousel where media_id = $1 - `, id) + `, f.CarouselName), 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 { +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(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 ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil { + panic(err) + } else if !ok { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } @@ -261,51 +269,28 @@ func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.U return } 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 { - maxSize := f.Media.MaxSize + 1024 - r.Body = http.MaxBytesReader(w, r.Body, maxSize) - if err := r.ParseMultipartForm(maxSize); err != nil { +func (f *slideForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { return err } f.Caption.FillValue(r) - if err := f.Media.FillValue(r); err != nil { - return err - } + f.Media.FillValue(r) return nil } -func (f *slideForm) Close() error { - return f.Media.Close() -} - -func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool { +func (f *slideForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { 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.")) + if v.CheckRequired(f.Media.Input, l.GettextNoop("Slide image can not be empty.")) { + if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil { + return false, err + } } - return v.AllOK + return v.AllOK, nil } 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) -} - -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 + template.MustRenderAdmin(w, r, user, company, "carousel/form.gohtml", f) } diff --git a/pkg/services/l10n.go b/pkg/carousel/l10n.go similarity index 67% rename from pkg/services/l10n.go rename to pkg/carousel/l10n.go index 791966a..b793008 100644 --- a/pkg/services/l10n.go +++ b/pkg/carousel/l10n.go @@ -3,10 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package services +package carousel import ( "context" + "fmt" "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" @@ -18,31 +19,33 @@ import ( ) type slideL10nForm struct { - Locale *locale.Locale - ID int - Caption *form.L10nInput + Locale *locale.Locale + CarouselName string + ID int + Caption *form.L10nInput } func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm { return &slideL10nForm{ - Locale: loc, - ID: f.ID, - Caption: f.Caption.L10nInput(), + Locale: loc, + CarouselName: f.CarouselName, + ID: f.ID, + Caption: f.Caption.L10nInput(), } } 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 - from services_carousel - left join services_carousel_i18n as i18n on services_carousel.media_id = i18n.media_id and i18n.lang_tag = $1 - where services_carousel.media_id = $2 - `, l10n.Locale.Language, l10n.ID) + from %[1]s_carousel as carousel + left join %[1]s_carousel_i18n as i18n on carousel.media_id = i18n.media_id and i18n.lang_tag = $1 + where carousel.media_id = $2 + `, l10n.CarouselName), 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, "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) { @@ -61,8 +64,8 @@ func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, comp l10n.MustRender(w, r, user, company) return } - conn.MustExec(r.Context(), "select translate_services_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption) - httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther) + 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/"+l10n.CarouselName, http.StatusSeeOther) } func (l10n *slideL10nForm) Parse(r *http.Request) error { diff --git a/pkg/form/media.go b/pkg/form/media.go new file mode 100644 index 0000000..dc3fb4d --- /dev/null +++ b/pkg/form/media.go @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package form + +type Media struct { + *Input + Label string + Prompt string +} diff --git a/pkg/form/validator.go b/pkg/form/validator.go index abdeb54..0fe1f41 100644 --- a/pkg/form/validator.go +++ b/pkg/form/validator.go @@ -11,6 +11,7 @@ import ( "net/mail" "net/url" "regexp" + "strconv" "dev.tandem.ws/tandem/camper/pkg/database" "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) } +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 { setError(error) } diff --git a/pkg/home/admin.go b/pkg/home/admin.go index 211472a..a65381f 100644 --- a/pkg/home/admin.go +++ b/pkg/home/admin.go @@ -9,18 +9,25 @@ import ( "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/carousel" "dev.tandem.ws/tandem/camper/pkg/database" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/template" ) +const carouselName = "home" + type AdminHandler struct { - locales locale.Locales + locales locale.Locales + carousel *carousel.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 { @@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat httplib.MethodNotAllowed(w, r, http.MethodGet) } case "slides": - h.carouselHandler(user, company, conn).ServeHTTP(w, r) + h.carousel.Handler(user, company, conn).ServeHTTP(w, r) default: 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) { - slides, err := collectSlideEntries(r.Context(), company, conn) + slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName) if err != nil { panic(err) } @@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com } type homeIndex struct { - Slides []*slideEntry + Slides []*carousel.SlideEntry } func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { diff --git a/pkg/home/l10n.go b/pkg/home/l10n.go deleted file mode 100644 index efe1a43..0000000 --- a/pkg/home/l10n.go +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 jordi fita mas - * 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 -} diff --git a/pkg/home/public.go b/pkg/home/public.go index 434ccd9..a14e7bc 100644 --- a/pkg/home/public.go +++ b/pkg/home/public.go @@ -10,6 +10,7 @@ import ( "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/carousel" "dev.tandem.ws/tandem/camper/pkg/database" httplib "dev.tandem.ws/tandem/camper/pkg/http" "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 { *template.PublicPage CampsiteTypes []*campsiteType - Carousel []*carouselSlide + Carousel []*carousel.Slide } 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) { p.Setup(r, user, company, conn) 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) } diff --git a/pkg/locale/locale.go b/pkg/locale/locale.go index cb7d576..60dee3e 100644 --- a/pkg/locale/locale.go +++ b/pkg/locale/locale.go @@ -68,6 +68,10 @@ func (l *Locale) GettextNoop(str string) string { return str } +func PgettextNoop(str string, ctx string) string { + return str +} + func Match(acceptLanguage string, locales Locales, matcher language.Matcher) *Locale { t, _, err := language.ParseAcceptLanguage(acceptLanguage) if err != nil { diff --git a/pkg/media/admin.go b/pkg/media/admin.go new file mode 100644 index 0000000..5736aa3 --- /dev/null +++ b/pkg/media/admin.go @@ -0,0 +1,341 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * 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) +} diff --git a/pkg/app/media.go b/pkg/media/public.go similarity index 66% rename from pkg/app/media.go rename to pkg/media/public.go index a504fd6..f86e468 100644 --- a/pkg/app/media.go +++ b/pkg/media/public.go @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package app +package media import ( "net/http" @@ -18,45 +18,45 @@ import ( httplib "dev.tandem.ws/tandem/camper/pkg/http" ) -type mediaHandler struct { +type PublicHandler struct { mediaDir string fileHandler http.Handler } -func newMediaHandler(mediaDir string) (*mediaHandler, error) { +func NewPublicHandler(mediaDir string) (*PublicHandler, error) { if err := os.MkdirAll(mediaDir, 0755); err != nil { return nil, err } - handler := &mediaHandler{ + handler := &PublicHandler{ mediaDir: mediaDir, fileHandler: http.FileServer(http.Dir(mediaDir)), } return handler, nil } -func (h *mediaHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) - if !mediaHashValid(head) { + if !hashValid(head) { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: - h.serveMedia(w, r, company, conn, strings.ToLower(head)) + h.serveMedia(w, r, conn, strings.ToLower(head)) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } - } + }) } -func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, company *auth.Company, conn *database.Conn, hash string) { +func (h *PublicHandler) serveMedia(w http.ResponseWriter, r *http.Request, conn *database.Conn, hash string) { mediaPath := h.mediaPath(hash) var err error if _, err = os.Stat(mediaPath); err != nil { - bytes, err := conn.GetBytes(r.Context(), "select content from media where company_id = $1 and hash = decode($2, 'hex')", company.ID, hash) + bytes, err := conn.GetBytes(r.Context(), "select bytes from media_content where content_hash = decode($1, 'hex')", hash) if err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) @@ -78,11 +78,11 @@ func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, compan 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:]) } -func mediaHashValid(s string) bool { +func hashValid(s string) bool { if len(s) != 64 { return false } diff --git a/pkg/app/media_test.go b/pkg/media/public_test.go similarity index 93% rename from pkg/app/media_test.go rename to pkg/media/public_test.go index 8b93ff6..3cbc588 100644 --- a/pkg/app/media_test.go +++ b/pkg/media/public_test.go @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package app +package media import ( "strings" @@ -24,7 +24,7 @@ var tests = []test{ } 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) } } diff --git a/pkg/services/admin.go b/pkg/services/admin.go index 9397a43..81bb828 100644 --- a/pkg/services/admin.go +++ b/pkg/services/admin.go @@ -9,18 +9,25 @@ import ( "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/carousel" "dev.tandem.ws/tandem/camper/pkg/database" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/template" ) +const carouselName = "services" + type AdminHandler struct { - locales locale.Locales + locales locale.Locales + carousel *carousel.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 { @@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat httplib.MethodNotAllowed(w, r, http.MethodGet) } case "slides": - h.carouselHandler(user, company, conn).ServeHTTP(w, r) + h.carousel.Handler(user, company, conn).ServeHTTP(w, r) default: 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) { - slides, err := collectSlideEntries(r.Context(), company, conn) + slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName) if err != nil { panic(err) } @@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com } type servicesIndex struct { - Slides []*slideEntry + Slides []*carousel.SlideEntry } func (page *servicesIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { diff --git a/pkg/services/carousel.go b/pkg/services/carousel.go deleted file mode 100644 index 04c4c21..0000000 --- a/pkg/services/carousel.go +++ /dev/null @@ -1,311 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 jordi fita mas - * 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 -} diff --git a/pkg/services/public.go b/pkg/services/public.go index 6f33740..f70f368 100644 --- a/pkg/services/public.go +++ b/pkg/services/public.go @@ -10,6 +10,7 @@ import ( "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/carousel" "dev.tandem.ws/tandem/camper/pkg/database" httplib "dev.tandem.ws/tandem/camper/pkg/http" "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 { *template.PublicPage Services []*service - Carousel []*carouselSlide + Carousel []*carousel.Slide } 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) { p.Setup(r, user, company, conn) 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) } diff --git a/pkg/template/humanize.go b/pkg/template/humanize.go new file mode 100644 index 0000000..85cf515 --- /dev/null +++ b/pkg/template/humanize.go @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2012–2015 Dustin Sallings + * 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) +} diff --git a/pkg/template/humanize_test.go b/pkg/template/humanize_test.go new file mode 100644 index 0000000..c296a9a --- /dev/null +++ b/pkg/template/humanize_test.go @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2012–2015 Dustin Sallings + * 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) + } +} diff --git a/pkg/template/render.go b/pkg/template/render.go index 846459a..830c447 100644 --- a/pkg/template/render.go +++ b/pkg/template/render.go @@ -10,6 +10,7 @@ import ( "html/template" "io" "net/http" + "path" "dev.tandem.ws/tandem/camper/pkg/auth" 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) { 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{}) { 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{}) { - t := template.New(filename) +func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templateFile func(string) string, data interface{}, templates ...string) { + t := template.New(templates[len(templates)-1]) t.Funcs(template.FuncMap{ "gettext": user.Locale.Get, "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 { 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) } if rw, ok := w.(http.ResponseWriter); ok { 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) } } diff --git a/po/ca.po b/po/ca.po index e782ef2..116ec1b 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\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" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -28,7 +28,7 @@ msgstr "Serveis" msgid "The campsite offers many different services." 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" msgid "Home" 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…." 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:59 +#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23 +#: web/templates/public/layout.gohtml:58 msgid "Campsite Montagut" msgstr "Càmping Montagut" @@ -140,10 +140,70 @@ msgstr "Càmping Montagut" msgid "Skip to main content" msgstr "Salta al contingut principal" -#: web/templates/public/layout.gohtml:33 +#: web/templates/public/layout.gohtml:32 msgid "Singular Lodges" 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:26 msgctxt "title" @@ -176,24 +236,6 @@ msgctxt "input" msgid "Label" 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:13 #: web/templates/admin/layout.gohtml:70 @@ -233,24 +275,24 @@ msgid "No campsites added yet." msgstr "No s’ha afegit cap allotjament encara." #: 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" msgid "Edit Campsite Type" msgstr "Edició del tipus d’allotjament" #: 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" msgid "New Campsite Type" msgstr "Nou tipus d’allotjament" -#: web/templates/admin/campsite/type/form.gohtml:39 +#: web/templates/admin/campsite/type/form.gohtml:38 #: web/templates/admin/campsite/type/index.gohtml:20 msgctxt "campsite type" msgid "Active" 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/season/form.gohtml:47 #: web/templates/admin/profile.gohtml:26 @@ -258,14 +300,7 @@ msgctxt "input" msgid "Name" msgstr "Nom" -#: web/templates/admin/campsite/type/form.gohtml:59 -#: 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/form.gohtml:58 #: web/templates/admin/campsite/type/l10n.gohtml:33 msgctxt "input" msgid "Description" @@ -306,28 +341,6 @@ msgctxt "title" msgid "Translate Campsite Type to %s" msgstr "Traducció del tipus d’allotjament 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:26 msgctxt "title" @@ -399,43 +412,11 @@ msgctxt "action" msgid "Login" 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/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 +#: web/templates/admin/layout.gohtml:79 msgctxt "title" -msgid "Home Page" -msgstr "Pàgina d’inici" +msgid "Services Page" +msgstr "Pàgina de serveis" #: web/templates/admin/services/index.gohtml:12 #: web/templates/admin/home/index.gohtml:12 @@ -598,27 +579,59 @@ msgctxt "action" msgid "Logout" msgstr "Surt" -#: web/templates/admin/layout.gohtml:79 -#, fuzzy +#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 msgctxt "title" -msgid "Services Page" -msgstr "Serveis" +msgid "Home Page" +msgstr "Pàgina d’inici" +#: web/templates/admin/layout.gohtml:82 #: web/templates/admin/media/index.gohtml:6 #: web/templates/admin/media/index.gohtml:12 msgctxt "title" msgid "Media" 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 s’ha 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 msgctxt "action" msgid "Upload media" msgstr "Puja mèdia" -#: web/templates/admin/media/index.gohtml:21 -msgid "No media uploaded yet." -msgstr "No s’ha pujat cap mèdia encara." - #: web/templates/admin/media/upload.gohtml:6 #: web/templates/admin/media/upload.gohtml:13 msgctxt "title" @@ -630,15 +643,29 @@ msgctxt "input" msgid "File" 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 msgctxt "action" msgid "Upload" 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 d’imatge." + #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 msgid "Email can not be empty." msgstr "No podeu deixar el correu-e en blanc." @@ -661,7 +688,7 @@ msgid "Automatic" msgstr "Automàtic" #: 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." 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." msgstr "L’idioma escollit no és vàlid." -#: pkg/app/user.go:253 pkg/campsite/types/admin.go:296 -#: pkg/services/carousel.go:287 pkg/home/carousel.go:287 +#: pkg/app/user.go:253 msgid "File must be a valid PNG or JPEG image." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." -#: pkg/app/admin.go:50 +#: pkg/app/admin.go:53 msgid "Access forbidden" 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 d’allotjament" + +#: pkg/campsite/types/admin.go:275 msgid "Cover image can not be empty." 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." 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 msgid "Selected country is not valid." 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." msgstr "S’ha 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." 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" #~ msgstr "Entorn" diff --git a/po/es.po b/po/es.po index 75bf5b8..3321b9b 100644 --- a/po/es.po +++ b/po/es.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\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" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -28,7 +28,7 @@ msgstr "Servicios" msgid "The campsite offers many different services." 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" msgid "Home" msgstr "Inicio" @@ -79,7 +79,6 @@ msgid "What to Do Outside the Campsite?" msgstr "¿Qué hacer desde el camping?" #: 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 d’en 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 d’en 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…." 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:59 +#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23 +#: web/templates/public/layout.gohtml:58 msgid "Campsite Montagut" msgstr "Camping Montagut" @@ -141,10 +140,70 @@ msgstr "Camping Montagut" msgid "Skip to main content" msgstr "Saltar al contenido principal" -#: web/templates/public/layout.gohtml:33 +#: web/templates/public/layout.gohtml:32 msgid "Singular Lodges" 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:26 msgctxt "title" @@ -177,24 +236,6 @@ msgctxt "input" msgid "Label" 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:13 #: 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." #: 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" msgid "Edit Campsite Type" msgstr "Edición del tipo de alojamientos" #: 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" msgid "New Campsite Type" 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 msgctxt "campsite type" msgid "Active" 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/season/form.gohtml:47 #: web/templates/admin/profile.gohtml:26 @@ -259,14 +300,7 @@ msgctxt "input" msgid "Name" msgstr "Nombre" -#: web/templates/admin/campsite/type/form.gohtml:59 -#: 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/form.gohtml:58 #: web/templates/admin/campsite/type/l10n.gohtml:33 msgctxt "input" msgid "Description" @@ -307,28 +341,6 @@ msgctxt "title" msgid "Translate Campsite Type to %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:26 msgctxt "title" @@ -400,43 +412,11 @@ msgctxt "action" msgid "Login" 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/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 +#: web/templates/admin/layout.gohtml:79 msgctxt "title" -msgid "Home Page" -msgstr "Página de inicio" +msgid "Services Page" +msgstr "Página de servicios" #: web/templates/admin/services/index.gohtml:12 #: web/templates/admin/home/index.gohtml:12 @@ -599,27 +579,59 @@ msgctxt "action" msgid "Logout" msgstr "Salir" -#: web/templates/admin/layout.gohtml:79 -#, fuzzy +#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 msgctxt "title" -msgid "Services Page" -msgstr "Servicios" +msgid "Home Page" +msgstr "Página de inicio" +#: web/templates/admin/layout.gohtml:82 #: web/templates/admin/media/index.gohtml:6 #: web/templates/admin/media/index.gohtml:12 msgctxt "title" msgid "Media" 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 msgctxt "action" msgid "Upload media" 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:13 msgctxt "title" @@ -631,15 +643,29 @@ msgctxt "input" msgid "File" 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 msgctxt "action" msgid "Upload" 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 msgid "Email can not be empty." msgstr "No podéis dejar el correo-e en blanco." @@ -662,7 +688,7 @@ msgid "Automatic" msgstr "Automático" #: 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." 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." msgstr "El idioma escogido no es válido." -#: pkg/app/user.go:253 pkg/campsite/types/admin.go:296 -#: pkg/services/carousel.go:287 pkg/home/carousel.go:287 +#: pkg/app/user.go:253 msgid "File must be a valid PNG or JPEG image." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." -#: pkg/app/admin.go:50 +#: pkg/app/admin.go:53 msgid "Access forbidden" 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." 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." 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 msgid "Selected country is not valid." 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." 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." 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" #~ msgstr "Entorno" diff --git a/revert/edit_media.sql b/revert/edit_media.sql new file mode 100644 index 0000000..c96b820 --- /dev/null +++ b/revert/edit_media.sql @@ -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; diff --git a/revert/media_content.sql b/revert/media_content.sql new file mode 100644 index 0000000..c7f095a --- /dev/null +++ b/revert/media_content.sql @@ -0,0 +1,7 @@ +-- Revert camper:media_content from pg + +begin; + +drop table if exists camper.media_content; + +commit; diff --git a/sqitch.plan b/sqitch.plan index 0ff0533..2d032ef 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -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 # Add row-level security profiles to company change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jordi fita mas # Add function to change the current user’s password media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas # Add domain for media type -media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas # Add relation of uploaded media -add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas # Add function to create media +media_content [roles schema_camper media_type] 2023-09-19T23:21:22Z jordi fita mas # Add relation for media content bytes +media [roles schema_camper company media_content user_profile] 2023-09-08T16:50:55Z jordi fita mas # Add relation of uploaded media +add_media [roles schema_camper media media_content media_type] 2023-09-08T17:40:28Z jordi fita mas # Add function to create media +edit_media [roles schema_camper media_content media media_type] 2023-09-20T15:46:53Z jordi fita mas # Add function to edit a media media_path [roles schema_camper media] 2023-09-13T22:50:14Z jordi fita mas # 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 # Add relation of campsite type campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas # Add relation for campsite_type translations diff --git a/test/add_campsite.sql b/test/add_campsite.sql index 075c81b..8dbb772 100644 --- a/test/add_campsite.sql +++ b/test/add_campsite.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate campsite cascade; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +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"};')) + , (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) diff --git a/test/add_campsite_type.sql b/test/add_campsite_type.sql index e09885b..5160a7a 100644 --- a/test/add_campsite_type.sql +++ b/test/add_campsite_type.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate campsite_type_i18n cascade; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +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"};')) + , (4, 2, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; select lives_ok( diff --git a/test/add_home_carousel_slide.sql b/test/add_home_carousel_slide.sql index fac1178..c25bb67 100644 --- a/test/add_home_carousel_slide.sql +++ b/test/add_home_carousel_slide.sql @@ -23,6 +23,7 @@ set client_min_messages to warning; truncate home_carousel_i18n cascade; truncate home_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into home_carousel (media_id, caption) diff --git a/test/add_media.sql b/test/add_media.sql index 36351ff..c1764f6 100644 --- a/test/add_media.sql +++ b/test/add_media.sql @@ -10,7 +10,7 @@ set search_path to camper, public; select plan(13); 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 isnt_definer('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']); 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; truncate media cascade; +truncate media_content cascade; truncate company cascade; reset client_min_messages; @@ -46,7 +47,7 @@ select lives_ok( ); 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!') , (2, sha256(''), 'image.svg', 'image/svg+xml', '') , (2, sha256('hello, world!'), 'world.txt', 'text/plain', 'hello, world!') diff --git a/test/add_services_carousel_slide.sql b/test/add_services_carousel_slide.sql index f35cf94..6a4c1fd 100644 --- a/test/add_services_carousel_slide.sql +++ b/test/add_services_carousel_slide.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate services_carousel_i18n cascade; truncate services_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into services_carousel (media_id, caption) diff --git a/test/campsite.sql b/test/campsite.sql index b1053e4..d2d9484 100644 --- a/test/campsite.sql +++ b/test/campsite.sql @@ -66,6 +66,7 @@ set client_min_messages to warning; truncate campsite cascade; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company_host cascade; truncate company_user cascade; truncate company cascade; @@ -93,9 +94,13 @@ values (2, 'co2') , (4, 'co4') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +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 (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) diff --git a/test/campsite_type.sql b/test/campsite_type.sql index 1598ab0..ee11da7 100644 --- a/test/campsite_type.sql +++ b/test/campsite_type.sql @@ -71,6 +71,7 @@ select col_default_is('campsite_type', 'active', 'true'); set client_min_messages to warning; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company_host cascade; truncate company_user cascade; truncate company cascade; @@ -97,9 +98,13 @@ values (2, 'co2') , (4, 'co4') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +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 (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) diff --git a/test/edit_campsite.sql b/test/edit_campsite.sql index 8c13e15..c5f09fe 100644 --- a/test/edit_campsite.sql +++ b/test/edit_campsite.sql @@ -23,6 +23,7 @@ set client_min_messages to warning; truncate campsite cascade; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +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) diff --git a/test/edit_campsite_type.sql b/test/edit_campsite_type.sql index 2963ada..23e3255 100644 --- a/test/edit_campsite_type.sql +++ b/test/edit_campsite_type.sql @@ -22,6 +22,7 @@ select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'intege set client_min_messages to warning; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (3, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};') - , (4, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};') +insert into 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"};') + , ('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) diff --git a/test/edit_media.sql b/test/edit_media.sql new file mode 100644 index 0000000..13535fb --- /dev/null +++ b/test/edit_media.sql @@ -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', '') $$, + '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(''), 'cover4.svg', 'image/svg+xml', '') + $$, + 'Should have added all three media' +); + +select * +from finish(); + +rollback; diff --git a/test/home_carousel.sql b/test/home_carousel.sql index 5d3f9f1..cde60a5 100644 --- a/test/home_carousel.sql +++ b/test/home_carousel.sql @@ -32,6 +32,7 @@ select col_hasnt_default('home_carousel', 'caption'); set client_min_messages to warning; truncate home_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company_host cascade; truncate company_user cascade; truncate company cascade; @@ -59,11 +60,18 @@ values (2, 'co2') , (4, 'co4') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values ( 7, 2, 'text2.txt', 'text/plain', 'content2') - , ( 8, 2, 'text3.txt', 'text/plain', 'content3') - , ( 9, 4, 'text4.txt', 'text/plain', 'content4') - , (10, 4, 'text5.txt', 'text/plain', 'content5') +insert into media_content (media_type, bytes) +values ('text/plain', 'content2') + , ('text/plain', 'content3') + , ('text/plain', 'content4') + , ('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) diff --git a/test/media.sql b/test/media.sql index 45e7adc..91c38c1 100644 --- a/test/media.sql +++ b/test/media.sql @@ -5,13 +5,12 @@ reset client_min_messages; begin; -select plan(55); +select plan(47); set search_path to camper, public; select has_table('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', 'employee', array['SELECT']); 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_hasnt_default('media', 'company_id'); -select has_column('media', 'hash'); -select col_type_is('media', 'hash', 'bytea'); -select col_not_null('media', 'hash'); -select col_has_default('media', 'hash'); -select col_default_is('media', 'hash', 'sha256(content)'); +select has_column('media', 'content_hash'); +select col_is_fk('media', 'content_hash'); +select fk_ok('media', 'content_hash', 'media_content', 'content_hash'); +select col_type_is('media', 'content_hash', 'bytea'); +select col_not_null('media', 'content_hash'); +select col_hasnt_default('media', 'content_hash'); select has_column('media', 'original_filename'); select col_type_is('media', 'original_filename', 'text'); select col_not_null('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; truncate media cascade; +truncate media_content cascade; truncate company_host cascade; truncate company_user cascade; truncate company cascade; @@ -87,9 +78,15 @@ values (2, 'co2') , (4, 'co4') ; -insert into media (company_id, original_filename, media_type, content) -values (2, 'text2.txt', 'text/plain', 'content2') - , (4, 'text4.txt', 'text/plain', 'content4') +insert into media_content (media_type, bytes) +values ('text/plain', 'content2') + , ('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 @@ -110,36 +107,36 @@ reset role; select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); select lives_ok( - $$ insert into media(company_id, original_filename, media_type, content) - values (2, 'text2-2.txt', 'text/plain', sha256('Another media')) $$, + $$ insert into media(company_id, original_filename, content_hash) + values (2, 'text2-6.txt', sha256('content6')) $$, 'Admin from company 2 should be able to insert a new media to that company.' ); select bag_eq( 'media_data', $$ values (2, 'text2.txt') - , (2, 'text2-2.txt') + , (2, 'text2-6.txt') , (4, 'text4.txt') $$, 'The new row should have been added' ); 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.' ); select bag_eq( 'media_data', $$ values (2, 'text2.txt') - , (2, 'text2_2.txt') + , (2, 'text2_6.txt') , (4, 'text4.txt') $$, 'The row should have been updated.' ); 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.' ); @@ -152,8 +149,8 @@ select bag_eq( ); select throws_ok( - $$ insert into media (company_id, original_filename, media_type, content) - values (4, 'text4-2.txt', 'text/plain', 'Another media') $$, + $$ insert into media (company_id, original_filename, content_hash) + values (4, 'text4-2.txt', sha256('content6')) $$, '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.' ); @@ -191,7 +188,7 @@ select bag_eq( ); 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"', 'Should not be able to insert media with a blank filename.' ); diff --git a/test/media_content.sql b/test/media_content.sql new file mode 100644 index 0000000..3eb7e59 --- /dev/null +++ b/test/media_content.sql @@ -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; + diff --git a/test/media_path.sql b/test/media_path.sql index 60c31ee..1c77c5d 100644 --- a/test/media_path.sql +++ b/test/media_path.sql @@ -21,6 +21,7 @@ select function_privs_are('camper', 'path', array['media'], 'authenticator', arr set client_min_messages to warning; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (3, 1, 'cover1.xpm', '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"};') - , (5, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'text.txt', 'text/plain', 'hello, world!') +insert into media_content (media_type, bytes) +values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};') + , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};') + , ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'text.txt', sha256('hello, world!')) ; select bag_eq( diff --git a/test/remove_home_carousel_slide.sql b/test/remove_home_carousel_slide.sql index 90fe059..03ab5fa 100644 --- a/test/remove_home_carousel_slide.sql +++ b/test/remove_home_carousel_slide.sql @@ -23,6 +23,7 @@ set client_min_messages to warning; truncate home_carousel_i18n cascade; truncate home_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into home_carousel (media_id, caption) diff --git a/test/remove_services_carousel_slide.sql b/test/remove_services_carousel_slide.sql index 1c7b2f5..5b27740 100644 --- a/test/remove_services_carousel_slide.sql +++ b/test/remove_services_carousel_slide.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate services_carousel_i18n cascade; truncate services_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into services_carousel (media_id, caption) diff --git a/test/services_carousel.sql b/test/services_carousel.sql index ec02bba..2906a62 100644 --- a/test/services_carousel.sql +++ b/test/services_carousel.sql @@ -32,6 +32,7 @@ select col_hasnt_default('services_carousel', 'caption'); set client_min_messages to warning; truncate services_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company_host cascade; truncate company_user cascade; truncate company cascade; @@ -58,11 +59,18 @@ values (2, 'co2') , (4, 'co4') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values ( 7, 2, 'text2.txt', 'text/plain', 'content2') - , ( 8, 2, 'text3.txt', 'text/plain', 'content3') - , ( 9, 4, 'text4.txt', 'text/plain', 'content4') - , (10, 4, 'text5.txt', 'text/plain', 'content5') +insert into media_content (media_type, bytes) +values ('text/plain', 'content2') + , ('text/plain', 'content3') + , ('text/plain', 'content4') + , ('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) diff --git a/test/translate_campsite_type.sql b/test/translate_campsite_type.sql index 84657e4..1337751 100644 --- a/test/translate_campsite_type.sql +++ b/test/translate_campsite_type.sql @@ -23,6 +23,7 @@ select function_privs_are('camper', 'translate_campsite_type', array['uuid', 'te set client_min_messages to warning; truncate campsite_type cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') - , (3, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};') - , (4, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};') +insert into 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"};') + , ('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) diff --git a/test/translate_home_carousel_slide.sql b/test/translate_home_carousel_slide.sql index 6ab4be0..432e50a 100644 --- a/test/translate_home_carousel_slide.sql +++ b/test/translate_home_carousel_slide.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate home_carousel_i18n cascade; truncate home_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into home_carousel (media_id, caption) diff --git a/test/translate_services_carousel_slide.sql b/test/translate_services_carousel_slide.sql index 73d1e31..5a9efd4 100644 --- a/test/translate_services_carousel_slide.sql +++ b/test/translate_services_carousel_slide.sql @@ -24,6 +24,7 @@ set client_min_messages to warning; truncate services_carousel_i18n cascade; truncate services_carousel cascade; truncate media cascade; +truncate media_content cascade; truncate company cascade; 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') ; -insert into media (media_id, company_id, original_filename, media_type, content) -values (5, 1, 'text.txt', 'text/plain', 'hello, world!') - , (6, 1, 'image.svg', 'image/svg+xml', '') - , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +insert into media_content (media_type, bytes) +values ('text/plain', 'hello, world!') + , ('image/svg+xml', '') + , ('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('')) + , (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) ; insert into services_carousel (media_id, caption) diff --git a/verify/edit_media.sql b/verify/edit_media.sql new file mode 100644 index 0000000..feed84c --- /dev/null +++ b/verify/edit_media.sql @@ -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; diff --git a/verify/media.sql b/verify/media.sql index 9334fcb..abd2aca 100644 --- a/verify/media.sql +++ b/verify/media.sql @@ -4,10 +4,8 @@ begin; select media_id , company_id - , hash + , content_hash , original_filename - , media_type - , content from camper.media where false; diff --git a/verify/media_content.sql b/verify/media_content.sql new file mode 100644 index 0000000..c5e391e --- /dev/null +++ b/verify/media_content.sql @@ -0,0 +1,11 @@ +-- Verify camper:media_content on pg + +begin; + +select content_hash + , media_type + , bytes +from camper.media_content +where false; + +rollback; diff --git a/web/static/camper.css b/web/static/camper.css index 929a0c5..fdbb7fe 100644 --- a/web/static/camper.css +++ b/web/static/camper.css @@ -48,3 +48,22 @@ p, h1, h2, h3, h4, h5, h6 { a.missing-translation { 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; +} diff --git a/web/templates/admin/campsite/type/form.gohtml b/web/templates/admin/campsite/type/form.gohtml index 221a2ab..43a5d5e 100644 --- a/web/templates/admin/campsite/type/form.gohtml +++ b/web/templates/admin/campsite/type/form.gohtml @@ -15,7 +15,6 @@ {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeForm*/ -}} {{ template "settings-tabs" "campsiteTypes" }}
- {{- end }} - - {{ template "error-message" . }} + {{ template "media-picker" . }} {{- end }} {{ with .Description -}}