Skip to content

Commit 72c4c6d

Browse files
committed
Implement custom domain
2 parents 687817e + b39927b commit 72c4c6d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1068
-66
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ PAGESHIP_TOKEN_AUTHORITY=http://api.localtest.me:8001
1515
PAGESHIP_CLEANUP_EXPIRED_CRONTAB=* * * * *
1616
# PAGESHIP_HOST_ID_SCHEME=suffix
1717

18+
# PAGESHIP_CUSTOM_DOMAIN_MESSAGE=

cmd/controller/app/start.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import (
77
"os"
88
"time"
99

10+
"github.com/carlmjohnson/versioninfo"
1011
"github.com/dustin/go-humanize"
1112
"github.com/oursky/pageship/internal/command"
1213
"github.com/oursky/pageship/internal/config"
1314
"github.com/oursky/pageship/internal/cron"
1415
"github.com/oursky/pageship/internal/db"
1516
_ "github.com/oursky/pageship/internal/db/postgres"
1617
_ "github.com/oursky/pageship/internal/db/sqlite"
18+
domaindb "github.com/oursky/pageship/internal/domain/db"
1719
"github.com/oursky/pageship/internal/handler/controller"
1820
"github.com/oursky/pageship/internal/handler/site"
1921
"github.com/oursky/pageship/internal/handler/site/middleware"
@@ -58,6 +60,8 @@ func init() {
5860
startCmd.PersistentFlags().String("token-authority", "pageship", "auth token authority")
5961
startCmd.PersistentFlags().String("token-signing-key", "", "auth token signing key")
6062

63+
startCmd.PersistentFlags().String("custom-domain-message", "", "message for custom domain users")
64+
6165
startCmd.PersistentFlags().String("cleanup-expired-crontab", "", "cleanup expired schedule")
6266
startCmd.PersistentFlags().Duration("keep-after-expired", time.Hour*24, "keep-after-expired")
6367

@@ -100,6 +104,8 @@ type StartControllerConfig struct {
100104
TokenAuthority string `mapstructure:"token-authority"`
101105
ReservedApps []string `mapstructure:"reserved-apps"`
102106
APIACLFile string `mapstructure:"api-acl" validate:"omitempty,filepath"`
107+
108+
CustomDomainMessage string `mapstructure:"custom-domain-message"`
103109
}
104110

105111
type StartCronConfig struct {
@@ -128,15 +134,20 @@ func (s *setup) checkDomain(name string) error {
128134
}
129135

130136
func (s *setup) sites(conf StartSitesConfig) error {
131-
resolver := &sitedb.Resolver{
137+
domainResolver := &domaindb.Resolver{
138+
HostIDScheme: conf.HostIDScheme,
139+
DB: s.database,
140+
}
141+
siteResolver := &sitedb.Resolver{
132142
HostIDScheme: conf.HostIDScheme,
133143
DB: s.database,
134144
Storage: s.storage,
135145
}
136146
handler, err := site.NewHandler(
137147
s.ctx,
138148
logger.Named("site"),
139-
resolver,
149+
domainResolver,
150+
siteResolver,
140151
site.HandlerConfig{
141152
HostPattern: conf.HostPattern,
142153
Middlewares: middleware.Default,
@@ -165,13 +176,15 @@ func (s *setup) controller(domain string, conf StartControllerConfig, sitesConf
165176
}
166177

167178
controllerConf := controller.Config{
168-
MaxDeploymentSize: int64(maxDeploymentSize),
169-
StorageKeyPrefix: conf.StorageKeyPrefix,
170-
HostIDScheme: sitesConf.HostIDScheme,
171-
HostPattern: config.NewHostPattern(sitesConf.HostPattern),
172-
ReservedApps: reservedApps,
173-
TokenSigningKey: []byte(tokenSigningKey),
174-
TokenAuthority: conf.TokenAuthority,
179+
MaxDeploymentSize: int64(maxDeploymentSize),
180+
StorageKeyPrefix: conf.StorageKeyPrefix,
181+
HostIDScheme: sitesConf.HostIDScheme,
182+
HostPattern: config.NewHostPattern(sitesConf.HostPattern),
183+
ReservedApps: reservedApps,
184+
TokenSigningKey: []byte(tokenSigningKey),
185+
TokenAuthority: conf.TokenAuthority,
186+
ServerVersion: versioninfo.Short(),
187+
CustomDomainMessage: conf.CustomDomainMessage,
175188
}
176189

177190
if conf.APIACLFile != "" {

cmd/pageship/app/apps.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,23 @@ var appsConfigureCmd = &cobra.Command{
140140
return fmt.Errorf("failed to get app: %w", err)
141141
}
142142

143+
oldConfig := app.Config
144+
143145
app, err = API().ConfigureApp(cmd.Context(), app.ID, &conf.App)
144146
if err != nil {
145147
return fmt.Errorf("failed to configure app: %w", err)
146148
}
147149

150+
for _, dconf := range conf.App.Domains {
151+
if _, exists := oldConfig.ResolveDomain(dconf.Domain); !exists {
152+
Info("Activating custom domain %q...", dconf.Domain)
153+
_, err = API().CreateDomain(cmd.Context(), app.ID, dconf.Domain, "")
154+
if err != nil {
155+
Warn("Activation of custom domain %q failed: %s", dconf.Domain, err)
156+
}
157+
}
158+
}
159+
148160
Info("Configured app %q.", app.ID)
149161
return nil
150162
},

cmd/pageship/app/domains.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"text/tabwriter"
9+
"time"
10+
11+
"github.com/manifoldco/promptui"
12+
"github.com/oursky/pageship/internal/api"
13+
"github.com/oursky/pageship/internal/models"
14+
"github.com/spf13/cobra"
15+
"github.com/spf13/viper"
16+
)
17+
18+
func init() {
19+
rootCmd.AddCommand(domainsCmd)
20+
domainsCmd.PersistentFlags().String("app", "", "app ID")
21+
22+
domainsCmd.AddCommand(domainsActivateCmd)
23+
domainsCmd.AddCommand(domainsDeactivateCmd)
24+
}
25+
26+
var domainsCmd = &cobra.Command{
27+
Use: "domains",
28+
Short: "Manage custom domains",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
appID := viper.GetString("app")
31+
if appID == "" {
32+
appID = tryLoadAppID()
33+
}
34+
if appID == "" {
35+
return fmt.Errorf("app ID is not set")
36+
}
37+
38+
manifest, err := API().GetManifest(cmd.Context())
39+
if err != nil {
40+
return fmt.Errorf("failed to get manifest: %w", err)
41+
}
42+
43+
app, err := API().GetApp(cmd.Context(), appID)
44+
if err != nil {
45+
return fmt.Errorf("failed to get app: %w", err)
46+
}
47+
48+
type domainEntry struct {
49+
name string
50+
site string
51+
model *api.APIDomain
52+
}
53+
domains := map[string]domainEntry{}
54+
for _, dconf := range app.Config.Domains {
55+
domains[dconf.Domain] = domainEntry{
56+
name: dconf.Domain,
57+
site: dconf.Site,
58+
model: nil,
59+
}
60+
}
61+
62+
apiDomains, err := API().ListDomains(cmd.Context(), appID)
63+
if err != nil {
64+
return fmt.Errorf("failed to list domains: %w", err)
65+
}
66+
67+
for _, d := range apiDomains {
68+
dd := d
69+
domains[d.Domain.Domain] = domainEntry{
70+
name: d.Domain.Domain,
71+
site: d.Domain.SiteName,
72+
model: &dd,
73+
}
74+
}
75+
76+
w := tabwriter.NewWriter(os.Stdout, 1, 4, 4, ' ', 0)
77+
fmt.Fprintln(w, "NAME\tSITE\tCREATED AT\tSTATUS")
78+
for _, domain := range domains {
79+
createdAt := "-"
80+
site := "-"
81+
if domain.model != nil {
82+
createdAt = domain.model.CreatedAt.Local().Format(time.DateTime)
83+
site = fmt.Sprintf("%s/%s", domain.model.AppID, domain.model.SiteName)
84+
} else {
85+
site = fmt.Sprintf("%s/%s", app.ID, domain.site)
86+
}
87+
88+
var status string
89+
switch {
90+
case domain.model != nil && domain.model.AppID != app.ID:
91+
status = "IN_USE"
92+
case domain.model != nil && domain.model.AppID == app.ID:
93+
status = "ACTIVE"
94+
default:
95+
status = "INACTIVE"
96+
}
97+
98+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", domain.name, site, createdAt, status)
99+
}
100+
w.Flush()
101+
102+
if manifest.CustomDomainMessage != "" {
103+
os.Stdout.WriteString("\n")
104+
Info(manifest.CustomDomainMessage)
105+
}
106+
107+
return nil
108+
},
109+
}
110+
111+
func promptDomainReplaceApp(ctx context.Context, appID string, domainName string) (replaceApp string, err error) {
112+
domains, err := API().ListDomains(ctx, appID)
113+
if err != nil {
114+
return "", fmt.Errorf("failed list domain: %w", err)
115+
}
116+
117+
appID = ""
118+
for _, d := range domains {
119+
if d.Domain.Domain == domainName {
120+
appID = d.AppID
121+
}
122+
}
123+
124+
if appID == "" {
125+
return "", models.ErrDomainUsedName
126+
}
127+
128+
label := fmt.Sprintf("Domain %q is in use by app %q; activates the domain anyways", domainName, appID)
129+
130+
prompt := promptui.Prompt{Label: label, IsConfirm: true}
131+
_, err = prompt.Run()
132+
if err != nil {
133+
Info("Cancelled.")
134+
return "", ErrCancelled
135+
}
136+
137+
return appID, nil
138+
}
139+
140+
var domainsActivateCmd = &cobra.Command{
141+
Use: "activate",
142+
Short: "Activate domain for the app",
143+
Args: cobra.ExactArgs(1),
144+
RunE: func(cmd *cobra.Command, args []string) error {
145+
domainName := args[0]
146+
147+
appID := viper.GetString("app")
148+
if appID == "" {
149+
appID = tryLoadAppID()
150+
}
151+
if appID == "" {
152+
return fmt.Errorf("app ID is not set")
153+
}
154+
155+
app, err := API().GetApp(cmd.Context(), appID)
156+
if err != nil {
157+
return fmt.Errorf("failed to get app: %w", err)
158+
}
159+
if _, ok := app.Config.ResolveDomain(domainName); !ok {
160+
return fmt.Errorf("undefined domain")
161+
}
162+
163+
_, err = API().CreateDomain(cmd.Context(), appID, domainName, "")
164+
if code, ok := api.ErrorStatusCode(err); ok && code == http.StatusConflict {
165+
var replaceApp string
166+
replaceApp, err = promptDomainReplaceApp(cmd.Context(), appID, domainName)
167+
if err != nil {
168+
return err
169+
}
170+
_, err = API().CreateDomain(cmd.Context(), appID, domainName, replaceApp)
171+
}
172+
173+
if err != nil {
174+
return fmt.Errorf("failed to create domain: %w", err)
175+
}
176+
177+
Info("Domain %q activated.", domainName)
178+
return nil
179+
},
180+
}
181+
182+
var domainsDeactivateCmd = &cobra.Command{
183+
Use: "deactivate",
184+
Short: "Deactivate domain for the app",
185+
Args: cobra.ExactArgs(1),
186+
RunE: func(cmd *cobra.Command, args []string) error {
187+
domainName := args[0]
188+
189+
appID := viper.GetString("app")
190+
if appID == "" {
191+
appID = tryLoadAppID()
192+
}
193+
if appID == "" {
194+
return fmt.Errorf("app ID is not set")
195+
}
196+
197+
_, err := API().DeleteDomain(cmd.Context(), appID, domainName)
198+
if err != nil {
199+
return fmt.Errorf("failed to delete domain: %w", err)
200+
}
201+
202+
Info("Domain %q deactivated.", domainName)
203+
return nil
204+
},
205+
}

cmd/pageship/app/serve.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import (
1111
"github.com/caddyserver/certmagic"
1212
"github.com/oursky/pageship/internal/command"
1313
"github.com/oursky/pageship/internal/config"
14+
"github.com/oursky/pageship/internal/domain"
15+
domainlocal "github.com/oursky/pageship/internal/domain/local"
1416
handler "github.com/oursky/pageship/internal/handler/site"
1517
"github.com/oursky/pageship/internal/handler/site/middleware"
1618
"github.com/oursky/pageship/internal/httputil"
1719
"github.com/oursky/pageship/internal/site"
18-
"github.com/oursky/pageship/internal/site/local"
20+
sitelocal "github.com/oursky/pageship/internal/site/local"
1921
"github.com/spf13/cobra"
2022
"github.com/spf13/viper"
2123
)
@@ -53,11 +55,13 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle
5355

5456
fsys := os.DirFS(dir)
5557

56-
var resolver site.Resolver
57-
resolver = local.NewSingleSiteResolver(fsys)
58+
var siteResolver site.Resolver
59+
siteResolver = sitelocal.NewSingleSiteResolver(fsys)
60+
var domainResolver domain.Resolver
61+
domainResolver = &domain.ResolverNull{}
5862

5963
// Check site on startup.
60-
_, err = resolver.Resolve(context.Background(), defaultSite)
64+
_, err = siteResolver.Resolve(context.Background(), defaultSite)
6165
if errors.Is(err, config.ErrConfigNotFound) {
6266
// continue in multi-site mode
6367

@@ -72,17 +76,23 @@ func makeHandler(prefix string, defaultSite string, hostPattern string) (*handle
7276
if sitesConf != nil {
7377
sites = sitesConf.Sites
7478
}
75-
resolver = local.NewMultiSiteResolver(fsys, defaultSite, sites)
79+
siteResolver = sitelocal.NewResolver(fsys, defaultSite, sites)
80+
domainResolver, err = domainlocal.NewResolver(defaultSite, sites)
81+
if err != nil {
82+
return nil, err
83+
}
7684
} else if err != nil {
7785
return nil, err
7886
}
7987

80-
Info("site resolution mode: %s", resolver.Kind())
88+
Info("site resolution mode: %s", siteResolver.Kind())
8189

82-
handler, err := handler.NewHandler(context.Background(), zapLogger, resolver, handler.HandlerConfig{
83-
HostPattern: hostPattern,
84-
Middlewares: middleware.Default,
85-
})
90+
handler, err := handler.NewHandler(context.Background(), zapLogger,
91+
domainResolver, siteResolver,
92+
handler.HandlerConfig{
93+
HostPattern: hostPattern,
94+
Middlewares: middleware.Default,
95+
})
8696
if err != nil {
8797
return nil, err
8898
}
@@ -127,7 +137,7 @@ var serveCmd = &cobra.Command{
127137

128138
if len(tlsDomain) > 0 {
129139
tls.DomainNames = []string{tlsDomain}
130-
} else if handler.AllowAnyDomain() {
140+
} else if handler.AcceptsAllDomain() {
131141
return fmt.Errorf("must provide domain name via --tls-domain to enable TLS")
132142
}
133143
}

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- [Automatic TLS](guides/features/automatic-tls.md)
1717
- [GitHub Actions Integration](guides/features/github-actions-integration.md)
1818
- [Access Control](guides/features/access-control.md)
19+
- [Custom Domain](guides/features/custom-domain.md)
1920

2021
# References
2122

0 commit comments

Comments
 (0)