about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorAlan Pearce2025-01-30 22:16:09 +0100
committerAlan Pearce2025-01-30 22:16:09 +0100
commit99f8047ef20a64f948ac2b703c81eb49bed091c0 (patch)
treea0365a7b2e477467a91bef247db09624028e1807
parent4566db657dab6af43f8fce814cd0e42cbcc788bf (diff)
downloadwebsite-sqlite.tar.lz
website-sqlite.tar.zst
website-sqlite.zip
re-organise everything sqlite
-rw-r--r--cmd/build/main.go24
-rw-r--r--cmd/server/main.go91
-rw-r--r--internal/builder/builder.go21
-rw-r--r--internal/server/app.go10
-rw-r--r--internal/server/logging.go3
-rw-r--r--internal/server/server.go217
-rw-r--r--internal/server/tcp.go4
-rw-r--r--internal/server/tls.go52
-rw-r--r--internal/storage/sqlite/reader.go7
-rw-r--r--internal/storage/sqlite/writer.go11
-rw-r--r--internal/watcher/watcher.go (renamed from internal/server/dev.go)4
-rw-r--r--internal/website/mux.go143
12 files changed, 339 insertions, 248 deletions
diff --git a/cmd/build/main.go b/cmd/build/main.go
index 84de2dc..72c1470 100644
--- a/cmd/build/main.go
+++ b/cmd/build/main.go
@@ -6,6 +6,7 @@ import (
 
 	"go.alanpearce.eu/website/internal/builder"
 	"go.alanpearce.eu/website/internal/config"
+	"go.alanpearce.eu/website/internal/storage/sqlite"
 	"go.alanpearce.eu/x/log"
 
 	"github.com/ardanlabs/conf/v3"
@@ -13,29 +14,38 @@ import (
 )
 
 func main() {
-	ioConfig := &builder.IOConfig{}
-	if help, err := conf.Parse("", ioConfig); err != nil {
+	builderOptions := &builder.Options{}
+	if help, err := conf.Parse("", builderOptions); err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
 			fmt.Println(help)
 			os.Exit(1)
 		}
 		panic("error parsing configuration: " + err.Error())
 	}
-	log := log.Configure(!ioConfig.Development)
+	log := log.Configure(!builderOptions.Development)
 
 	log.Debug("starting build process")
-	if ioConfig.Source != "." {
-		err := os.Chdir(ioConfig.Source)
+	if builderOptions.Source != "." {
+		err := os.Chdir(builderOptions.Source)
 		if err != nil {
 			log.Panic("could not change to source directory")
 		}
 	}
-	cfg, err := config.GetConfig(ioConfig.Source, log)
+	cfg, err := config.GetConfig(builderOptions.Source, log)
 	if err != nil {
 		log.Error("could not read config", "error", err)
 	}
 
-	_, err = builder.BuildSite(ioConfig, cfg, log)
+	db, err := sqlite.OpenDB(builderOptions.DBPath)
+	if err != nil {
+		log.Error("could not open database", "error", err)
+
+		return
+	}
+
+	builderOptions.DB = db
+
+	_, err = builder.BuildSite(builderOptions, cfg, log)
 	if err != nil {
 		log.Error("could not build site", "error", err)
 		os.Exit(1)
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 8d8d4d4..fbb2318 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -3,19 +3,39 @@ package main
 import (
 	"context"
 	"fmt"
+	"net"
+	"net/url"
 	"os"
 	"os/signal"
+	"slices"
+	"strconv"
+	"strings"
 
+	"go.alanpearce.eu/website/internal/config"
 	"go.alanpearce.eu/website/internal/server"
+	"go.alanpearce.eu/website/internal/website"
 	"go.alanpearce.eu/x/log"
 
 	"github.com/ardanlabs/conf/v3"
 	"gitlab.com/tozd/go/errors"
 )
 
+type Options struct {
+	Development    bool   `conf:"default:false,flag:dev"`
+	DBPath         string `conf:"default:site.db"`
+	Redirect       bool   `conf:"default:true"`
+	Port           int    `conf:"default:8080,short:p"`
+	TLS            bool   `conf:"default:false"`
+	TLSPort        int    `conf:"default:8443"`
+	ListenAddress  string `conf:"default:localhost"`
+	Domains        string `conf:"default:localhost"`
+	ACMEIssuer     string
+	ACMEIssuerCert string
+}
+
 func main() {
-	runtimeConfig := server.Config{}
-	help, err := conf.Parse("", &runtimeConfig)
+	options := &Options{}
+	help, err := conf.Parse("", options)
 	if err != nil {
 		if errors.Is(err, conf.ErrHelpWanted) {
 			fmt.Println(help)
@@ -23,19 +43,63 @@ func main() {
 		}
 		panic("parsing runtime configuration" + err.Error())
 	}
-	log := log.Configure(!runtimeConfig.Development)
+	log := log.Configure(!options.Development)
+
+	cfg, err := config.GetConfig(".", log.Named("config"))
+	if err != nil {
+		log.Error("error reading configuration file", "error", err)
+	}
 
-	if runtimeConfig.Development {
-		runtimeConfig.DBPath = ":memory:"
+	// Domains?
+	webOpts := &website.Options{
+		DBPath:      options.DBPath,
+		Redirect:    options.Redirect,
+		Development: options.Development,
+		Config:      cfg,
 	}
 
-	sv, err := server.New(&runtimeConfig, log)
+	serverOpts := &server.Options{
+		Development:    options.Development,
+		ListenAddress:  options.ListenAddress,
+		Port:           options.Port,
+		TLS:            options.TLS,
+		TLSPort:        options.TLSPort,
+		ACMEIssuer:     options.ACMEIssuer,
+		ACMEIssuerCert: options.ACMEIssuerCert,
+		Config:         cfg,
+	}
+
+	if options.Development {
+		webOpts.DBPath = ":memory:"
+		cfg.CSP.ScriptSrc = slices.Insert(cfg.CSP.ScriptSrc, 0, "'unsafe-inline'")
+		cfg.CSP.ConnectSrc = slices.Insert(cfg.CSP.ConnectSrc, 0, "'self'")
+		if options.Domains != "" {
+			cfg.Domains = strings.Split(options.Domains, ",")
+		} else {
+			cfg.Domains = []string{options.ListenAddress}
+		}
+		cfg.BaseURL = mkBaseURL(options, cfg)
+	}
+
+	sv, err := server.New(serverOpts, log.Named("server"))
 	if err != nil {
 		log.Error("could not create server", "error", err)
 
 		return
 	}
 
+	website, err := website.New(webOpts, log.Named("website"))
+	if err != nil {
+		log.Error("could not initialise website", "error", err)
+
+		return
+	}
+
+	sv.HostApp(website.App)
+	if options.Redirect {
+		sv.HostFallbackApp(website.MakeRedirectorApp())
+	}
+
 	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
 	defer cancel()
 
@@ -52,3 +116,18 @@ func main() {
 	<-sv.Stop()
 	log.Debug("done")
 }
+
+func mkBaseURL(options *Options, cfg *config.Config) config.URL {
+	scheme := "http"
+	port := options.Port
+	if options.TLS {
+		scheme = "https"
+		port = options.TLSPort
+	}
+	return config.URL{
+		URL: &url.URL{
+			Scheme: scheme,
+			Host:   net.JoinHostPort(cfg.Domains[0], strconv.Itoa(port)),
+		},
+	}
+}
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index 266ce56..14bbb77 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -2,6 +2,7 @@ package builder
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"io"
 	"io/fs"
@@ -24,11 +25,10 @@ import (
 	"gitlab.com/tozd/go/errors"
 )
 
-type IOConfig struct {
+type Options struct {
 	Source      string `conf:"default:.,short:s,flag:src"`
-	Destination string `conf:"default:public,short:d,flag:dest"`
-	DBPath      string `conf:"default:site.db,flag:db"`
 	Development bool   `conf:"default:false,flag:dev"`
+	DB          *sql.DB
 }
 
 type Result struct {
@@ -75,7 +75,7 @@ func copyRecursive(storage storage.Writer, src string) error {
 
 func build(
 	storage storage.Writer,
-	ioConfig *IOConfig,
+	ioConfig *Options,
 	config *config.Config,
 	log *log.Logger,
 ) (*Result, error) {
@@ -83,7 +83,6 @@ func build(
 	buf := new(buffer.Buffer)
 	joinSource := joinSourcePath(ioConfig.Source)
 
-	log.Debug("output", "dir", ioConfig.Destination)
 	r := &Result{
 		Hashes: make([]string, 0),
 	}
@@ -259,22 +258,22 @@ func build(
 	return r, nil
 }
 
-func BuildSite(ioConfig *IOConfig, cfg *config.Config, log *log.Logger) (*Result, error) {
+func BuildSite(options *Options, cfg *config.Config, log *log.Logger) (*Result, error) {
 	if cfg == nil {
 		return nil, errors.New("config is nil")
 	}
-	cfg.InjectLiveReload = ioConfig.Development
+	cfg.InjectLiveReload = options.Development
 
 	templates.Setup()
-	loadCSS(ioConfig.Source)
+	loadCSS(options.Source)
 
 	var storage storage.Writer
-	storage, err := sqlite.NewWriter(ioConfig.DBPath, log, &sqlite.Options{
-		Compress: !ioConfig.Development,
+	storage, err := sqlite.NewWriter(options.DB, log, &sqlite.Options{
+		Compress: !options.Development,
 	})
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not create storage")
 	}
 
-	return build(storage, ioConfig, cfg, log)
+	return build(storage, options, cfg, log)
 }
diff --git a/internal/server/app.go b/internal/server/app.go
new file mode 100644
index 0000000..d1a7dbc
--- /dev/null
+++ b/internal/server/app.go
@@ -0,0 +1,10 @@
+package server
+
+import (
+	"net/http"
+)
+
+type App struct {
+	Domain  string
+	Handler http.Handler
+}
diff --git a/internal/server/logging.go b/internal/server/logging.go
index f744931..800e97a 100644
--- a/internal/server/logging.go
+++ b/internal/server/logging.go
@@ -27,9 +27,6 @@ func wrapHandlerWithLogging(wrappedHandler http.Handler, log *log.Logger) http.H
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		lw := NewLoggingResponseWriter(w)
 		wrappedHandler.ServeHTTP(lw, r)
-		if r.URL.Path == "/health" {
-			return
-		}
 		log.Info(
 			"http request",
 			"method", r.Method,
diff --git a/internal/server/server.go b/internal/server/server.go
index 0f5e22f..b3161fb 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -3,22 +3,12 @@ package server
 import (
 	"context"
 	"fmt"
-	"net"
 	"net/http"
-	"net/url"
-	"regexp"
-	"slices"
-	"strconv"
-	"strings"
 	"time"
 
-	"go.alanpearce.eu/website/internal/builder"
 	cfg "go.alanpearce.eu/website/internal/config"
-	"go.alanpearce.eu/website/internal/storage/sqlite"
-	"go.alanpearce.eu/website/internal/website"
 	"go.alanpearce.eu/x/log"
 
-	"github.com/osdevisnot/sorvor/pkg/livereload"
 	"gitlab.com/tozd/go/errors"
 )
 
@@ -26,55 +16,31 @@ var (
 	CommitSHA    = "local"
 	ShortSHA     = "local"
 	serverHeader = fmt.Sprintf("website (%s)", ShortSHA)
+
+	ReadHeaderTimeout = 10 * time.Second
+	ReadTimeout       = 1 * time.Minute
+	WriteTimeout      = 2 * time.Minute
+	IdleTimeout       = 10 * time.Minute
 )
 
-type Config struct {
-	DBPath        string `conf:"default:site.db"`
-	Redirect      bool   `conf:"default:true"`
-	ListenAddress string `conf:"default:localhost"`
-	Port          int    `conf:"default:8080,short:p"`
-	TLSPort       int    `conf:"default:8443"`
-	TLS           bool   `conf:"default:false"`
-
-	Development bool   `conf:"default:false,flag:dev"`
-	ACMECA      string `conf:"env:ACME_CA"`
-	ACMECACert  string `conf:"env:ACME_CA_CERT"`
-	Domains     string
-}
+type Options struct {
+	Development   bool
+	ListenAddress string
+	Port          int
+	TLSPort       int
+	TLS           bool
 
-type Server struct {
-	*http.Server
-	runtimeConfig *Config
-	config        *cfg.Config
-	log           *log.Logger
-}
+	ACMEIssuer     string
+	ACMEIssuerCert string
 
-func applyDevModeOverrides(config *cfg.Config, runtimeConfig *Config) {
-	config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'")
-	config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'")
-	if runtimeConfig.Domains != "" {
-		config.Domains = strings.Split(runtimeConfig.Domains, ",")
-	} else {
-		config.Domains = []string{runtimeConfig.ListenAddress}
-	}
-	scheme := "http"
-	port := runtimeConfig.Port
-	if runtimeConfig.TLS {
-		scheme = "https"
-		port = runtimeConfig.TLSPort
-	}
-	config.BaseURL = cfg.URL{
-		URL: &url.URL{
-			Scheme: scheme,
-			Host:   net.JoinHostPort(config.Domains[0], strconv.Itoa(port)),
-		},
-	}
+	Config *cfg.Config
 }
 
-func updateCSPHashes(config *cfg.Config, r *builder.Result) {
-	for i, h := range r.Hashes {
-		config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h)
-	}
+type Server struct {
+	mux     *http.ServeMux
+	options *Options
+	log     *log.Logger
+	server  *http.Server
 }
 
 func serverHeaderHandler(wrappedHandler http.Handler) http.Handler {
@@ -84,109 +50,37 @@ func serverHeaderHandler(wrappedHandler http.Handler) http.Handler {
 	})
 }
 
-func rebuild(builderConfig *builder.IOConfig, config *cfg.Config, log *log.Logger) error {
-	r, err := builder.BuildSite(builderConfig, config, log.Named("builder"))
-	if err != nil {
-		return errors.WithMessage(err, "could not build site")
-	}
-	updateCSPHashes(config, r)
-
-	return nil
-}
-
-func New(runtimeConfig *Config, log *log.Logger) (*Server, error) {
-	builderConfig := &builder.IOConfig{
-		Development: runtimeConfig.Development,
-	}
-
-	config, err := cfg.GetConfig(builderConfig.Source, log.Named("config"))
-	if err != nil {
-		return nil, errors.WithMessage(err, "error parsing configuration file")
-	}
-	if runtimeConfig.Development {
-		applyDevModeOverrides(config, runtimeConfig)
-	}
-
-	top := http.NewServeMux()
-
-	err = rebuild(builderConfig, config, log)
-	if err != nil {
-		return nil, err
-	}
-
+func New(options *Options, log *log.Logger) (*Server, error) {
 	fixupMIMETypes(log)
 
-	if runtimeConfig.Development {
-		liveReload := livereload.New()
-		top.Handle("/_/reload", liveReload)
-		liveReload.Start()
-		fw, err := NewFileWatcher(log.Named("watcher"))
-		if err != nil {
-			return nil, errors.WithMessage(err, "could not create file watcher")
-		}
-		for _, dir := range []string{"content", "static", "templates", "internal/builder"} {
-			err := fw.AddRecursive(dir)
-			if err != nil {
-				return nil, errors.WithMessagef(
-					err,
-					"could not add directory %s to file watcher",
-					dir,
-				)
-			}
-		}
-		go fw.Start(func(filename string) {
-			log.Info("rebuilding site", "changed_file", filename)
-			err := rebuild(builderConfig, config, log)
-			if err != nil {
-				log.Error("error rebuilding site", "error", err)
-			}
-		})
-	}
+	return &Server{
+		mux:     http.NewServeMux(),
+		log:     log,
+		options: options,
+	}, nil
+}
 
-	loggingMux := http.NewServeMux()
+func (s *Server) HostApp(app *App) {
+	s.mux.Handle(app.Domain+"/", app.Handler)
+}
 
-	log.Debug("creating reader")
-	reader, err := sqlite.NewReader(runtimeConfig.DBPath, log.Named("sqlite"))
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not create sqlite reader")
-	}
+func (s *Server) HostFallbackApp(app *App) {
+	s.mux.Handle("/", app.Handler)
+}
 
-	mux, err := website.NewMux(config, reader, log.Named("website"))
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not create website mux")
+func (s *Server) serve(tls bool) error {
+	if tls {
+		return s.serveTLS()
 	}
 
-	if runtimeConfig.Redirect {
-		re := regexp.MustCompile(
-			"^(.*)\\." + strings.ReplaceAll(config.WildcardDomain, ".", `\.`) + "$",
-		)
-		replace := "${1}." + config.Domains[0]
-		loggingMux.Handle(config.BaseURL.Hostname()+"/", mux)
-		loggingMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-			switch {
-			case slices.Contains(config.Domains, r.Host):
-				path, _ := reader.CanonicalisePath(r.URL.Path)
-				http.Redirect(
-					w,
-					r,
-					config.BaseURL.JoinPath(path).String(),
-					http.StatusMovedPermanently,
-				)
-			case re.MatchString(r.Host):
-				url := config.BaseURL.JoinPath()
-				url.Host = re.ReplaceAllString(r.Host, replace)
-				http.Redirect(w, r, url.String(), http.StatusTemporaryRedirect)
-			case true:
-				http.NotFound(w, r)
-			}
-		})
-	} else {
-		loggingMux.Handle("/", mux)
-	}
+	return s.serveTCP()
+}
 
+func (s *Server) Start() error {
+	top := http.NewServeMux()
 	top.Handle("/",
 		serverHeaderHandler(
-			wrapHandlerWithLogging(loggingMux, log),
+			wrapHandlerWithLogging(s.mux, s.log),
 		),
 	)
 
@@ -194,30 +88,15 @@ func New(runtimeConfig *Config, log *log.Logger) (*Server, error) {
 		w.WriteHeader(http.StatusNoContent)
 	})
 
-	return &Server{
-		Server: &http.Server{
-			ReadHeaderTimeout: 10 * time.Second,
-			ReadTimeout:       1 * time.Minute,
-			WriteTimeout:      2 * time.Minute,
-			IdleTimeout:       10 * time.Minute,
-			Handler:           top,
-		},
-		log:           log,
-		config:        config,
-		runtimeConfig: runtimeConfig,
-	}, nil
-}
-
-func (s *Server) serve(tls bool) error {
-	if tls {
-		return s.serveTLS()
+	s.server = &http.Server{
+		ReadHeaderTimeout: ReadHeaderTimeout,
+		ReadTimeout:       ReadTimeout,
+		WriteTimeout:      WriteTimeout,
+		IdleTimeout:       IdleTimeout,
+		Handler:           s.mux,
 	}
 
-	return s.serveTCP()
-}
-
-func (s *Server) Start() error {
-	if err := s.serve(s.runtimeConfig.TLS); err != http.ErrServerClosed {
+	if err := s.serve(s.options.TLS); err != http.ErrServerClosed {
 		return errors.WithMessage(err, "error creating/closing server")
 	}
 
@@ -233,7 +112,7 @@ func (s *Server) Stop() chan struct{} {
 		s.log.Debug("shutting down server")
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 		defer cancel()
-		err := s.Server.Shutdown(ctx)
+		err := s.server.Shutdown(ctx)
 		s.log.Debug("server shut down")
 		if err != nil {
 			// Error from closing listeners, or context timeout:
diff --git a/internal/server/tcp.go b/internal/server/tcp.go
index 1d57c9c..14eb9e6 100644
--- a/internal/server/tcp.go
+++ b/internal/server/tcp.go
@@ -9,12 +9,12 @@ import (
 
 func (s *Server) serveTCP() error {
 	l, err := listenfd.GetListener(0,
-		net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.Port)),
+		net.JoinHostPort(s.options.ListenAddress, strconv.Itoa(s.options.Port)),
 		s.log.Named("tcp.listenfd"),
 	)
 	if err != nil {
 		return err
 	}
 
-	return s.Serve(l)
+	return s.server.Serve(l)
 }
diff --git a/internal/server/tls.go b/internal/server/tls.go
index 40fddac..5e2819f 100644
--- a/internal/server/tls.go
+++ b/internal/server/tls.go
@@ -24,31 +24,31 @@ type redisConfig struct {
 	Password      string `conf:"required"`
 	EncryptionKey string `conf:"required"`
 	KeyPrefix     string `conf:"default:certmagic"`
-	TLSEnabled    bool   `conf:"default:false,env:TLS_ENABLED"`
-	TLSInsecure   bool   `conf:"default:false,env:TLS_INSECURE"`
+	TLSEnabled    bool   `conf:"default:false"`
+	TLSInsecure   bool   `conf:"default:false"`
 }
 
 func (s *Server) serveTLS() (err error) {
 	log := s.log.Named("tls")
 
-	wildcardDomain := "*." + s.config.WildcardDomain
-	certificateDomains := slices.Clone(s.config.Domains)
+	wildcardDomain := "*." + s.options.Config.WildcardDomain
+	certificateDomains := slices.Clone(s.options.Config.Domains)
 
-	certmagic.HTTPPort = s.runtimeConfig.Port
-	certmagic.HTTPSPort = s.runtimeConfig.TLSPort
+	certmagic.HTTPPort = s.options.Port
+	certmagic.HTTPSPort = s.options.TLSPort
 	certmagic.Default.Logger = log.GetLogger().Named("certmagic")
 	cfg := certmagic.NewDefault()
 
 	acme := &certmagic.DefaultACME
 	acme.Logger = certmagic.Default.Logger
 	acme.Agreed = true
-	acme.Email = s.config.Email
-	acme.ListenHost = strings.Trim(s.runtimeConfig.ListenAddress, "[]")
+	acme.Email = s.options.Config.Email
+	acme.ListenHost = strings.Trim(s.options.ListenAddress, "[]")
 
-	if s.runtimeConfig.Development {
-		ca := s.runtimeConfig.ACMECA
+	if s.options.Development {
+		ca := s.options.ACMEIssuer
 		if ca == "" {
-			return errors.New("can't enable tls in development without an ACME_CA")
+			return errors.New("can't enable tls in development without an ACME_ISSUER")
 		}
 
 		cp, err := x509.SystemCertPool()
@@ -57,14 +57,14 @@ func (s *Server) serveTLS() (err error) {
 			cp = x509.NewCertPool()
 		}
 
-		if cacert := s.runtimeConfig.ACMECACert; cacert != "" {
+		if cacert := s.options.ACMEIssuerCert; cacert != "" {
 			cp.AppendCertsFromPEM([]byte(cacert))
 		}
 
 		// caddy's ACME server (step-ca) doesn't specify an OCSP server
 		cfg.OCSP.DisableStapling = true
 
-		acme.CA = s.runtimeConfig.ACMECA
+		acme.CA = s.options.ACMEIssuer
 		acme.TrustedRoots = cp
 		acme.DisableTLSALPNChallenge = true
 	} else {
@@ -87,7 +87,7 @@ func (s *Server) serveTLS() (err error) {
 			},
 		}
 
-		certificateDomains = append(slices.Clone(s.config.Domains), wildcardDomain)
+		certificateDomains = append(slices.Clone(s.options.Config.Domains), wildcardDomain)
 
 		rs := certmagic_redis.New()
 		rs.Address = []string{rc.Address}
@@ -107,7 +107,7 @@ func (s *Server) serveTLS() (err error) {
 
 	ln, err := listenfd.GetListener(
 		1,
-		net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.Port)),
+		net.JoinHostPort(s.options.ListenAddress, strconv.Itoa(s.options.Port)),
 		log.Named("listenfd"),
 	)
 	if err != nil {
@@ -123,7 +123,7 @@ func (s *Server) serveTLS() (err error) {
 			}
 			url := r.URL
 			url.Scheme = "https"
-			port := s.config.BaseURL.Port()
+			port := s.options.Config.BaseURL.Port()
 			if port == "" {
 				url.Host = r.Host
 			} else {
@@ -132,9 +132,9 @@ func (s *Server) serveTLS() (err error) {
 					log.Warn("error splitting host and port", "error", err)
 					host = r.Host
 				}
-				url.Host = net.JoinHostPort(host, s.config.BaseURL.Port())
+				url.Host = net.JoinHostPort(host, s.options.Config.BaseURL.Port())
 			}
-			if slices.Contains(s.config.Domains, r.Host) {
+			if slices.Contains(s.options.Config.Domains, r.Host) {
 				http.Redirect(w, r, url.String(), http.StatusMovedPermanently)
 			} else {
 				http.NotFound(w, r)
@@ -146,18 +146,18 @@ func (s *Server) serveTLS() (err error) {
 			log.Error("error in http handler", "error", err)
 		}
 	}(ln, &http.Server{
-		ReadHeaderTimeout: s.ReadHeaderTimeout,
-		ReadTimeout:       s.ReadTimeout,
-		WriteTimeout:      s.WriteTimeout,
-		IdleTimeout:       s.IdleTimeout,
+		ReadHeaderTimeout: ReadHeaderTimeout,
+		ReadTimeout:       ReadTimeout,
+		WriteTimeout:      WriteTimeout,
+		IdleTimeout:       IdleTimeout,
 	})
 
 	log.Debug(
 		"starting certmagic",
 		"http_port",
-		s.runtimeConfig.Port,
+		s.options.Port,
 		"https_port",
-		s.runtimeConfig.TLSPort,
+		s.options.TLSPort,
 	)
 	cfg.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(cfg, *acme)}
 	err = cfg.ManageAsync(context.TODO(), certificateDomains)
@@ -169,7 +169,7 @@ func (s *Server) serveTLS() (err error) {
 
 	sln, err := listenfd.GetListenerTLS(
 		0,
-		net.JoinHostPort(s.runtimeConfig.ListenAddress, strconv.Itoa(s.runtimeConfig.TLSPort)),
+		net.JoinHostPort(s.options.ListenAddress, strconv.Itoa(s.options.TLSPort)),
 		tlsConfig,
 		log.Named("listenfd"),
 	)
@@ -177,5 +177,5 @@ func (s *Server) serveTLS() (err error) {
 		return errors.WithMessage(err, "could not bind tls socket")
 	}
 
-	return s.Serve(sln)
+	return s.server.Serve(sln)
 }
diff --git a/internal/storage/sqlite/reader.go b/internal/storage/sqlite/reader.go
index fe5da7e..fefeb74 100644
--- a/internal/storage/sqlite/reader.go
+++ b/internal/storage/sqlite/reader.go
@@ -19,12 +19,7 @@ type Reader struct {
 	}
 }
 
-func NewReader(dbPath string, log *log.Logger) (r *Reader, err error) {
-	db, err := openDB(dbPath)
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not open SQLite database")
-	}
-
+func NewReader(db *sql.DB, log *log.Logger) (r *Reader, err error) {
 	r = &Reader{
 		log: log,
 		db:  db,
diff --git a/internal/storage/sqlite/writer.go b/internal/storage/sqlite/writer.go
index c35494d..ec0d6d0 100644
--- a/internal/storage/sqlite/writer.go
+++ b/internal/storage/sqlite/writer.go
@@ -32,7 +32,7 @@ type Options struct {
 	Compress bool
 }
 
-func openDB(dbPath string) (*sql.DB, error) {
+func OpenDB(dbPath string) (*sql.DB, error) {
 	return sql.Open(
 		"sqlite",
 		fmt.Sprintf(
@@ -44,14 +44,9 @@ func openDB(dbPath string) (*sql.DB, error) {
 	)
 }
 
-func NewWriter(dbPath string, logger *log.Logger, opts *Options) (*Writer, error) {
-	db, err := openDB(dbPath)
-	if err != nil {
-		return nil, errors.WithMessage(err, "opening sqlite database")
-	}
-
+func NewWriter(db *sql.DB, logger *log.Logger, opts *Options) (*Writer, error) {
 	// WIP: only memory database for now
-	_, err = db.Exec(`
+	_, err := db.Exec(`
 		CREATE TABLE IF NOT EXISTS url (
 			id INTEGER PRIMARY KEY,
 			path TEXT NOT NULL
diff --git a/internal/server/dev.go b/internal/watcher/watcher.go
index 6fcc93e..33f8ead 100644
--- a/internal/server/dev.go
+++ b/internal/watcher/watcher.go
@@ -1,4 +1,4 @@
-package server
+package watcher
 
 import (
 	"fmt"
@@ -43,7 +43,7 @@ func ignored(pathname string) bool {
 	return slices.ContainsFunc(ignores, matches(path.Base(pathname)))
 }
 
-func NewFileWatcher(log *log.Logger) (*FileWatcher, error) {
+func New(log *log.Logger) (*FileWatcher, error) {
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not create watcher")
diff --git a/internal/website/mux.go b/internal/website/mux.go
index 05f6272..e4d4a42 100644
--- a/internal/website/mux.go
+++ b/internal/website/mux.go
@@ -2,19 +2,42 @@ package website
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"regexp"
+	"slices"
 	"strings"
 
+	"gitlab.com/tozd/go/errors"
+	"go.alanpearce.eu/website/internal/builder"
 	"go.alanpearce.eu/website/internal/config"
 	ihttp "go.alanpearce.eu/website/internal/http"
+	"go.alanpearce.eu/website/internal/server"
 	"go.alanpearce.eu/website/internal/storage"
+	"go.alanpearce.eu/website/internal/storage/sqlite"
+	"go.alanpearce.eu/website/internal/watcher"
 	"go.alanpearce.eu/website/templates"
 	"go.alanpearce.eu/x/log"
 
 	"github.com/benpate/digit"
 	"github.com/kevinpollet/nego"
+	"github.com/osdevisnot/sorvor/pkg/livereload"
 )
 
+type Options struct {
+	DBPath      string
+	Redirect    bool
+	Development bool
+	Config      *config.Config
+}
+
+type Website struct {
+	config *config.Config
+	log    *log.Logger
+	reader storage.Reader
+	*server.App
+}
+
 type webHandler func(http.ResponseWriter, *http.Request) *ihttp.Error
 
 type WrappedWebHandler struct {
@@ -51,22 +74,74 @@ func (fn WrappedWebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func NewMux(
-	cfg *config.Config,
-	reader storage.Reader,
+func New(
+	opts *Options,
 	log *log.Logger,
-) (mux *http.ServeMux, err error) {
-	mux = &http.ServeMux{}
+) (*Website, error) {
+	website := &Website{
+		config: opts.Config,
+		log:    log,
+	}
+	builderOptions := &builder.Options{}
+
+	mux := &http.ServeMux{}
 	templates.Setup()
 
+	cfg := opts.Config
+
+	db, err := sqlite.OpenDB(opts.DBPath)
+	if err != nil {
+		return nil, errors.WithMessage(err, "error opening database")
+	}
+
+	builderOptions.DB = db
+
+	if opts.Development {
+		liveReload := livereload.New()
+		mux.Handle("/_/reload", liveReload)
+		liveReload.Start()
+		fw, err := watcher.New(log.Named("watcher"))
+		if err != nil {
+			return nil, errors.WithMessage(err, "could not create file watcher")
+		}
+		for _, dir := range []string{"content", "static", "templates", "internal/builder"} {
+			err := fw.AddRecursive(dir)
+			if err != nil {
+				return nil, errors.WithMessagef(
+					err,
+					"could not add directory %s to file watcher",
+					dir,
+				)
+			}
+		}
+		// TODO implement rebuilding
+		// go fw.Start(func(filename string) {
+		// 	log.Info("rebuilding site", "changed_file", filename)
+		// 	err := rebuild(builderOptions, cfg, log)
+		// 	if err != nil {
+		// 		log.Error("error rebuilding site", "error", err)
+		// 	}
+		// })
+	}
+
+	err = rebuild(builderOptions, cfg, log)
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not build site")
+	}
+
+	website.reader, err = sqlite.NewReader(db, log.Named("reader"))
+	if err != nil {
+		return nil, errors.WithMessage(err, "error creating sqlite reader")
+	}
+
 	mux.Handle("/", wrapHandler(cfg, func(w http.ResponseWriter, r *http.Request) *ihttp.Error {
-		urlPath, shouldRedirect := reader.CanonicalisePath(r.URL.Path)
+		urlPath, shouldRedirect := website.reader.CanonicalisePath(r.URL.Path)
 		if shouldRedirect {
 			http.Redirect(w, r, urlPath, 302)
 
 			return nil
 		}
-		file, err := reader.GetFile(urlPath)
+		file, err := website.reader.GetFile(urlPath)
 		if err != nil {
 			log.Error("error getting file from reader", "err", err)
 			return &ihttp.Error{
@@ -130,5 +205,57 @@ func NewMux(
 			http.Redirect(w, r, u.String(), 302)
 		})
 
-	return mux, nil
+	website.App = &server.App{
+		Domain:  cfg.Domains[0],
+		Handler: mux,
+	}
+
+	return website, nil
+}
+
+func (website *Website) MakeRedirectorApp() *server.App {
+	mux := http.NewServeMux()
+
+	re := regexp.MustCompile(
+		"^(.*)\\." + strings.ReplaceAll(website.config.WildcardDomain, ".", `\.`) + "$",
+	)
+	replace := "${1}." + website.config.Domains[0]
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		switch {
+		case slices.Contains(website.config.Domains, r.Host):
+			path, _ := website.reader.CanonicalisePath(r.URL.Path)
+			http.Redirect(
+				w,
+				r,
+				website.config.BaseURL.JoinPath(path).String(),
+				http.StatusMovedPermanently,
+			)
+		case re.MatchString(r.Host):
+			url := website.config.BaseURL.JoinPath()
+			url.Host = re.ReplaceAllString(r.Host, replace)
+			http.Redirect(w, r, url.String(), http.StatusTemporaryRedirect)
+		case true:
+			http.NotFound(w, r)
+		}
+	})
+
+	return &server.App{
+		Handler: mux,
+	}
+}
+
+func updateCSPHashes(config *config.Config, r *builder.Result) {
+	for i, h := range r.Hashes {
+		config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h)
+	}
+}
+
+func rebuild(builderConfig *builder.Options, config *config.Config, log *log.Logger) error {
+	r, err := builder.BuildSite(builderConfig, config, log.Named("builder"))
+	if err != nil {
+		return errors.WithMessage(err, "could not build site")
+	}
+	updateCSPHashes(config, r)
+
+	return nil
 }