Use HTTP Host to establish the request’s company

We made the decision that this application will also serve the public
pages to guests and customers, to avoid the overhead of having to
synchronize all data between this application and a bespoke WordPress
plugin.

That means that i no longer can have a /company/slug in the URL to know
which company the request is for, not only because it looks ugly but
because guest users do not have a “main company”—or any company
whatsoever.

Since the public-facing web is going to be served through a valid DNS
domain, and all companies are going to have a different domain, i
realized this is enough: i only had to add a relation of company and
their hosts.  The same company can have many hosts for staging servers
or to separate the administration and public parts, for instance.

With change, the company is already known from the first handler, and
can pass it down to all the others, not only the handlers under
/company/slug/whatever.  And i no longer need the companyURL function,
as there is no more explicit company in the URL.

Even though template technically does not need the template, as it only
contains the ID —the rest of the data is in a relation inaccessible to
guests for now—, but i left the parameter just in case later on i need
the decimal digits or currency symbol for whatever reason.
This commit is contained in:
jordi fita mas 2023-08-03 20:21:21 +02:00
parent f65110824e
commit 0d2812acc5
13 changed files with 125 additions and 130 deletions

View File

@ -12,6 +12,11 @@ alter sequence company_company_id_seq restart with 52;
insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag, legal_disclaimer) insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag, legal_disclaimer)
values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'Càmping les mines, S.L.U.', 'ESB17616756', 'Pescamines', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@lesmines.cat', 'https://lesmines.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 'ca', 'Càmping les mines, S.L.U. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Càmping les mines, S.L.U., amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@lesmines.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a lesmines.cat.'); values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'Càmping les mines, S.L.U.', 'ESB17616756', 'Pescamines', parse_packed_phone_number('972 50 60 70', 'ES'), 'info@lesmines.cat', 'https://lesmines.cat/', 'C/ de lHort', 'Castelló dEmpúries', 'Girona', '17486', 'ES', 'EUR', 'ca', 'Càmping les mines, S.L.U. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a Càmping les mines, S.L.U., amb domicili Carrer de lHort 71, 17486 Castelló dEmpúries o enviant un correu electrònic a info@lesmines.cat. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a lesmines.cat.');
insert into company_host (company_id, host)
values (52, 'localhost:8080')
, (52, 'camper.tandem.ws')
;
insert into company_user (company_id, user_id) insert into company_user (company_id, user_id)
values (52, 42) values (52, 42)
, (52, 43) , (52, 43)

20
deploy/company_host.sql Normal file
View File

@ -0,0 +1,20 @@
-- Deploy camper:company_host to pg
-- requires: roles
-- requires: schema_public
begin;
set search_path to public, camper;
create table company_host (
host text primary key,
company_id integer not null references company
);
comment on column company_host.host is 'This should be a value from the HTTP Host header.';
grant select on table company_host to guest;
grant select on table company_host to employee;
grant select on table company_host to admin;
commit;

View File

@ -11,17 +11,18 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/company" "dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
) )
type App struct { type App struct {
db *database.DB db *database.DB
fileHandler http.Handler fileHandler http.Handler
profile *profileHandler profile *profileHandler
company *company.Handler campsite *campsite.Handler
locales locale.Locales locales locale.Locales
defaultLocale *locale.Locale defaultLocale *locale.Locale
languageMatcher language.Matcher languageMatcher language.Matcher
@ -38,7 +39,7 @@ func New(db *database.DB, avatarsDir string) (http.Handler, error) {
db: db, db: db,
fileHandler: static, fileHandler: static,
profile: profile, profile: profile,
company: company.NewHandler(), campsite: campsite.NewHandler(),
locales: locales, locales: locales,
defaultLocale: locales[language.Catalan], defaultLocale: locales[language.Catalan],
languageMatcher: language.NewMatcher(locales.Tags()), languageMatcher: language.NewMatcher(locales.Tags()),
@ -72,31 +73,39 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
panic(err) panic(err)
} }
company, err := auth.CompanyByHost(r.Context(), conn, r.Host)
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
} else if err != nil {
panic(err)
}
if head == "login" { if head == "login" {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
serveLoginForm(w, r, user, "/") serveLoginForm(w, r, user, company, "/")
case http.MethodPost: case http.MethodPost:
handleLogin(w, r, user, conn) handleLogin(w, r, user, company, conn)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodPost, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodPost, http.MethodGet)
} }
} else { } else {
if !user.LoggedIn { if !user.LoggedIn {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
serveLoginForm(w, r, user, requestPath) serveLoginForm(w, r, user, company, requestPath)
return return
} }
switch head { switch head {
case "me": case "me":
h.profile.Handler(user, conn).ServeHTTP(w, r) h.profile.Handler(user, company, conn).ServeHTTP(w, r)
case "company": case "campsites":
h.company.Handler(user, conn).ServeHTTP(w, r) h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "": case "":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
redirectToMainCompany(w, r, conn) serveDashboard(w, r, user, company)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet)
} }
@ -107,10 +116,6 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
func redirectToMainCompany(w http.ResponseWriter, r *http.Request, conn *database.Conn) { func serveDashboard(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
co, err := auth.QueryMainCompany(r.Context(), conn) template.MustRender(w, r, user, company, "dashboard.gohtml", nil)
if err != nil {
panic(err)
}
httplib.Relocate(w, r, co.URL(), http.StatusFound)
} }

