Skip to content

Commit

Permalink
Implement custom domain
Browse files Browse the repository at this point in the history
  • Loading branch information
kiootic committed Nov 29, 2023
2 parents 687817e + b39927b commit 72c4c6d
Show file tree
Hide file tree
Showing 50 changed files with 1,068 additions and 66 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
31 changes: 22 additions & 9 deletions cmd/controller/app/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"os"
"time"

"github.com/carlmjohnson/versioninfo"
"github.com/dustin/go-humanize"
"github.com/oursky/pageship/internal/command"
"github.com/oursky/pageship/internal/config"
"github.com/oursky/pageship/internal/cron"
"github.com/oursky/pageship/internal/db"
_ "github.com/oursky/pageship/internal/db/postgres"
_ "github.com/oursky/pageship/internal/db/sqlite"
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"
Expand Down Expand Up @@ -58,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")

Expand Down Expand Up @@ -100,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 {
Expand Down Expand Up @@ -128,15 +134,20 @@ func (s *setup) checkDomain(name string) error {
}

func (s *setup) sites(conf StartSitesConfig) error {
resolver := &sitedb.Resolver{
domainResolver := &domaindb.Resolver{
HostIDScheme: conf.HostIDScheme,
DB: s.database,
}
siteResolver := &sitedb.Resolver{
HostIDScheme: conf.HostIDScheme,
DB: s.database,
Storage: s.storage,
}
handler, err := site.NewHandler(
s.ctx,
logger.Named("site"),
resolver,
domainResolver,
siteResolver,
site.HandlerConfig{
HostPattern: conf.HostPattern,
Middlewares: middleware.Default,
Expand Down Expand Up @@ -165,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 != "" {
Expand Down
12 changes: 12 additions & 0 deletions cmd/pageship/app/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
205 changes: 205 additions & 0 deletions cmd/pageship/app/domains.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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")
}

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)
}

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()

if manifest.CustomDomainMessage != "" {
os.Stdout.WriteString("\n")
Info(manifest.CustomDomainMessage)
}

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
},
}
32 changes: 21 additions & 11 deletions cmd/pageship/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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

Expand All @@ -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
}
Expand Down Expand Up @@ -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")
}
}
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading

0 comments on commit 72c4c6d

Please sign in to comment.