Skip to content

Commit 8d3ae46

Browse files
Add per-site web settings for compression and cache control
Introduce persistent website-level settings and a unified per-site settings modal so users can manage domains and tune gzip/zstd and Cache-Control behavior per website. This wires the new settings through DB, API, and Caddy generation with backward-compatible defaults and validation. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e7b1de5 commit 8d3ae46

File tree

7 files changed

+492
-158
lines changed

7 files changed

+492
-158
lines changed

cmd/fastcp/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func main() {
122122
r.Get("/sites", apiHandler.ListSites)
123123
r.Post("/sites", apiHandler.CreateSite)
124124
r.Get("/sites/{id}", apiHandler.GetSite)
125+
r.Put("/sites/{id}/settings", apiHandler.UpdateSiteSettings)
125126
r.Delete("/sites/{id}", apiHandler.DeleteSite)
126127

127128
// Site domains

cmd/fastcp/ui/dist/index.html

Lines changed: 197 additions & 72 deletions
Large diffs are not rendered by default.

internal/agent/handlers.go

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -975,21 +975,44 @@ func (s *Server) generateCaddyfile() error {
975975
}
976976
}
977977

978-
// Fetch all sites
979-
rows, err := db.Query("SELECT id, domain, username, document_root FROM sites")
978+
// Fetch all sites (with fallback for older schemas during rolling updates)
979+
rows, err := db.Query(`SELECT id, domain, username, document_root,
980+
COALESCE(compression_enabled, 1), COALESCE(gzip_enabled, 1), COALESCE(zstd_enabled, 1),
981+
COALESCE(cache_control_enabled, 0), COALESCE(cache_control_value, '')
982+
FROM sites`)
983+
legacySchema := false
980984
if err != nil {
981-
// If no sites table yet, use default config
982-
slog.Warn("no sites table found, using default config", "error", err)
983-
return nil
985+
rows, err = db.Query("SELECT id, domain, username, document_root FROM sites")
986+
if err != nil {
987+
// If no sites table yet, use default config
988+
slog.Warn("no sites table found, using default config", "error", err)
989+
return nil
990+
}
991+
legacySchema = true
984992
}
985993
defer rows.Close()
986994