View File

@ -60,17 +60,17 @@ func (f *loginForm) Valid(l *locale.Locale) bool {
return v.AllOK return v.AllOK
} }
func (f *loginForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) { func (f *loginForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRender(w, r, user, nil, "login.gohtml", f) template.MustRender(w, r, user, company, "login.gohtml", f)
} }
func serveLoginForm(w http.ResponseWriter, r *http.Request, user *auth.User, redirectPath string) { func serveLoginForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, redirectPath string) {
login := newLoginForm() login := newLoginForm()
login.Redirect.Val = redirectPath login.Redirect.Val = redirectPath
login.MustRender(w, r, user) login.MustRender(w, r, user, company)
} }
func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
login := newLoginForm() login := newLoginForm()
if err := login.Parse(r); err != nil { if err := login.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -88,7 +88,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *
} else { } else {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} }
login.MustRender(w, r, user) login.MustRender(w, r, user, company)
} }
func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {

View File

@ -80,7 +80,7 @@ func newProfileHandler(static http.Handler, avatarsDir string) (*profileHandler,
return handler, nil return handler, nil
} }
func (h *profileHandler) Handler(user *auth.User, conn *database.Conn) http.HandlerFunc { func (h *profileHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var head string var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
@ -103,9 +103,9 @@ func (h *profileHandler) Handler(user *auth.User, conn *database.Conn) http.Hand
case "": case "":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
serveProfileForm(w, r, user, conn) serveProfileForm(w, r, user, company, conn)
case http.MethodPut: case http.MethodPut:
h.updateProfile(w, r, user, conn) h.updateProfile(w, r, user, company, conn)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
} }
@ -130,15 +130,15 @@ func (h *profileHandler) avatarPath(user *auth.User) string {
return filepath.Join(h.avatarsDir, strconv.Itoa(user.ID)) return filepath.Join(h.avatarsDir, strconv.Itoa(user.ID))
} }
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn) profile := newProfileForm(r.Context(), user.Locale, conn)
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile") profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
profile.Email.Val = user.Email profile.Email.Val = user.Email
profile.Language.Selected = []string{user.Language.String()} profile.Language.Selected = []string{user.Language.String()}
profile.MustRender(w, r, user) profile.MustRender(w, r, user, company)
} }
func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn) profile := newProfileForm(r.Context(), user.Locale, conn)
if err := profile.Parse(w, r); err != nil { if err := profile.Parse(w, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -153,7 +153,7 @@ func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, u
if !httplib.IsHTMxRequest(r) { if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} }
profile.MustRender(w, r, user) profile.MustRender(w, r, user, company)
return return
} }
//goland:noinspection SqlWithoutWhere //goland:noinspection SqlWithoutWhere
@ -248,8 +248,8 @@ func (f *profileForm) Valid(l *locale.Locale) bool {
return v.AllOK return v.AllOK
} }
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) { func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRender(w, r, user, nil, "profile.gohtml", f) template.MustRender(w, r, user, company, "profile.gohtml", f)
} }
func (f *profileForm) HasAvatarFile() bool { func (f *profileForm) HasAvatarFile() bool {

View File

@ -12,44 +12,19 @@ import (
) )
type Company struct { type Company struct {
ID int ID int
CurrencySymbol string
DecimalDigits int
Slug string
} }
func QueryMainCompany(ctx context.Context, conn *database.Conn) (*Company, error) { func CompanyByHost(ctx context.Context, conn *database.Conn, host string) (*Company, error) {
slug, err := conn.GetText(ctx, "select slug::text from company order by company_id limit 1") company := &Company{}
if err != nil {
return nil, err
}
return QueryBySlug(ctx, conn, slug)
}
func QueryBySlug(ctx context.Context, conn *database.Conn, slug string) (*Company, error) {
company := &Company{
Slug: slug,
}
if err := conn.QueryRow(ctx, ` if err := conn.QueryRow(ctx, `
select company_id select company_id
, currency_symbol from company_host
, decimal_digits where host = $1
from company `, host).Scan(
join currency using (currency_code)
where slug = $1
`, company.Slug).Scan(
&company.ID, &company.ID,
&company.CurrencySymbol,
&company.DecimalDigits,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
return company, nil return company, nil
} }
func (c *Company) URL() string {
if c == nil {
return ""
}
return "/company/" + c.Slug
}

View File

@ -1,64 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package company
import (
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
"net/http"
)
type Handler struct {
campsite *campsite.Handler
}
func NewHandler() *Handler {
return &Handler{
campsite: campsite.NewHandler(),
}
}
func (h *Handler) Handler(user *auth.User, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var slug string
slug, r.URL.Path = httplib.ShiftPath(r.URL.Path)
if !uuid.Valid(slug) {
http.NotFound(w, r)
return
}
company, err := auth.QueryBySlug(r.Context(), conn, slug)
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
} else if err != nil {
panic(err)
}
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "campsites":
h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "":
switch r.Method {
case http.MethodGet:
serveDashboard(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
})
}
func serveDashboard(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRender(w, r, user, company, "dashboard.gohtml", nil)
}

View File

@ -30,9 +30,8 @@ func MustRender(w io.Writer, r *http.Request, user *auth.User, company *auth.Com
func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, layout string, filename string, data interface{}) { func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, layout string, filename string, data interface{}) {
t := template.New(filename) t := template.New(filename)
t.Funcs(template.FuncMap{ t.Funcs(template.FuncMap{
"gettext": user.Locale.Get, "gettext": user.Locale.Get,
"pgettext": user.Locale.GetC, "pgettext": user.Locale.GetC,
"companyURL": company.URL,
"currentLocale": func() string { "currentLocale": func() string {
return user.Locale.Language.String() return user.Locale.Language.String()
}, },

7
revert/company_host.sql Normal file
View File

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

View File

@ -37,3 +37,4 @@ available_countries [schema_camper country country_i18n] 2023-07-29T01:48:40Z jo
company [roles schema_camper extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country language] 2023-07-29T01:56:41Z jordi fita mas <jordi@tandem.blog> # Add relation for company company [roles schema_camper extension_vat email extension_pg_libphonenumber extension_uri currency_code currency country_code country language] 2023-07-29T01:56:41Z jordi fita mas <jordi@tandem.blog> # Add relation for company
company_user [roles schema_camper user company] 2023-07-29T02:08:07Z jordi fita mas <jordi@tandem.blog> # Add relation of company user company_user [roles schema_camper user company] 2023-07-29T02:08:07Z jordi fita mas <jordi@tandem.blog> # Add relation of company user
campsite_type [roles schema_camper company] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type campsite_type [roles schema_camper company] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type
company_host [roles schema_public] 2023-08-03T17:46:45Z jordi fita mas <jordi@tandem.blog> # Add relation of DNS domain and company

37
test/company_host.sql Normal file
View File

@ -0,0 +1,37 @@
-- Test company_host
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(17);
set search_path to camper, public;
select has_table('company_host');
select has_pk('company_host' );
select table_privs_are('company_host', 'guest', array ['SELECT']);
select table_privs_are('company_host', 'employee', array ['SELECT']);
select table_privs_are('company_host', 'admin', array ['SELECT']);
select table_privs_are('company_host', 'authenticator', array []::text[]);
select has_column('company_host', 'host');
select col_is_pk('company_host', 'host');
select col_type_is('company_host', 'host', 'text');
select col_not_null('company_host', 'host');
select col_hasnt_default('company_host', 'host');
select has_column('company_host', 'company_id');
select col_is_fk('company_host', 'company_id');
select fk_ok('company_host', 'company_id', 'company', 'company_id');
select col_type_is('company_host', 'company_id', 'integer');
select col_not_null('company_host', 'company_id');
select col_hasnt_default('company_host', 'company_id');
select *
from finish();
rollback;

10
verify/company_host.sql Normal file
View File

@ -0,0 +1,10 @@
-- Verify camper:company_host on pg
begin;
select host
, company_id
from public.company_host
where false;
rollback;

View File

@ -40,7 +40,7 @@
<nav> <nav>
<ul role="menu"> <ul role="menu">
<li role="presentation"> <li role="presentation">
<a role="menuitem" href="{{ companyURL }}/">{{( pgettext "Dashboard" "title" )}}</a> <a role="menuitem" href="/">{{( pgettext "Dashboard" "title" )}}</a>
</li> </li>
</ul> </ul>
</nav> </nav>