From 9746b441c3d137b61d46d8af5cd2e8515171cd0d Mon Sep 17 00:00:00 2001 From: kiootic Date: Tue, 28 Nov 2023 16:31:26 +0800 Subject: [PATCH 1/6] Add custom domain for local resolution --- cmd/controller/app/start.go | 7 ++- cmd/pageship/app/serve.go | 32 ++++++---- examples/sites.toml | 8 ++- internal/config/sites.go | 1 + internal/domain/local/resolver.go | 15 +++++ internal/domain/local/resolver_static.go | 44 ++++++++++++++ internal/domain/resolver.go | 13 ++++ internal/domain/resolver_null.go | 13 ++++ internal/handler/site/handler.go | 59 +++++++++++-------- internal/handler/site/middleware.go | 6 +- internal/handler/site/middleware/canon.go | 4 +- .../handler/site/middleware/custom_domain.go | 20 +++++++ internal/handler/site/middleware/index.go | 4 +- .../handler/site/middleware/middleware.go | 1 + internal/handler/site/middleware/spa.go | 4 +- internal/handler/site/site_handler.go | 4 +- internal/site/db/resolver.go | 3 +- internal/site/fs.go | 1 + internal/site/local/resolver.go | 2 +- internal/site/local/resolver_adhoc.go | 2 +- internal/site/local/resolver_single.go | 2 +- internal/site/local/resolver_static.go | 3 +- internal/site/resolver.go | 3 +- 23 files changed, 195 insertions(+), 56 deletions(-) create mode 100644 internal/domain/local/resolver.go create mode 100644 internal/domain/local/resolver_static.go create mode 100644 internal/domain/resolver.go create mode 100644 internal/domain/resolver_null.go create mode 100644 internal/handler/site/middleware/custom_domain.go diff --git a/cmd/controller/app/start.go b/cmd/controller/app/start.go index 3fa9984..357f6d1 100644 --- a/cmd/controller/app/start.go +++ b/cmd/controller/app/start.go @@ -14,6 +14,7 @@ import ( "github.com/oursky/pageship/internal/db" _ "github.com/oursky/pageship/internal/db/postgres" _ "github.com/oursky/pageship/internal/db/sqlite" + "github.com/oursky/pageship/internal/domain" "github.com/oursky/pageship/internal/handler/controller" "github.com/oursky/pageship/internal/handler/site" "github.com/oursky/pageship/internal/handler/site/middleware" @@ -128,7 +129,8 @@ func (s *setup) checkDomain(name string) error { } func (s *setup) sites(conf StartSitesConfig) error { - resolver := &sitedb.Resolver{ + domainResolver := &domain.ResolverNull{} // FIXME: custom domain + siteResolver := &sitedb.Resolver{ HostIDScheme: conf.HostIDScheme, DB: s.database, Storage: s.storage, @@ -136,7 +138,8 @@ func (s *setup) sites(conf StartSitesConfig) error { handler, err := site.NewHandler( s.ctx, logger.Named("site"), - resolver, + domainResolver, + siteResolver, site.HandlerConfig{ HostPattern: conf.HostPattern, Middlewares: middleware.Default, diff --git a/cmd/pageship/app/serve.go b/cmd/pageship/app/serve.go index 1153aba..bbed06e 100644 --- a/cmd/pageship/app/serve.go +++ b/cmd/pageship/app/serve.go @@ -11,11 +11,13 @@ import ( "github.com/caddyserver/certmagic" "github.com/oursky/pageship/internal/command" "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/domain" + domainlocal "github.com/oursky/pageship/internal/domain/local" handler "github.com/oursky/pageship/internal/handler/site" "github.com/oursky/pageship/internal/handler/site/middleware" "github.com/oursky/pageship/internal/httputil" "github.com/oursky/pageship/internal/site" - "github.com/oursky/pageship/internal/site/local" + sitelocal "github.com/oursky/pageship/internal/site/local" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -53,11 +55,13 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle fsys := os.DirFS(dir) - var resolver site.Resolver - resolver = local.NewSingleSiteResolver(fsys) + var siteResolver site.Resolver + siteResolver = sitelocal.NewSingleSiteResolver(fsys) + var domainResolver domain.Resolver + domainResolver = &domain.ResolverNull{} // Check site on startup. - _, err = resolver.Resolve(context.Background(), defaultSite) + _, err = siteResolver.Resolve(context.Background(), defaultSite) if errors.Is(err, config.ErrConfigNotFound) { // continue in multi-site mode @@ -72,17 +76,23 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle if sitesConf != nil { sites = sitesConf.Sites } - resolver = local.NewMultiSiteResolver(fsys, defaultSite, sites) + siteResolver = sitelocal.NewResolver(fsys, defaultSite, sites) + domainResolver, err = domainlocal.NewResolver(defaultSite, sites) + if err != nil { + return nil, err + } } else if err != nil { return nil, err } - Info("site resolution mode: %s", resolver.Kind()) + Info("site resolution mode: %s", siteResolver.Kind()) - handler, err := handler.NewHandler(context.Background(), zapLogger, resolver, handler.HandlerConfig{ - HostPattern: hostPattern, - Middlewares: middleware.Default, - }) + handler, err := handler.NewHandler(context.Background(), zapLogger, + domainResolver, siteResolver, + handler.HandlerConfig{ + HostPattern: hostPattern, + Middlewares: middleware.Default, + }) if err != nil { return nil, err } @@ -127,7 +137,7 @@ var serveCmd = &cobra.Command{ if len(tlsDomain) > 0 { tls.DomainNames = []string{tlsDomain} - } else if handler.AllowAnyDomain() { + } else if handler.AcceptsAllDomain() { return fmt.Errorf("must provide domain name via --tls-domain to enable TLS") } } diff --git a/examples/sites.toml b/examples/sites.toml index 56fe505..bd87b08 100644 --- a/examples/sites.toml +++ b/examples/sites.toml @@ -1,5 +1,9 @@ [sites."main"] -context="main" +context = "main" [sites."dev"] -context="dev" +context = "dev" + +[sites."custom"] +context = "dev" +domain = "example.com" diff --git a/internal/config/sites.go b/internal/config/sites.go index dd0ec9a..420848d 100644 --- a/internal/config/sites.go +++ b/internal/config/sites.go @@ -8,6 +8,7 @@ type SitesConfig struct { type SitesConfigEntry struct { Context string `json:"context"` + Domain string `json:"domain"` } func DefaultSitesConfig() *SitesConfig { diff --git a/internal/domain/local/resolver.go b/internal/domain/local/resolver.go new file mode 100644 index 0000000..f8d8074 --- /dev/null +++ b/internal/domain/local/resolver.go @@ -0,0 +1,15 @@ +package local + +import ( + "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/domain" +) + +func NewResolver(defaultSite string, sites map[string]config.SitesConfigEntry) (domain.Resolver, error) { + if len(sites) == 0 { + // Custom domain resolution is not supported for ad-hoc sites. + return &domain.ResolverNull{}, nil + } + + return newResolverStatic(defaultSite, sites) +} diff --git a/internal/domain/local/resolver_static.go b/internal/domain/local/resolver_static.go new file mode 100644 index 0000000..1a2c36d --- /dev/null +++ b/internal/domain/local/resolver_static.go @@ -0,0 +1,44 @@ +package local + +import ( + "context" + "fmt" + + "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/domain" +) + +type resolverStatic struct { + domains map[string]string +} + +func newResolverStatic(defaultSite string, sites map[string]config.SitesConfigEntry) (*resolverStatic, error) { + domains := make(map[string]string) + for id, site := range sites { + if site.Domain == "" { + continue + } + + if _, exists := domains[site.Domain]; exists { + return nil, fmt.Errorf("duplicated domain: %q", site.Domain) + } + + if defaultSite != "-" && id == defaultSite { + id = "" + } + domains[site.Domain] = id + } + + return &resolverStatic{domains: domains}, nil +} + +func (h *resolverStatic) Kind() string { return "static config" } + +func (h *resolverStatic) Resolve(ctx context.Context, hostname string) (string, error) { + id, ok := h.domains[hostname] + if !ok { + return "", domain.ErrDomainNotFound + } + + return id, nil +} diff --git a/internal/domain/resolver.go b/internal/domain/resolver.go new file mode 100644 index 0000000..6405a47 --- /dev/null +++ b/internal/domain/resolver.go @@ -0,0 +1,13 @@ +package domain + +import ( + "context" + "errors" +) + +var ErrDomainNotFound = errors.New("domain not found") + +type Resolver interface { + Kind() string + Resolve(ctx context.Context, hostname string) (string, error) +} diff --git a/internal/domain/resolver_null.go b/internal/domain/resolver_null.go new file mode 100644 index 0000000..7e07e79 --- /dev/null +++ b/internal/domain/resolver_null.go @@ -0,0 +1,13 @@ +package domain + +import ( + "context" +) + +type ResolverNull struct{} + +func (h *ResolverNull) Kind() string { return "null" } + +func (h *ResolverNull) Resolve(ctx context.Context, hostname string) (string, error) { + return "", ErrDomainNotFound +} diff --git a/internal/handler/site/handler.go b/internal/handler/site/handler.go index d2ade45..dfc8794 100644 --- a/internal/handler/site/handler.go +++ b/internal/handler/site/handler.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/oursky/pageship/internal/cache" "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/domain" "github.com/oursky/pageship/internal/httputil" "github.com/oursky/pageship/internal/models" "github.com/oursky/pageship/internal/site" @@ -28,21 +29,23 @@ type HandlerConfig struct { } type Handler struct { - ctx context.Context - logger *zap.Logger - resolver site.Resolver - hostPattern *config.HostPattern - cache *cache.Cache[*SiteHandler] - middlewares []Middleware + ctx context.Context + logger *zap.Logger + domainResolver domain.Resolver + siteResolver site.Resolver + hostPattern *config.HostPattern + cache *cache.Cache[*SiteHandler] + middlewares []Middleware } -func NewHandler(ctx context.Context, logger *zap.Logger, resolver site.Resolver, conf HandlerConfig) (*Handler, error) { +func NewHandler(ctx context.Context, logger *zap.Logger, domainResolver domain.Resolver, siteResolver site.Resolver, conf HandlerConfig) (*Handler, error) { h := &Handler{ - ctx: ctx, - logger: logger, - resolver: resolver, - hostPattern: config.NewHostPattern(conf.HostPattern), - middlewares: conf.Middlewares, + ctx: ctx, + logger: logger, + domainResolver: domainResolver, + siteResolver: siteResolver, + hostPattern: config.NewHostPattern(conf.HostPattern), + middlewares: conf.Middlewares, } cache, err := cache.NewCache(cacheSize, cacheTTL, h.doResolve) @@ -54,17 +57,23 @@ func NewHandler(ctx context.Context, logger *zap.Logger, resolver site.Resolver, return h, nil } -func (h *Handler) resolveSite(host string) (*SiteHandler, error) { - matchedID, ok := h.hostPattern.MatchString(host) +func (h *Handler) resolveSite(hostname string) (*SiteHandler, error) { + return h.cache.Load(hostname) +} + +func (h *Handler) doResolve(hostname string) (*SiteHandler, error) { + matchedID, ok := h.hostPattern.MatchString(hostname) if !ok { - return nil, site.ErrSiteNotFound + id, err := h.domainResolver.Resolve(h.ctx, hostname) + if errors.Is(err, domain.ErrDomainNotFound) { + return nil, site.ErrSiteNotFound + } else if err != nil { + return nil, err + } + matchedID = id } - return h.cache.Load(matchedID) -} - -func (h *Handler) doResolve(matchedID string) (*SiteHandler, error) { - desc, err := h.resolver.Resolve(h.ctx, matchedID) + desc, err := h.siteResolver.Resolve(h.ctx, matchedID) if err != nil { return nil, err } @@ -72,15 +81,15 @@ func (h *Handler) doResolve(matchedID string) (*SiteHandler, error) { return NewSiteHandler(desc, h.middlewares), nil } -func (h *Handler) AllowAnyDomain() bool { - return h.resolver.AllowAnyDomain() +func (h *Handler) AcceptsAllDomain() bool { + return h.siteResolver.IsWildcard() } -func (h *Handler) CheckValidDomain(name string) error { - if h.resolver.AllowAnyDomain() { +func (h *Handler) CheckValidDomain(hostname string) error { + if h.siteResolver.IsWildcard() { return nil } - _, err := h.resolveSite(name) + _, err := h.resolveSite(hostname) return err } diff --git a/internal/handler/site/middleware.go b/internal/handler/site/middleware.go index 05be7f5..694b158 100644 --- a/internal/handler/site/middleware.go +++ b/internal/handler/site/middleware.go @@ -6,11 +6,11 @@ import ( "github.com/oursky/pageship/internal/site" ) -type Middleware func(site.FS, http.Handler) http.Handler +type Middleware func(*site.Descriptor, http.Handler) http.Handler -func applyMiddleware(fs site.FS, middlewares []Middleware, handler http.Handler) http.Handler { +func applyMiddleware(site *site.Descriptor, middlewares []Middleware, handler http.Handler) http.Handler { for i := len(middlewares) - 1; i >= 0; i-- { - handler = middlewares[i](fs, handler) + handler = middlewares[i](site, handler) } return handler } diff --git a/internal/handler/site/middleware/canon.go b/internal/handler/site/middleware/canon.go index c43c44a..7e258d9 100644 --- a/internal/handler/site/middleware/canon.go +++ b/internal/handler/site/middleware/canon.go @@ -10,9 +10,9 @@ import ( "github.com/oursky/pageship/internal/site" ) -func CanonicalizePath(fs site.FS, next http.Handler) http.Handler { +func CanonicalizePath(site *site.Descriptor, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urlpath, err := canonicalizePath(fs, r.URL.Path) + urlpath, err := canonicalizePath(site.FS, r.URL.Path) if err != nil { handler.Error(w, r, err) return diff --git a/internal/handler/site/middleware/custom_domain.go b/internal/handler/site/middleware/custom_domain.go new file mode 100644 index 0000000..eb98225 --- /dev/null +++ b/internal/handler/site/middleware/custom_domain.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "net/http" + + "github.com/oursky/pageship/internal/site" +) + +func RedirectCustomDomain(site *site.Descriptor, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if site.Domain != "" && r.Host != site.Domain { + // Site with custom domain must be accessed through the custom domain. + url := *r.URL + url.Host = site.Domain + http.Redirect(w, r, url.String(), http.StatusMovedPermanently) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/site/middleware/index.go b/internal/handler/site/middleware/index.go index 1bbb31d..ed73102 100644 --- a/internal/handler/site/middleware/index.go +++ b/internal/handler/site/middleware/index.go @@ -6,11 +6,11 @@ import ( "github.com/oursky/pageship/internal/site" ) -func IndexPage(fs site.FS, next http.Handler) http.Handler { +func IndexPage(site *site.Descriptor, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { const indexPage = "index.html" - info, err := fs.Stat(r.URL.Path) + info, err := site.FS.Stat(r.URL.Path) if err != nil { http.Error(w, "internal server error", http.StatusInternalServerError) return diff --git a/internal/handler/site/middleware/middleware.go b/internal/handler/site/middleware/middleware.go index 653403b..99e87f9 100644 --- a/internal/handler/site/middleware/middleware.go +++ b/internal/handler/site/middleware/middleware.go @@ -3,6 +3,7 @@ package middleware import "github.com/oursky/pageship/internal/handler/site" var Default = []site.Middleware{ + RedirectCustomDomain, CanonicalizePath, RouteSPA, IndexPage, diff --git a/internal/handler/site/middleware/spa.go b/internal/handler/site/middleware/spa.go index 77508fb..537d153 100644 --- a/internal/handler/site/middleware/spa.go +++ b/internal/handler/site/middleware/spa.go @@ -10,11 +10,11 @@ import ( ) // RouteSPA routes non-existing files to nearest parent directory -func RouteSPA(fs site.FS, next http.Handler) http.Handler { +func RouteSPA(site *site.Descriptor, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { urlpath := r.URL.Path for { - _, err := fs.Stat(urlpath) + _, err := site.FS.Stat(urlpath) if os.IsNotExist(err) { if urlpath == "/" { // Reached root; stop diff --git a/internal/handler/site/site_handler.go b/internal/handler/site/site_handler.go index 3bc82c2..3a67dd5 100644 --- a/internal/handler/site/site_handler.go +++ b/internal/handler/site/site_handler.go @@ -26,7 +26,9 @@ func NewSiteHandler(desc *site.Descriptor, middlewares []Middleware) *SiteHandle publicFS: site.SubFS(desc.FS, path.Clean("/"+desc.Config.Public)), } - h.next = applyMiddleware(h.publicFS, middlewares, http.HandlerFunc(h.serveFile)) + publicDesc := *desc + publicDesc.FS = site.SubFS(desc.FS, path.Clean("/"+desc.Config.Public)) + h.next = applyMiddleware(&publicDesc, middlewares, http.HandlerFunc(h.serveFile)) return h } diff --git a/internal/site/db/resolver.go b/internal/site/db/resolver.go index 45dadff..d836348 100644 --- a/internal/site/db/resolver.go +++ b/internal/site/db/resolver.go @@ -68,7 +68,7 @@ func (r *Resolver) resolveDeployment( return deployment, siteName, nil } -func (h *Resolver) AllowAnyDomain() bool { return false } +func (h *Resolver) IsWildcard() bool { return false } func (r *Resolver) Resolve(ctx context.Context, matchedID string) (*site.Descriptor, error) { appID, siteName := r.HostIDScheme.Split(matchedID) @@ -103,6 +103,7 @@ func (r *Resolver) Resolve(ctx context.Context, matchedID string) (*site.Descrip desc := &site.Descriptor{ ID: id, Config: &config, + Domain: "", // FIXME: custom domain FS: newStorageFS(r.Storage, deployment), } diff --git a/internal/site/fs.go b/internal/site/fs.go index ab9c21c..082076a 100644 --- a/internal/site/fs.go +++ b/internal/site/fs.go @@ -24,6 +24,7 @@ type FS interface { type Descriptor struct { ID string + Domain string Config *config.SiteConfig FS FS } diff --git a/internal/site/local/resolver.go b/internal/site/local/resolver.go index 556240d..ea43e46 100644 --- a/internal/site/local/resolver.go +++ b/internal/site/local/resolver.go @@ -11,7 +11,7 @@ import ( "github.com/oursky/pageship/internal/site" ) -func NewMultiSiteResolver(fs fs.FS, defaultSite string, sites map[string]config.SitesConfigEntry) site.Resolver { +func NewResolver(fs fs.FS, defaultSite string, sites map[string]config.SitesConfigEntry) site.Resolver { if len(sites) == 0 { return &resolverAdhoc{fs: fs, defaultSite: defaultSite} } diff --git a/internal/site/local/resolver_adhoc.go b/internal/site/local/resolver_adhoc.go index 31f7848..e5bfcad 100644 --- a/internal/site/local/resolver_adhoc.go +++ b/internal/site/local/resolver_adhoc.go @@ -20,7 +20,7 @@ type resolverAdhoc struct { func (h *resolverAdhoc) Kind() string { return "ad-hoc" } -func (h *resolverAdhoc) AllowAnyDomain() bool { return false } +func (h *resolverAdhoc) IsWildcard() bool { return false } func (h *resolverAdhoc) Resolve(ctx context.Context, matchedID string) (*site.Descriptor, error) { if !site.CheckDefaultSite(&matchedID, h.defaultSite) { diff --git a/internal/site/local/resolver_single.go b/internal/site/local/resolver_single.go index 21c4306..e3ab1e2 100644 --- a/internal/site/local/resolver_single.go +++ b/internal/site/local/resolver_single.go @@ -18,7 +18,7 @@ func NewSingleSiteResolver(fs fs.FS) site.Resolver { func (h *resolverSingle) Kind() string { return "single site" } -func (h *resolverSingle) AllowAnyDomain() bool { return true } +func (h *resolverSingle) IsWildcard() bool { return true } func (h *resolverSingle) Resolve(ctx context.Context, matchedID string) (*site.Descriptor, error) { if err := checkSiteFS(h.fs); err != nil { diff --git a/internal/site/local/resolver_static.go b/internal/site/local/resolver_static.go index 5978a97..8b20fb5 100644 --- a/internal/site/local/resolver_static.go +++ b/internal/site/local/resolver_static.go @@ -17,7 +17,7 @@ type resolverStatic struct { func (h *resolverStatic) Kind() string { return "static config" } -func (h *resolverStatic) AllowAnyDomain() bool { return false } +func (h *resolverStatic) IsWildcard() bool { return false } func (h *resolverStatic) Resolve(ctx context.Context, matchedID string) (*site.Descriptor, error) { if !site.CheckDefaultSite(&matchedID, h.defaultSite) { @@ -45,6 +45,7 @@ func (h *resolverStatic) Resolve(ctx context.Context, matchedID string) (*site.D return &site.Descriptor{ ID: matchedID, + Domain: entry.Domain, Config: &config.Site, FS: siteFS{fs: fsys}, }, nil diff --git a/internal/site/resolver.go b/internal/site/resolver.go index 65ca4e1..9f2f69c 100644 --- a/internal/site/resolver.go +++ b/internal/site/resolver.go @@ -9,7 +9,8 @@ var ErrSiteNotFound = errors.New("site not found") type Resolver interface { Kind() string - AllowAnyDomain() bool + // IsWildcard indicates whether this resolver would always resolve successfully. + IsWildcard() bool Resolve(ctx context.Context, matchedID string) (*Descriptor, error) } From 89fe61c3253e64188ad405048a5389d337f61cb9 Mon Sep 17 00:00:00 2001 From: kiootic Date: Wed, 29 Nov 2023 10:56:00 +0800 Subject: [PATCH 2/6] Add custom domain in configuration --- examples/dev/pageship.toml | 4 ++++ internal/config/app.go | 1 + internal/config/app_domain.go | 6 ++++++ 3 files changed, 11 insertions(+) create mode 100644 internal/config/app_domain.go diff --git a/examples/dev/pageship.toml b/examples/dev/pageship.toml index 241cc3b..4f717dc 100644 --- a/examples/dev/pageship.toml +++ b/examples/dev/pageship.toml @@ -9,6 +9,10 @@ name = "main" [[app.sites]] name = "dev" +[[app.domains]] +domain="example.com:8001" +site="dev" + [site] public="public" diff --git a/internal/config/app.go b/internal/config/app.go index 0e8e8cc..d559e3a 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -12,6 +12,7 @@ type AppConfig struct { Sites []AppSiteConfig `json:"sites" pageship:"max=10,dive,required"` Deployments AppDeploymentsConfig `json:"deployments"` Team []*AccessRule `json:"team" pageship:"max=100,dive,required"` + Domains []AppDomainConfig `json:"domains" pageship:"max=10,unique=Domain,unique=Site,dive,required"` } func DefaultAppConfig() AppConfig { diff --git a/internal/config/app_domain.go b/internal/config/app_domain.go new file mode 100644 index 0000000..19eeb7f --- /dev/null +++ b/internal/config/app_domain.go @@ -0,0 +1,6 @@ +package config + +type AppDomainConfig struct { + Domain string `json:"domain" pageship:"required,max=200,hostname_port,lowercase"` + Site string `json:"site" pageship:"required,dnsLabel"` +} From 330f852e0a088c7d96926401e8a7be84e5cb735d Mon Sep 17 00:00:00 2001 From: kiootic Date: Wed, 29 Nov 2023 15:22:06 +0800 Subject: [PATCH 3/6] Add custom domain DB configuration logic --- cmd/pageship/app/apps.go | 12 ++ cmd/pageship/app/domains.go | 194 ++++++++++++++++++ internal/api/client.go | 74 +++++++ internal/api/models.go | 4 + internal/config/app.go | 11 + internal/db/db.go | 8 + internal/db/postgres/domains.go | 74 +++++++ internal/db/sqlite/domains.go | 74 +++++++ internal/handler/controller/app_config.go | 25 ++- internal/handler/controller/controller.go | 9 + internal/handler/controller/domain.go | 111 ++++++++++ internal/handler/controller/utils.go | 6 + internal/models/domain.go | 25 +++ internal/models/errors.go | 4 + .../postgres/000005_custom_domain.down.sql | 5 + .../postgres/000005_custom_domain.up.sql | 15 ++ .../sqlite/000005_custom_domain.down.sql | 1 + migrations/sqlite/000005_custom_domain.up.sql | 11 + 18 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 cmd/pageship/app/domains.go create mode 100644 internal/db/postgres/domains.go create mode 100644 internal/db/sqlite/domains.go create mode 100644 internal/handler/controller/domain.go create mode 100644 internal/models/domain.go create mode 100644 migrations/postgres/000005_custom_domain.down.sql create mode 100644 migrations/postgres/000005_custom_domain.up.sql create mode 100644 migrations/sqlite/000005_custom_domain.down.sql create mode 100644 migrations/sqlite/000005_custom_domain.up.sql diff --git a/cmd/pageship/app/apps.go b/cmd/pageship/app/apps.go index edf7f59..cdd8d95 100644 --- a/cmd/pageship/app/apps.go +++ b/cmd/pageship/app/apps.go @@ -140,11 +140,23 @@ var appsConfigureCmd = &cobra.Command{ return fmt.Errorf("failed to get app: %w", err) } + oldConfig := app.Config + app, err = API().ConfigureApp(cmd.Context(), app.ID, &conf.App) if err != nil { return fmt.Errorf("failed to configure app: %w", err) } + for _, dconf := range conf.App.Domains { + if _, exists := oldConfig.ResolveDomain(dconf.Domain); !exists { + Info("Activating custom domain %q...", dconf.Domain) + _, err = API().CreateDomain(cmd.Context(), app.ID, dconf.Domain, "") + if err != nil { + Warn("Activation of custom domain %q failed: %s", dconf.Domain, err) + } + } + } + Info("Configured app %q.", app.ID) return nil }, diff --git a/cmd/pageship/app/domains.go b/cmd/pageship/app/domains.go new file mode 100644 index 0000000..26f4269 --- /dev/null +++ b/cmd/pageship/app/domains.go @@ -0,0 +1,194 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "os" + "text/tabwriter" + "time" + + "github.com/manifoldco/promptui" + "github.com/oursky/pageship/internal/api" + "github.com/oursky/pageship/internal/models" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + rootCmd.AddCommand(domainsCmd) + domainsCmd.PersistentFlags().String("app", "", "app ID") + + domainsCmd.AddCommand(domainsActivateCmd) + domainsCmd.AddCommand(domainsDeactivateCmd) +} + +var domainsCmd = &cobra.Command{ + Use: "domains", + Short: "Manage custom domains", + RunE: func(cmd *cobra.Command, args []string) error { + appID := viper.GetString("app") + if appID == "" { + appID = tryLoadAppID() + } + if appID == "" { + return fmt.Errorf("app ID is not set") + } + + app, err := API().GetApp(cmd.Context(), appID) + if err != nil { + return fmt.Errorf("failed to get app: %w", err) + } + + type domainEntry struct { + name string + site string + model *api.APIDomain + } + domains := map[string]domainEntry{} + for _, dconf := range app.Config.Domains { + domains[dconf.Domain] = domainEntry{ + name: dconf.Domain, + site: dconf.Site, + model: nil, + } + } + + apiDomains, err := API().ListDomains(cmd.Context(), appID) + if err != nil { + return fmt.Errorf("failed to list domains: %w", err) + } + + for _, d := range apiDomains { + dd := d + domains[d.Domain.Domain] = domainEntry{ + name: d.Domain.Domain, + site: d.Domain.SiteName, + model: &dd, + } + } + + w := tabwriter.NewWriter(os.Stdout, 1, 4, 4, ' ', 0) + fmt.Fprintln(w, "NAME\tSITE\tCREATED AT\tSTATUS") + for _, domain := range domains { + createdAt := "-" + site := "-" + if domain.model != nil { + createdAt = domain.model.CreatedAt.Local().Format(time.DateTime) + site = fmt.Sprintf("%s/%s", domain.model.AppID, domain.model.SiteName) + } else { + site = fmt.Sprintf("%s/%s", app.ID, domain.site) + } + + var status string + switch { + case domain.model != nil && domain.model.AppID != app.ID: + status = "IN_USE" + case domain.model != nil && domain.model.AppID == app.ID: + status = "ACTIVE" + default: + status = "INACTIVE" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", domain.name, site, createdAt, status) + } + w.Flush() + return nil + }, +} + +func promptDomainReplaceApp(ctx context.Context, appID string, domainName string) (replaceApp string, err error) { + domains, err := API().ListDomains(ctx, appID) + if err != nil { + return "", fmt.Errorf("failed list domain: %w", err) + } + + appID = "" + for _, d := range domains { + if d.Domain.Domain == domainName { + appID = d.AppID + } + } + + if appID == "" { + return "", models.ErrDomainUsedName + } + + label := fmt.Sprintf("Domain %q is in use by app %q; activates the domain anyways", domainName, appID) + + prompt := promptui.Prompt{Label: label, IsConfirm: true} + _, err = prompt.Run() + if err != nil { + Info("Cancelled.") + return "", ErrCancelled + } + + return appID, nil +} + +var domainsActivateCmd = &cobra.Command{ + Use: "activate", + Short: "Activate domain for the app", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainName := args[0] + + appID := viper.GetString("app") + if appID == "" { + appID = tryLoadAppID() + } + if appID == "" { + return fmt.Errorf("app ID is not set") + } + + app, err := API().GetApp(cmd.Context(), appID) + if err != nil { + return fmt.Errorf("failed to get app: %w", err) + } + if _, ok := app.Config.ResolveDomain(domainName); !ok { + return fmt.Errorf("undefined domain") + } + + _, err = API().CreateDomain(cmd.Context(), appID, domainName, "") + if code, ok := api.ErrorStatusCode(err); ok && code == http.StatusConflict { + var replaceApp string + replaceApp, err = promptDomainReplaceApp(cmd.Context(), appID, domainName) + if err != nil { + return err + } + _, err = API().CreateDomain(cmd.Context(), appID, domainName, replaceApp) + } + + if err != nil { + return fmt.Errorf("failed to create domain: %w", err) + } + + Info("Domain %q activated.", domainName) + return nil + }, +} + +var domainsDeactivateCmd = &cobra.Command{ + Use: "deactivate", + Short: "Deactivate domain for the app", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainName := args[0] + + appID := viper.GetString("app") + if appID == "" { + appID = tryLoadAppID() + } + if appID == "" { + return fmt.Errorf("app ID is not set") + } + + _, err := API().DeleteDomain(cmd.Context(), appID, domainName) + if err != nil { + return fmt.Errorf("failed to delete domain: %w", err) + } + + Info("Domain %q deactivated.", domainName) + return nil + }, +} diff --git a/internal/api/client.go b/internal/api/client.go index 71704d3..daa0742 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -406,6 +406,80 @@ func (c *Client) UploadDeploymentTarball( return decodeJSONResponse[*models.Deployment](resp) } +func (c *Client) ListDomains(ctx context.Context, appID string) ([]APIDomain, error) { + endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "apps", appID, "domains") + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, err + } + if err := c.attachToken(req); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return decodeJSONResponse[[]APIDomain](resp) +} + +func (c *Client) CreateDomain(ctx context.Context, appID string, domainName string, replaceApp string) (*APIDomain, error) { + endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "apps", appID, "domains", domainName) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, nil) + if err != nil { + return nil, err + } + if replaceApp != "" { + req.URL.RawQuery = url.Values{ + "replaceApp": []string{replaceApp}, + }.Encode() + } + if err := c.attachToken(req); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return decodeJSONResponse[*APIDomain](resp) +} + +func (c *Client) DeleteDomain(ctx context.Context, appID string, domainName string) (*APIDomain, error) { + endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "apps", appID, "domains", domainName) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "DELETE", endpoint, nil) + if err != nil { + return nil, err + } + if err := c.attachToken(req); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return decodeJSONResponse[*APIDomain](resp) +} + func (c *Client) OpenAuthGitHubSSH(ctx context.Context) (*websocket.Conn, error) { endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "auth", "github-ssh") if err != nil { diff --git a/internal/api/models.go b/internal/api/models.go index 5ac4b0c..94111d7 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -21,6 +21,10 @@ type APIDeployment struct { URL *string `json:"url"` } +type APIDomain struct { + *models.Domain +} + type APIUser struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/config/app.go b/internal/config/app.go index d559e3a..3d0b277 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -48,6 +48,17 @@ func (c *AppConfig) SetDefaults() { } } +func (c *AppConfig) ResolveDomain(domain string) (resolved AppDomainConfig, ok bool) { + for _, d := range c.Domains { + if d.Domain == domain { + resolved = d + ok = true + return + } + } + return +} + func (c *AppConfig) ResolveSite(site string) (resolved AppSiteConfig, ok bool) { for _, s := range c.Sites { pattern, err := s.CompilePattern() diff --git a/internal/db/db.go b/internal/db/db.go index 0b512d5..fa8bb66 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -43,6 +43,7 @@ type DBQuery interface { AppsDB SitesDB DeploymentsDB + DomainsDB UserDB CertificateDB } @@ -74,6 +75,13 @@ type DeploymentsDB interface { DeleteExpiredDeployments(ctx context.Context, now time.Time, expireBefore time.Time) (int64, error) } +type DomainsDB interface { + CreateDomain(ctx context.Context, domain *models.Domain) error + GetDomainByName(ctx context.Context, domain string) (*models.Domain, error) + DeleteDomain(ctx context.Context, id string, now time.Time) error + ListDomains(ctx context.Context, appID string) ([]*models.Domain, error) +} + type UserDB interface { GetUser(ctx context.Context, id string) (*models.User, error) GetCredential(ctx context.Context, id models.CredentialID) (*models.UserCredential, error) diff --git a/internal/db/postgres/domains.go b/internal/db/postgres/domains.go new file mode 100644 index 0000000..6354ae2 --- /dev/null +++ b/internal/db/postgres/domains.go @@ -0,0 +1,74 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + "github.com/oursky/pageship/internal/models" +) + +func (q query[T]) CreateDomain(ctx context.Context, domain *models.Domain) error { + result, err := sqlx.NamedExecContext(ctx, q.ext, ` + INSERT INTO domain_association (id, created_at, updated_at, deleted_at, domain, app_id, site_name) + VALUES (:id, :created_at, :updated_at, :deleted_at, :domain, :app_id, :site_name) + ON CONFLICT (domain) WHERE deleted_at IS NULL DO NOTHING + `, domain) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return models.ErrDomainUsedName + } + + return nil +} + +func (q query[T]) GetDomainByName(ctx context.Context, domainName string) (*models.Domain, error) { + var domain models.Domain + + err := sqlx.GetContext(ctx, q.ext, &domain, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + JOIN app a ON (a.id = d.app_id AND a.deleted_at IS NULL) + WHERE d.domain = $1 AND d.deleted_at IS NULL + `, domainName) + if errors.Is(err, sql.ErrNoRows) { + return nil, models.ErrDomainNotFound + } else if err != nil { + return nil, err + } + + return &domain, nil +} + +func (q query[T]) DeleteDomain(ctx context.Context, id string, now time.Time) error { + _, err := q.ext.ExecContext(ctx, ` + UPDATE domain_association SET deleted_at = $1 WHERE id = $2 + `, now, id) + if err != nil { + return err + } + + return nil +} + +func (q query[T]) ListDomains(ctx context.Context, appID string) ([]*models.Domain, error) { + var domains []*models.Domain + err := sqlx.SelectContext(ctx, q.ext, &domains, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + WHERE d.app_id = $1 AND d.deleted_at IS NULL + ORDER BY d.domain, d.created_at + `, appID) + if err != nil { + return nil, err + } + + return domains, nil +} diff --git a/internal/db/sqlite/domains.go b/internal/db/sqlite/domains.go new file mode 100644 index 0000000..54e63a0 --- /dev/null +++ b/internal/db/sqlite/domains.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + "github.com/oursky/pageship/internal/models" +) + +func (q query[T]) CreateDomain(ctx context.Context, domain *models.Domain) error { + result, err := sqlx.NamedExecContext(ctx, q.ext, ` + INSERT INTO domain_association (id, created_at, updated_at, deleted_at, domain, app_id, site_name) + VALUES (:id, :created_at, :updated_at, :deleted_at, :domain, :app_id, :site_name) + ON CONFLICT (domain) WHERE deleted_at IS NULL DO NOTHING + `, domain) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return models.ErrDomainUsedName + } + + return nil +} + +func (q query[T]) GetDomainByName(ctx context.Context, domainName string) (*models.Domain, error) { + var domain models.Domain + + err := sqlx.GetContext(ctx, q.ext, &domain, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + JOIN app a ON (a.id = d.app_id AND a.deleted_at IS NULL) + WHERE d.domain = ? AND d.deleted_at IS NULL + `, domainName) + if errors.Is(err, sql.ErrNoRows) { + return nil, models.ErrDomainNotFound + } else if err != nil { + return nil, err + } + + return &domain, nil +} + +func (q query[T]) DeleteDomain(ctx context.Context, id string, now time.Time) error { + _, err := q.ext.ExecContext(ctx, ` + UPDATE domain_association SET deleted_at = ? WHERE id = ? + `, now, id) + if err != nil { + return err + } + + return nil +} + +func (q query[T]) ListDomains(ctx context.Context, appID string) ([]*models.Domain, error) { + var domains []*models.Domain + err := sqlx.SelectContext(ctx, q.ext, &domains, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + WHERE d.app_id = ? AND d.deleted_at IS NULL + ORDER BY d.domain, d.created_at + `, appID) + if err != nil { + return nil, err + } + + return domains, nil +} diff --git a/internal/handler/controller/app_config.go b/internal/handler/controller/app_config.go index 76c8c6d..14ab455 100644 --- a/internal/handler/controller/app_config.go +++ b/internal/handler/controller/app_config.go @@ -1,11 +1,13 @@ package controller import ( + "fmt" "net/http" "github.com/oursky/pageship/internal/config" "github.com/oursky/pageship/internal/db" "github.com/oursky/pageship/internal/models" + "go.uber.org/zap" ) func (c *Controller) handleAppConfigGet(w http.ResponseWriter, r *http.Request) { @@ -29,15 +31,34 @@ func (c *Controller) handleAppConfigSet(w http.ResponseWriter, r *http.Request) respond(w, withTx(r.Context(), c.DB, func(tx db.Tx) (any, error) { app.Config = request.Config - app.UpdatedAt = c.Clock.Now().UTC() + now := c.Clock.Now().UTC() + app.UpdatedAt = now - err := c.DB.UpdateAppConfig(r.Context(), app) + err := tx.UpdateAppConfig(r.Context(), app) if err != nil { return nil, err } log(r).Info("updating config") + // Deactivated removed domains; added domains need manual activation. + domains, err := tx.ListDomains(r.Context(), app.ID) + if err != nil { + return nil, err + } + for _, d := range domains { + if _, exists := app.Config.ResolveDomain(d.Domain); exists { + continue + } + + err = tx.DeleteDomain(r.Context(), d.ID, now) + if err != nil { + return nil, fmt.Errorf("failed to deactivate domain: %w", err) + } + + log(r).Info("deleting domain", zap.String("domain", d.Domain)) + } + return app.Config, nil })) } diff --git a/internal/handler/controller/controller.go b/internal/handler/controller/controller.go index dc9f8c6..cb84cba 100644 --- a/internal/handler/controller/controller.go +++ b/internal/handler/controller/controller.go @@ -88,6 +88,15 @@ func (c *Controller) Handler() http.Handler { r.With(c.requireAccessDeployer()).Put("/tarball", c.handleDeploymentUpload) }) }) + + r.Route("/domains", func(r chi.Router) { + r.Get("/", c.handleDomainList) + + r.With(c.requireAccessAdmin()).Route("/{domain-name}", func(r chi.Router) { + r.With(c.requireAccessDeployer()).Post("/", c.handleDomainCreate) + r.With(c.requireAccessDeployer()).Delete("/", c.handleDomainDelete) + }) + }) }) }) diff --git a/internal/handler/controller/domain.go b/internal/handler/controller/domain.go new file mode 100644 index 0000000..fcef463 --- /dev/null +++ b/internal/handler/controller/domain.go @@ -0,0 +1,111 @@ +package controller + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oursky/pageship/internal/db" + "github.com/oursky/pageship/internal/models" + "go.uber.org/zap" +) + +type apiDomain struct { + *models.Domain +} + +func (c *Controller) makeAPIDomain(domain *models.Domain) *apiDomain { + return &apiDomain{Domain: domain} +} + +func (c *Controller) handleDomainList(w http.ResponseWriter, r *http.Request) { + app := get[*models.App](r) + + respond(w, func() (any, error) { + var domains []*models.Domain + for _, dconf := range app.Config.Domains { + domain, err := c.DB.GetDomainByName(r.Context(), dconf.Domain) + if errors.Is(err, models.ErrDomainNotFound) { + continue + } else if err != nil { + return nil, err + } + domains = append(domains, domain) + } + + return mapModels(domains, func(d *models.Domain) *apiDomain { + return c.makeAPIDomain(d) + }), nil + }) +} + +func (c *Controller) handleDomainCreate(w http.ResponseWriter, r *http.Request) { + app := get[*models.App](r) + + domainName := chi.URLParam(r, "domain-name") + replaceApp := r.URL.Query().Get("replaceApp") + + respond(w, withTx(r.Context(), c.DB, func(tx db.Tx) (any, error) { + config, ok := app.Config.ResolveDomain(domainName) + if !ok { + return nil, models.ErrUndefinedDomain + } + + domain, err := tx.GetDomainByName(r.Context(), domainName) + if errors.Is(err, models.ErrDomainNotFound) { + // Continue create new domain. + } else if err != nil { + return nil, err + } else { + if domain.AppID == app.ID { + return c.makeAPIDomain(domain), nil + } else if replaceApp != domain.AppID { + return nil, models.ErrDomainUsedName + } + + err = tx.DeleteDomain(r.Context(), domain.ID, c.Clock.Now().UTC()) + if err != nil { + return nil, err + } + } + + domain = models.NewDomain(c.Clock.Now().UTC(), domainName, app.ID, config.Site) + err = tx.CreateDomain(r.Context(), domain) + if err != nil { + return nil, err + } + + log(r).Info("creating domain", + zap.String("domain", domain.Domain), + zap.String("site", domain.SiteName)) + + return c.makeAPIDomain(domain), nil + })) +} + +func (c *Controller) handleDomainDelete(w http.ResponseWriter, r *http.Request) { + app := get[*models.App](r) + + domainName := chi.URLParam(r, "domain-name") + + respond(w, withTx(r.Context(), c.DB, func(tx db.Tx) (any, error) { + domain, err := tx.GetDomainByName(r.Context(), domainName) + if err != nil { + return nil, err + } + if domain.AppID != app.ID { + return nil, models.ErrAccessDenied + } + + err = tx.DeleteDomain(r.Context(), domain.ID, c.Clock.Now().UTC()) + if err != nil { + return nil, err + } + + log(r).Info("deleting domain", + zap.String("domain", domain.Domain), + zap.String("site", domain.SiteName)) + + return struct{}{}, nil + })) +} diff --git a/internal/handler/controller/utils.go b/internal/handler/controller/utils.go index 5b92a1d..9270653 100644 --- a/internal/handler/controller/utils.go +++ b/internal/handler/controller/utils.go @@ -101,6 +101,12 @@ func writeResponse(w http.ResponseWriter, result any, err error) { writeJSON(w, http.StatusBadRequest, response{Error: err}) case errors.Is(err, models.ErrDeploymentExpired): writeJSON(w, http.StatusBadRequest, response{Error: err}) + case errors.Is(err, models.ErrUndefinedDomain): + writeJSON(w, http.StatusBadRequest, response{Error: err}) + case errors.Is(err, models.ErrDomainNotFound): + writeJSON(w, http.StatusNotFound, response{Error: err}) + case errors.Is(err, models.ErrDomainUsedName): + writeJSON(w, http.StatusConflict, response{Error: err}) case errors.Is(err, models.ErrUserNotFound): writeJSON(w, http.StatusNotFound, response{Error: err}) case errors.Is(err, models.ErrAccessDenied): diff --git a/internal/models/domain.go b/internal/models/domain.go new file mode 100644 index 0000000..105cfbf --- /dev/null +++ b/internal/models/domain.go @@ -0,0 +1,25 @@ +package models + +import "time" + +type Domain struct { + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt" db:"deleted_at"` + Domain string `json:"domain" db:"domain"` + AppID string `json:"appID" db:"app_id"` + SiteName string `json:"siteName" db:"site_name"` +} + +func NewDomain(now time.Time, domain string, appID string, siteName string) *Domain { + return &Domain{ + ID: newID("domain"), + CreatedAt: now, + UpdatedAt: now, + DeletedAt: nil, + Domain: domain, + AppID: appID, + SiteName: siteName, + } +} diff --git a/internal/models/errors.go b/internal/models/errors.go index 9ce1d01..f6bf796 100644 --- a/internal/models/errors.go +++ b/internal/models/errors.go @@ -14,6 +14,10 @@ var ErrDeploymentNotUploaded = errors.New("deployment is not uploaded") var ErrDeploymentAlreadyUploaded = errors.New("deployment is already uploaded") var ErrDeploymentExpired = errors.New("deployment expired") +var ErrUndefinedDomain = errors.New("undefined domain") +var ErrDomainNotFound = errors.New("domain not found") +var ErrDomainUsedName = errors.New("used domain name") + var ErrUserNotFound = errors.New("user not found") var ErrAccessDenied = errors.New("access denied") var ErrInvalidCredentials = errors.New("invalid credentials") diff --git a/migrations/postgres/000005_custom_domain.down.sql b/migrations/postgres/000005_custom_domain.down.sql new file mode 100644 index 0000000..3571011 --- /dev/null +++ b/migrations/postgres/000005_custom_domain.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE domain_association; + +COMMIT; diff --git a/migrations/postgres/000005_custom_domain.up.sql b/migrations/postgres/000005_custom_domain.up.sql new file mode 100644 index 0000000..f294a08 --- /dev/null +++ b/migrations/postgres/000005_custom_domain.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +CREATE TABLE domain_association ( + id TEXT NOT NULL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + domain TEXT NOT NULL, + app_id TEXT NOT NULL REFERENCES app(id), + site_name TEXT NOT NULL +); +CREATE UNIQUE INDEX domain_name ON domain_association(domain) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX domain_mapping ON domain_association(app_id, site_name) WHERE deleted_at IS NULL; + +COMMIT; diff --git a/migrations/sqlite/000005_custom_domain.down.sql b/migrations/sqlite/000005_custom_domain.down.sql new file mode 100644 index 0000000..13298a0 --- /dev/null +++ b/migrations/sqlite/000005_custom_domain.down.sql @@ -0,0 +1 @@ +DROP TABLE domain_association; diff --git a/migrations/sqlite/000005_custom_domain.up.sql b/migrations/sqlite/000005_custom_domain.up.sql new file mode 100644 index 0000000..f9cbe6b --- /dev/null +++ b/migrations/sqlite/000005_custom_domain.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE domain_association ( + id TEXT NOT NULL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + domain TEXT NOT NULL, + app_id TEXT NOT NULL REFERENCES app(id), + site_name TEXT NOT NULL +); +CREATE UNIQUE INDEX domain_name ON domain_association(domain) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX domain_mapping ON domain_association(app_id, site_name) WHERE deleted_at IS NULL; From 04498ea7f48192001a62fdef6c76973c39010cce Mon Sep 17 00:00:00 2001 From: kiootic Date: Wed, 29 Nov 2023 16:06:43 +0800 Subject: [PATCH 4/6] Add custom domain resolution logic --- cmd/controller/app/start.go | 7 ++++-- internal/db/db.go | 1 + internal/db/postgres/domains.go | 17 +++++++++++++++ internal/db/sqlite/domains.go | 17 +++++++++++++++ internal/domain/db/resolver.go | 38 +++++++++++++++++++++++++++++++++ internal/site/db/resolver.go | 14 +++++++++++- 6 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 internal/domain/db/resolver.go diff --git a/cmd/controller/app/start.go b/cmd/controller/app/start.go index 357f6d1..fe30599 100644 --- a/cmd/controller/app/start.go +++ b/cmd/controller/app/start.go @@ -14,7 +14,7 @@ import ( "github.com/oursky/pageship/internal/db" _ "github.com/oursky/pageship/internal/db/postgres" _ "github.com/oursky/pageship/internal/db/sqlite" - "github.com/oursky/pageship/internal/domain" + domaindb "github.com/oursky/pageship/internal/domain/db" "github.com/oursky/pageship/internal/handler/controller" "github.com/oursky/pageship/internal/handler/site" "github.com/oursky/pageship/internal/handler/site/middleware" @@ -129,7 +129,10 @@ func (s *setup) checkDomain(name string) error { } func (s *setup) sites(conf StartSitesConfig) error { - domainResolver := &domain.ResolverNull{} // FIXME: custom domain + domainResolver := &domaindb.Resolver{ + HostIDScheme: conf.HostIDScheme, + DB: s.database, + } siteResolver := &sitedb.Resolver{ HostIDScheme: conf.HostIDScheme, DB: s.database, diff --git a/internal/db/db.go b/internal/db/db.go index fa8bb66..452c772 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -78,6 +78,7 @@ type DeploymentsDB interface { type DomainsDB interface { CreateDomain(ctx context.Context, domain *models.Domain) error GetDomainByName(ctx context.Context, domain string) (*models.Domain, error) + GetDomainBySite(ctx context.Context, appID string, siteName string) (*models.Domain, error) DeleteDomain(ctx context.Context, id string, now time.Time) error ListDomains(ctx context.Context, appID string) ([]*models.Domain, error) } diff --git a/internal/db/postgres/domains.go b/internal/db/postgres/domains.go index 6354ae2..53eadd5 100644 --- a/internal/db/postgres/domains.go +++ b/internal/db/postgres/domains.go @@ -48,6 +48,23 @@ func (q query[T]) GetDomainByName(ctx context.Context, domainName string) (*mode return &domain, nil } +func (q query[T]) GetDomainBySite(ctx context.Context, appID string, siteName string) (*models.Domain, error) { + var domain models.Domain + + err := sqlx.GetContext(ctx, q.ext, &domain, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + JOIN app a ON (a.id = d.app_id AND a.deleted_at IS NULL) + WHERE d.app_id = $1 AND d.site_name = $2 AND d.deleted_at IS NULL + `, appID, siteName) + if errors.Is(err, sql.ErrNoRows) { + return nil, models.ErrDomainNotFound + } else if err != nil { + return nil, err + } + + return &domain, nil +} + func (q query[T]) DeleteDomain(ctx context.Context, id string, now time.Time) error { _, err := q.ext.ExecContext(ctx, ` UPDATE domain_association SET deleted_at = $1 WHERE id = $2 diff --git a/internal/db/sqlite/domains.go b/internal/db/sqlite/domains.go index 54e63a0..eb3800b 100644 --- a/internal/db/sqlite/domains.go +++ b/internal/db/sqlite/domains.go @@ -48,6 +48,23 @@ func (q query[T]) GetDomainByName(ctx context.Context, domainName string) (*mode return &domain, nil } +func (q query[T]) GetDomainBySite(ctx context.Context, appID string, siteName string) (*models.Domain, error) { + var domain models.Domain + + err := sqlx.GetContext(ctx, q.ext, &domain, ` + SELECT d.id, d.created_at, d.updated_at, d.deleted_at, d.domain, d.app_id, d.site_name FROM domain_association d + JOIN app a ON (a.id = d.app_id AND a.deleted_at IS NULL) + WHERE d.app_id = ? AND d.site_name = ? AND d.deleted_at IS NULL + `, appID, siteName) + if errors.Is(err, sql.ErrNoRows) { + return nil, models.ErrDomainNotFound + } else if err != nil { + return nil, err + } + + return &domain, nil +} + func (q query[T]) DeleteDomain(ctx context.Context, id string, now time.Time) error { _, err := q.ext.ExecContext(ctx, ` UPDATE domain_association SET deleted_at = ? WHERE id = ? diff --git a/internal/domain/db/resolver.go b/internal/domain/db/resolver.go new file mode 100644 index 0000000..d03247c --- /dev/null +++ b/internal/domain/db/resolver.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "errors" + + "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/db" + "github.com/oursky/pageship/internal/domain" + "github.com/oursky/pageship/internal/models" +) + +type Resolver struct { + DB db.DB + HostIDScheme config.HostIDScheme +} + +func (r *Resolver) Kind() string { return "database" } + +func (r *Resolver) Resolve(ctx context.Context, hostname string) (string, error) { + dom, err := r.DB.GetDomainByName(ctx, hostname) + if errors.Is(err, models.ErrDomainNotFound) { + return "", domain.ErrDomainNotFound + } + + app, err := r.DB.GetApp(ctx, dom.AppID) + if errors.Is(err, models.ErrAppNotFound) { + return "", domain.ErrDomainNotFound + } + + sub := dom.SiteName + if dom.SiteName == app.Config.DefaultSite { + sub = "" + } + id := r.HostIDScheme.Make(dom.AppID, sub) + + return id, nil +} diff --git a/internal/site/db/resolver.go b/internal/site/db/resolver.go index d836348..948ee0c 100644 --- a/internal/site/db/resolver.go +++ b/internal/site/db/resolver.go @@ -99,11 +99,23 @@ func (r *Resolver) Resolve(ctx context.Context, matchedID string) (*site.Descrip config.Access = app.Config.Deployments.Access } + domain, err := r.DB.GetDomainBySite(ctx, app.ID, siteName) + if errors.Is(err, models.ErrDomainNotFound) { + domain = nil + } else if err != nil { + return nil, err + } + + domainName := "" + if domain != nil { + domainName = domain.Domain + } + id := strings.Join([]string{deployment.AppID, siteName, deployment.ID}, "/") desc := &site.Descriptor{ ID: id, Config: &config, - Domain: "", // FIXME: custom domain + Domain: domainName, FS: newStorageFS(r.Storage, deployment), } From b01fdebdfa7b08f2619080864f03e94418461345 Mon Sep 17 00:00:00 2001 From: kiootic Date: Wed, 29 Nov 2023 18:07:32 +0800 Subject: [PATCH 5/6] Add custom domain message config --- .env.example | 1 + cmd/controller/app/start.go | 21 ++++++++++++++------- cmd/pageship/app/domains.go | 11 +++++++++++ internal/api/client.go | 23 +++++++++++++++++++++++ internal/api/models.go | 5 +++++ internal/handler/controller/config.go | 3 +++ internal/handler/controller/controller.go | 2 ++ internal/handler/controller/manifest.go | 17 +++++++++++++++++ 8 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 internal/handler/controller/manifest.go diff --git a/.env.example b/.env.example index 74a6744..3f08637 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,4 @@ PAGESHIP_TOKEN_AUTHORITY=http://api.localtest.me:8001 PAGESHIP_CLEANUP_EXPIRED_CRONTAB=* * * * * # PAGESHIP_HOST_ID_SCHEME=suffix +# PAGESHIP_CUSTOM_DOMAIN_MESSAGE= diff --git a/cmd/controller/app/start.go b/cmd/controller/app/start.go index fe30599..eb26303 100644 --- a/cmd/controller/app/start.go +++ b/cmd/controller/app/start.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/carlmjohnson/versioninfo" "github.com/dustin/go-humanize" "github.com/oursky/pageship/internal/command" "github.com/oursky/pageship/internal/config" @@ -59,6 +60,8 @@ func init() { startCmd.PersistentFlags().String("token-authority", "pageship", "auth token authority") startCmd.PersistentFlags().String("token-signing-key", "", "auth token signing key") + startCmd.PersistentFlags().String("custom-domain-message", "", "message for custom domain users") + startCmd.PersistentFlags().String("cleanup-expired-crontab", "", "cleanup expired schedule") startCmd.PersistentFlags().Duration("keep-after-expired", time.Hour*24, "keep-after-expired") @@ -101,6 +104,8 @@ type StartControllerConfig struct { TokenAuthority string `mapstructure:"token-authority"` ReservedApps []string `mapstructure:"reserved-apps"` APIACLFile string `mapstructure:"api-acl" validate:"omitempty,filepath"` + + CustomDomainMessage string `mapstructure:"custom-domain-message"` } type StartCronConfig struct { @@ -171,13 +176,15 @@ func (s *setup) controller(domain string, conf StartControllerConfig, sitesConf } controllerConf := controller.Config{ - MaxDeploymentSize: int64(maxDeploymentSize), - StorageKeyPrefix: conf.StorageKeyPrefix, - HostIDScheme: sitesConf.HostIDScheme, - HostPattern: config.NewHostPattern(sitesConf.HostPattern), - ReservedApps: reservedApps, - TokenSigningKey: []byte(tokenSigningKey), - TokenAuthority: conf.TokenAuthority, + MaxDeploymentSize: int64(maxDeploymentSize), + StorageKeyPrefix: conf.StorageKeyPrefix, + HostIDScheme: sitesConf.HostIDScheme, + HostPattern: config.NewHostPattern(sitesConf.HostPattern), + ReservedApps: reservedApps, + TokenSigningKey: []byte(tokenSigningKey), + TokenAuthority: conf.TokenAuthority, + ServerVersion: versioninfo.Short(), + CustomDomainMessage: conf.CustomDomainMessage, } if conf.APIACLFile != "" { diff --git a/cmd/pageship/app/domains.go b/cmd/pageship/app/domains.go index 26f4269..07630aa 100644 --- a/cmd/pageship/app/domains.go +++ b/cmd/pageship/app/domains.go @@ -35,6 +35,11 @@ var domainsCmd = &cobra.Command{ return fmt.Errorf("app ID is not set") } + manifest, err := API().GetManifest(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to get manifest: %w", err) + } + app, err := API().GetApp(cmd.Context(), appID) if err != nil { return fmt.Errorf("failed to get app: %w", err) @@ -93,6 +98,12 @@ var domainsCmd = &cobra.Command{ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", domain.name, site, createdAt, status) } w.Flush() + + if manifest.CustomDomainMessage != "" { + os.Stdout.WriteString("\n") + Info(manifest.CustomDomainMessage) + } + return nil }, } diff --git a/internal/api/client.go b/internal/api/client.go index daa0742..91996a9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -46,6 +46,29 @@ func (c *Client) attachToken(r *http.Request) error { return nil } +func (c *Client) GetManifest(ctx context.Context) (*APIManifest, error) { + endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "manifest") + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, err + } + if err := c.attachToken(req); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return decodeJSONResponse[*APIManifest](resp) +} + func (c *Client) CreateApp(ctx context.Context, appID string) (*APIApp, error) { endpoint, err := url.JoinPath(c.endpoint, "api", "v1", "apps") if err != nil { diff --git a/internal/api/models.go b/internal/api/models.go index 94111d7..dd9998f 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -4,6 +4,11 @@ import ( "github.com/oursky/pageship/internal/models" ) +type APIManifest struct { + Version string `json:"version"` + CustomDomainMessage string `json:"customDomainMessage,omitempty"` +} + type APIApp struct { *models.App URL string `json:"url"` diff --git a/internal/handler/controller/config.go b/internal/handler/controller/config.go index 8c4b1af..cb0e0a1 100644 --- a/internal/handler/controller/config.go +++ b/internal/handler/controller/config.go @@ -14,4 +14,7 @@ type Config struct { TokenAuthority string TokenSigningKey []byte ACL *watch.File[config.ACL] + + ServerVersion string + CustomDomainMessage string } diff --git a/internal/handler/controller/controller.go b/internal/handler/controller/controller.go index cb84cba..4eeb852 100644 --- a/internal/handler/controller/controller.go +++ b/internal/handler/controller/controller.go @@ -100,6 +100,8 @@ func (c *Controller) Handler() http.Handler { }) }) + r.With(requireAuth).Get("/manifest", c.handleManifest) + r.With(requireAuth).Get("/auth/me", c.handleMe) r.Get("/auth/github-ssh", c.handleAuthGithubSSH) r.Post("/auth/github-oidc", c.handleAuthGithubOIDC) diff --git a/internal/handler/controller/manifest.go b/internal/handler/controller/manifest.go new file mode 100644 index 0000000..1362e8c --- /dev/null +++ b/internal/handler/controller/manifest.go @@ -0,0 +1,17 @@ +package controller + +import ( + "net/http" +) + +type apiManifest struct { + Version string `json:"version"` + CustomDomainMessage string `json:"customDomainMessage,omitempty"` +} + +func (c *Controller) handleManifest(w http.ResponseWriter, r *http.Request) { + writeResponse(w, &apiManifest{ + Version: c.Config.ServerVersion, + CustomDomainMessage: c.Config.CustomDomainMessage, + }, nil) +} From b39927bc68ae1836b37c9db5edd2ea97bacdb16b Mon Sep 17 00:00:00 2001 From: kiootic Date: Wed, 29 Nov 2023 18:16:29 +0800 Subject: [PATCH 6/6] Update docs on custom domain --- docs/SUMMARY.md | 1 + docs/guides/features/custom-domain.md | 32 +++++++++++++++++++++++++++ docs/references/configuration-file.md | 5 ++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docs/guides/features/custom-domain.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 06a1b4f..ab12945 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ - [Automatic TLS](guides/features/automatic-tls.md) - [GitHub Actions Integration](guides/features/github-actions-integration.md) - [Access Control](guides/features/access-control.md) + - [Custom Domain](guides/features/custom-domain.md) # References diff --git a/docs/guides/features/custom-domain.md b/docs/guides/features/custom-domain.md new file mode 100644 index 0000000..76223df --- /dev/null +++ b/docs/guides/features/custom-domain.md @@ -0,0 +1,32 @@ +# Custom Domains + +Pageship supports custom domains for serving pages for an app. We assume a +cooperative model for custom domain association, so domain ownership +verification is not required. + +To enable custom domain, configure `pageship.toml` and specify the site to serve +from the domain: + +```toml +# 2 sites for the app: 'main' & 'dev' +[[app.sites]] +name = "main" + +[[app.sites]] +name = "dev" + +# For 'main' site, serve it at 'example.com'. Traffic to default domain is +# redirected to the configured domain automatically. +[[app.domains]] +domain="example.com" +site="main" +``` + +If the domain name is already in-use by other apps, the custom domain would not +be activated automatically when first added to the configuration. It can be +activated/deactivated manually using `pageship domains activate `/ +`pageship domains deactivate ` command. + +Custom domains of the app can be listed with `pageship domains` command. +Additional setup instruction (e.g. DNS setup) would be shown if provided by +server operator. diff --git a/docs/references/configuration-file.md b/docs/references/configuration-file.md index e8a8e81..2e9b98b 100644 --- a/docs/references/configuration-file.md +++ b/docs/references/configuration-file.md @@ -24,7 +24,10 @@ server. subdomain. - `app.deployments`: Configuration for preview deployments - `access`: ACL rules controlling access of preview deployments. - - `ttl`: the lifetime of a preview deployment (default to `24h`)`` + - `ttl`: the lifetime of a preview deployment (default to `24h`) +- `app.domains`: Configuration for custom domains + - `domain`: The custom domain to use + - `site`: The site name associated the custom domain ### `site` section