987995
// Build sites map
988996
sitesMap := make(map[string]*siteInfo)
989997
for rows.Next() {
990998
var site siteInfo
991-
if err := rows.Scan(&site.ID, &site.Domain, &site.Username, &site.DocumentRoot); err != nil {
992-
continue
999+
if legacySchema {
1000+
if err := rows.Scan(&site.ID, &site.Domain, &site.Username, &site.DocumentRoot); err != nil {
1001+
continue
1002+
}
1003+
site.CompressionEnabled = true
1004+
site.GzipEnabled = true
1005+
site.ZstdEnabled = true
1006+
site.CacheControlEnabled = false
1007+
site.CacheControlValue = ""
1008+
} else {
1009+
if err := rows.Scan(
1010+
&site.ID, &site.Domain, &site.Username, &site.DocumentRoot,
1011+
&site.CompressionEnabled, &site.GzipEnabled, &site.ZstdEnabled,
1012+
&site.CacheControlEnabled, &site.CacheControlValue,
1013+
); err != nil {
1014+
continue
1015+
}
9931016
}
9941017
site.SafeDomain = strings.ReplaceAll(site.Domain, ".", "_")
9951018
site.PrimaryDomain = site.Domain // Default to main domain
@@ -1272,13 +1295,38 @@ func (s *Server) generateUserCaddyfile(username string, sites []siteInfo) error
12721295

12731296
matcherName := strings.ReplaceAll(site.SafeDomain, "-", "_")
12741297
hostList := strings.Join(servingDomains, " ")
1298+
compressionLine := ""
1299+
if site.CompressionEnabled {
1300+
var algos []string
1301+
if site.ZstdEnabled {
1302+
algos = append(algos, "zstd")
1303+
}
1304+
if site.GzipEnabled {
1305+
algos = append(algos, "gzip")
1306+
}
1307+
if len(algos) > 0 {
1308+
compressionLine = fmt.Sprintf(" encode %s\n", strings.Join(algos, " "))
1309+
}
1310+
}
1311+
1312+
cacheControlLine := ""
1313+
if site.CacheControlEnabled {
1314+
cacheVal := strings.TrimSpace(site.CacheControlValue)
1315+
cacheVal = strings.ReplaceAll(cacheVal, "\r", "")
1316+
cacheVal = strings.ReplaceAll(cacheVal, "\n", "")
1317+
if cacheVal != "" {
1318+
cacheControlLine = fmt.Sprintf(" header Cache-Control %q\n", cacheVal)
1319+
}
1320+
}
1321+
12751322
buf.WriteString(fmt.Sprintf(` @%s host %s
12761323
handle @%s {
12771324
root * %s
1325+
%s%s
12781326
php_server
12791327
}
12801328
1281-
`, matcherName, hostList, matcherName, site.DocumentRoot))
1329+
`, matcherName, hostList, matcherName, site.DocumentRoot, compressionLine, cacheControlLine))
12821330
}
12831331

12841332
// Per-user temp directory paths (directories are created by bootstrapUserEnvironment)
@@ -1359,14 +1407,19 @@ session.cookie_samesite = Strict
13591407
}
13601408

13611409
type siteInfo struct {
1362-
ID string
1363-
Domain string
1364-
Username string
1365-
DocumentRoot string
1366-
SafeDomain string
1367-
Domains []siteDomainInfo
1368-
PrimaryDomain string
1369-
IsSuspended bool
1410+
ID string
1411+
Domain string
1412+
Username string
1413+
DocumentRoot string
1414+
SafeDomain string
1415+
Domains []siteDomainInfo
1416+
PrimaryDomain string
1417+
IsSuspended bool
1418+
CompressionEnabled bool
1419+
GzipEnabled bool
1420+
ZstdEnabled bool
1421+
CacheControlEnabled bool
1422+
CacheControlValue string
13701423
}
13711424

13721425
type siteDomainInfo struct {

internal/api/handler.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,25 @@ func (h *Handler) DeleteSite(w http.ResponseWriter, r *http.Request) {
209209
w.WriteHeader(http.StatusNoContent)
210210
}
211211

212+
func (h *Handler) UpdateSiteSettings(w http.ResponseWriter, r *http.Request) {
213+
user := h.getUser(r)
214+
id := chi.URLParam(r, "id")
215+
216+
var req UpdateSiteSettingsRequest
217+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
218+
h.error(w, http.StatusBadRequest, "invalid request body")
219+
return
220+
}
221+
req.Username = user.Username
222+
223+
site, err := h.siteService.UpdateSettings(r.Context(), id, user.Username, &req)
224+
if err != nil {
225+
h.error(w, http.StatusBadRequest, err.Error())
226+
return
227+
}
228+
h.json(w, http.StatusOK, site)
229+
}
230+
212231
// Domain handlers
213232
func (h *Handler) AddDomain(w http.ResponseWriter, r *http.Request) {
214233
user := h.getUser(r)

internal/api/sites.go

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,20 @@ func (s *SiteService) List(ctx context.Context, username string) ([]*Site, error
4040
// Get domains for this site
4141
domains, _ := s.db.GetSiteDomains(ctx, dbSite.ID)
4242
sites[i] = &Site{
43-
ID: dbSite.ID,
44-
Username: dbSite.Username,
45-
Domain: dbSite.Domain,
46-
Slug: dbSite.Slug,
47-
SiteType: dbSite.SiteType,
48-
DocumentRoot: dbSite.DocumentRoot,
49-
SSLEnabled: dbSite.SSLEnabled,
50-
CreatedAt: dbSite.CreatedAt,
51-
Domains: convertDomains(domains),
43+
ID: dbSite.ID,
44+
Username: dbSite.Username,
45+
Domain: dbSite.Domain,
46+
Slug: dbSite.Slug,
47+
SiteType: dbSite.SiteType,
48+
DocumentRoot: dbSite.DocumentRoot,
49+
SSLEnabled: dbSite.SSLEnabled,
50+
CompressionEnabled: dbSite.CompressionEnabled,
51+
GzipEnabled: dbSite.GzipEnabled,
52+
ZstdEnabled: dbSite.ZstdEnabled,
53+
CacheControlEnabled: dbSite.CacheControlEnabled,
54+
CacheControlValue: dbSite.CacheControlValue,
55+
CreatedAt: dbSite.CreatedAt,
56+
Domains: convertDomains(domains),
5257
}
5358
}
5459
return sites, nil
@@ -65,15 +70,20 @@ func (s *SiteService) ListPaginated(ctx context.Context, username string, page,
6570
for i, dbSite := range dbSites {
6671
domains, _ := s.db.GetSiteDomains(ctx, dbSite.ID)
6772
sites[i] = &Site{
68-
ID: dbSite.ID,
69-
Username: dbSite.Username,
70-
Domain: dbSite.Domain,
71-
Slug: dbSite.Slug,
72-
SiteType: dbSite.SiteType,
73-
DocumentRoot: dbSite.DocumentRoot,
74-
SSLEnabled: dbSite.SSLEnabled,
75-
CreatedAt: dbSite.CreatedAt,
76-
Domains: convertDomains(domains),
73+
ID: dbSite.ID,
74+
Username: dbSite.Username,
75+
Domain: dbSite.Domain,
76+
Slug: dbSite.Slug,
77+
SiteType: dbSite.SiteType,
78+
DocumentRoot: dbSite.DocumentRoot,
79+
SSLEnabled: dbSite.SSLEnabled,
80+
CompressionEnabled: dbSite.CompressionEnabled,
81+
GzipEnabled: dbSite.GzipEnabled,
82+
ZstdEnabled: dbSite.ZstdEnabled,
83+
CacheControlEnabled: dbSite.CacheControlEnabled,
84+
CacheControlValue: dbSite.CacheControlValue,
85+
CreatedAt: dbSite.CreatedAt,
86+
Domains: convertDomains(domains),
7787
}
7888
}
7989
return sites, total, nil
@@ -113,15 +123,20 @@ func (s *SiteService) Get(ctx context.Context, id, username string) (*Site, erro
113123
domains, _ := s.db.GetSiteDomains(ctx, dbSite.ID)
114124

115125
return &Site{
116-
ID: dbSite.ID,
117-
Username: dbSite.Username,
118-
Domain: dbSite.Domain,
119-
Slug: dbSite.Slug,
120-
SiteType: dbSite.SiteType,
121-
DocumentRoot: dbSite.DocumentRoot,
122-
SSLEnabled: dbSite.SSLEnabled,
123-
CreatedAt: dbSite.CreatedAt,
124-
Domains: convertDomains(domains),
126+
ID: dbSite.ID,
127+
Username: dbSite.Username,
128+
Domain: dbSite.Domain,
129+
Slug: dbSite.Slug,
130+
SiteType: dbSite.SiteType,
131+
DocumentRoot: dbSite.DocumentRoot,
132+
SSLEnabled: dbSite.SSLEnabled,
133+
CompressionEnabled: dbSite.CompressionEnabled,
134+
GzipEnabled: dbSite.GzipEnabled,
135+
ZstdEnabled: dbSite.ZstdEnabled,
136+
CacheControlEnabled: dbSite.CacheControlEnabled,
137+
CacheControlValue: dbSite.CacheControlValue,
138+
CreatedAt: dbSite.CreatedAt,
139+
Domains: convertDomains(domains),
125140
}, nil
126141
}
127142

@@ -242,13 +257,18 @@ func (s *SiteService) Create(ctx context.Context, req *CreateSiteRequest) (*Site
242257

243258
// Save to database
244259
dbSite := &database.Site{
245-
ID: id,
246-
Username: req.Username,
247-
Domain: domain,
248-
Slug: slug,
249-
SiteType: siteType,
250-
DocumentRoot: documentRoot,
251-
SSLEnabled: true,
260+
ID: id,
261+
Username: req.Username,
262+
Domain: domain,
263+
Slug: slug,
264+
SiteType: siteType,
265+
DocumentRoot: documentRoot,
266+
SSLEnabled: true,
267+
CompressionEnabled: true,
268+
GzipEnabled: true,
269+
ZstdEnabled: true,
270+
CacheControlEnabled: false,
271+
CacheControlValue: "",
252272
}
253273
if err := s.db.CreateSite(ctx, dbSite); err != nil {
254274
return nil, fmt.Errorf("failed to save site: %w", err)
@@ -272,13 +292,18 @@ func (s *SiteService) Create(ctx context.Context, req *CreateSiteRequest) (*Site
272292
}
273293

274294
return &Site{
275-
ID: id,
276-
Username: req.Username,
277-
Domain: domain,
278-
Slug: slug,
279-
SiteType: siteType,
280-
DocumentRoot: documentRoot,
281-
SSLEnabled: true,
295+
ID: id,
296+
Username: req.Username,
297+
Domain: domain,
298+
Slug: slug,
299+
SiteType: siteType,
300+
DocumentRoot: documentRoot,
301+
SSLEnabled: true,
302+
CompressionEnabled: true,
303+
GzipEnabled: true,
304+
ZstdEnabled: true,
305+
CacheControlEnabled: false,
306+
CacheControlValue: "",
282307
Domains: []SiteDomain{{
283308
ID: primaryDomain.ID,
284309
SiteID: id,
@@ -323,6 +348,51 @@ func (s *SiteService) Delete(ctx context.Context, id, username string) error {
323348
return nil
324349
}
325350

351+
// UpdateSettings updates per-site runtime settings and regenerates Caddy configuration.
352+
func (s *SiteService) UpdateSettings(ctx context.Context, siteID, username string, req *UpdateSiteSettingsRequest) (*Site, error) {
353+
// Verify ownership first
354+
dbSite, err := s.db.GetSite(ctx, siteID)
355+
if err != nil || dbSite.Username != username {
356+
return nil, fmt.Errorf("site not found")
357+
}
358+
359+
cacheControlValue := strings.TrimSpace(req.CacheControlValue)
360+
cacheControlValue = strings.ReplaceAll(cacheControlValue, "\r", "")
361+
cacheControlValue = strings.ReplaceAll(cacheControlValue, "\n", "")
362+
if len(cacheControlValue) > 255 {
363+
return nil, fmt.Errorf("cache-control value is too long")
364+
}
365+
366+
if req.CompressionEnabled && !req.GzipEnabled && !req.ZstdEnabled {
367+
return nil, fmt.Errorf("enable at least one compression algorithm (gzip or zstd)")
368+
}
369+
if req.CacheControlEnabled && cacheControlValue == "" {
370+
return nil, fmt.Errorf("cache-control value is required when cache-control is enabled")
371+
}
372+
373+
// When compression is disabled, force algorithms off for clarity.
374+
gzipEnabled := req.GzipEnabled
375+
zstdEnabled := req.ZstdEnabled
376+
if !req.CompressionEnabled {
377+
gzipEnabled = false
378+
zstdEnabled = false
379+
}
380+
381+
if err := s.db.UpdateSiteSettings(ctx, siteID, username, req.CompressionEnabled, gzipEnabled, zstdEnabled, req.CacheControlEnabled, cacheControlValue); err != nil {
382+
if err == context.Canceled {
383+
return nil, err
384+
}
385+
return nil, fmt.Errorf("failed to update site settings: %w", err)
386+
}
387+
388+
// Reload Caddy configuration
389+
if err := s.agent.ReloadCaddy(ctx); err != nil {
390+
fmt.Printf("warning: failed to reload Caddy: %v\n", err)
391+
}
392+
393+
return s.Get(ctx, siteID, username)
394+
}
395+
326396
const maxDomainsPerSite = 20
327397

328398
// AddDomain adds a domain to a site

internal/api/types.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,30 @@ type CreateSiteRequest struct {
4040

4141
// Site represents a website
4242
type Site struct {
43-
ID string `json:"id"`
44-
Username string `json:"username"`
45-
Domain string `json:"domain"`
46-
Slug string `json:"slug"`
47-
SiteType string `json:"site_type"`
48-
DocumentRoot string `json:"document_root"`
49-
SSLEnabled bool `json:"ssl_enabled"`
50-
CreatedAt time.Time `json:"created_at"`
51-
Domains []SiteDomain `json:"domains,omitempty"`
43+
ID string `json:"id"`
44+
Username string `json:"username"`
45+
Domain string `json:"domain"`
46+
Slug string `json:"slug"`
47+
SiteType string `json:"site_type"`
48+
DocumentRoot string `json:"document_root"`
49+
SSLEnabled bool `json:"ssl_enabled"`
50+
CompressionEnabled bool `json:"compression_enabled"`
51+
GzipEnabled bool `json:"gzip_enabled"`
52+
ZstdEnabled bool `json:"zstd_enabled"`
53+
CacheControlEnabled bool `json:"cache_control_enabled"`
54+
CacheControlValue string `json:"cache_control_value"`
55+
CreatedAt time.Time `json:"created_at"`
56+
Domains []SiteDomain `json:"domains,omitempty"`
57+
}
58+
59+
// UpdateSiteSettingsRequest is the request body for updating site runtime settings
60+
type UpdateSiteSettingsRequest struct {
61+
Username string `json:"-"` // Set from auth
62+
CompressionEnabled bool `json:"compression_enabled"`
63+
GzipEnabled bool `json:"gzip_enabled"`
64+
ZstdEnabled bool `json:"zstd_enabled"`
65+
CacheControlEnabled bool `json:"cache_control_enabled"`
66+
CacheControlValue string `json:"cache_control_value"`
5267
}
5368

5469
// ValidateSlugRequest is the request body for validating a site slug

0 commit comments

Comments
 (0)