all repos — homestead @ 5ce60eee31c403537efd6ddb20308a3652a928b2

Code for my website

re-organise everything

Alan Pearce
commit

5ce60eee31c403537efd6ddb20308a3652a928b2

parent

4f48df92fa20e1c9cb4833400aabbeaf5c67718b

M cmd/build/main.gocmd/build/main.go
@@ -13,29 +13,29 @@ "gitlab.com/tozd/go/errors"
) 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) + _, err = builder.BuildSite(builderOptions, cfg, log) if err != nil { log.Error("could not build site", "error", err) os.Exit(1)
M cmd/server/main.gocmd/server/main.go
@@ -3,19 +3,40 @@
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"` + Source string `conf:"default:../website"` + Destination string `conf:"default:public"` + 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,25 +44,71 @@ os.Exit(1)
} panic("parsing runtime configuration" + err.Error()) } - log := log.Configure(!runtimeConfig.Development) + log := log.Configure(!options.Development) + + cfg, err := config.GetConfig(options.Source, log) + if err != nil { + log.Fatal("could not read config", "error", err) + } + + // Domains? + webOpts := &website.Options{ + Source: options.Source, + Destination: options.Destination, + Redirect: options.Redirect, + Development: options.Development, + Config: cfg, + } - if runtimeConfig.Development { + if options.Development { tmpdir, err := os.MkdirTemp("", "website") if err != nil { log.Fatal("could not create temporary directory", "error", err) } log.Info("using temporary directory", "dir", tmpdir) defer os.RemoveAll(tmpdir) - runtimeConfig.Root = tmpdir + webOpts.Destination = tmpdir + + 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(&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, + } + + 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()
@@ -58,3 +125,18 @@ log.Debug("calling stop")
<-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)), + }, + } +}
M internal/builder/builder.gointernal/builder/builder.go
@@ -2,6 +2,7 @@ package builder
import ( "context" + "database/sql" "fmt" "io" "io/fs"
@@ -24,10 +25,11 @@ mapset "github.com/deckarep/golang-set/v2"
"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"` Development bool `conf:"default:false,flag:dev"` + DB *sql.DB } type Result struct {
@@ -74,27 +76,21 @@ }
func build( storage storage.Writer, - ioConfig *IOConfig, + options *Options, config *config.Config, log *log.Logger, ) (*Result, error) { ctx := context.TODO() buf := new(buffer.Buffer) - joinSource := joinSourcePath(ioConfig.Source) + joinSource := joinSourcePath(options.Source) - log.Debug("output", "dir", ioConfig.Destination) r := &Result{ Hashes: make([]string, 0), } - err := copyRecursive(storage, joinSource("static")) - if err != nil { - return nil, errors.WithMessage(err, "could not copy static files") - } - - log.Debug("reading posts") + log.Debug("reading posts", "source", options.Source) posts, tags, err := content.ReadPosts(&content.Config{ - Root: joinSource("content"), + Root: options.Source, InputDir: "post", }, log.Named("content")) if err != nil {
@@ -190,7 +186,7 @@ return nil, err
} log.Debug("rendering feed styles") - feedStyles, err := renderFeedStyles(ioConfig.Source) + feedStyles, err := renderFeedStyles(options.Source) if err != nil { return nil, errors.WithMessage(err, "could not render feed styles") }
@@ -212,7 +208,7 @@ }
r.Hashes = append(r.Hashes, h) log.Debug("rendering homepage") - _, text, err := content.GetPost(joinSource(filepath.Join("content", "index.md"))) + _, text, err := content.GetPost(joinSource("index.md")) if err != nil { return nil, err }
@@ -243,7 +239,7 @@ return nil, err
} log.Debug("rendering robots.txt") - rob, err := renderRobotsTXT(ioConfig.Source, config) + rob, err := renderRobotsTXT(config) if err != nil { return nil, err }
@@ -258,21 +254,21 @@
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() - storage, err := files.NewWriter(ioConfig.Destination, log, &files.Options{ - Compress: !ioConfig.Development, + storage, err := files.NewWriter(options.Destination, log, &files.Options{ + Compress: !options.Development, }) if err != nil { return nil, errors.WithMessage(err, "could not create storage writer") } - return build(storage, ioConfig, cfg, log) + return build(storage, options, cfg, log) }
M internal/builder/template.gointernal/builder/template.go
@@ -5,7 +5,6 @@ "bytes"
"encoding/xml" "io" "os" - "path/filepath" "strings" "text/template"
@@ -28,8 +27,8 @@ "xhtml": "http://www.w3.org/1999/xhtml",
} ) -func loadCSS(source string) { - bytes, err := os.ReadFile(filepath.Join(source, "templates/style.css")) +func loadCSS() { + bytes, err := os.ReadFile("templates/style.css") if err != nil { panic(err) }
@@ -54,9 +53,9 @@ func (q *QueryDocument) Find(selector string) *QuerySelection {
return &QuerySelection{q.Document.Find(selector)} } -func renderRobotsTXT(source string, config *config.Config) (io.Reader, error) { +func renderRobotsTXT(config *config.Config) (io.Reader, error) { r, w := io.Pipe() - tpl, err := template.ParseFiles(filepath.Join(source, "templates/robots.tmpl")) + tpl, err := template.ParseFiles("templates/robots.tmpl") if err != nil { return nil, err }
@@ -119,7 +118,7 @@ return buf, nil
} func renderFeedStyles(source string) (*strings.Reader, error) { - tpl, err := template.ParseFiles(filepath.Join(source, "templates/feed-styles.xsl")) + tpl, err := template.ParseFiles("templates/feed-styles.xsl") if err != nil { return nil, err }
M internal/content/posts.gointernal/content/posts.go
@@ -88,13 +88,20 @@
func ReadPosts(config *Config, log *log.Logger) ([]Post, Tags, error) { tags := mapset.NewSet[string]() posts := []Post{} + log.Debug("reading posts", "root", config.Root, "input_dir", config.InputDir) subdir := filepath.Join(config.Root, config.InputDir) files, err := os.ReadDir(subdir) if err != nil { return nil, nil, errors.WithMessagef(err, "could not read post directory %s", subdir) } - outputReplacer := strings.NewReplacer(".md", "/index.html") - urlReplacer := strings.NewReplacer(".md", "/") + outputReplacer := strings.NewReplacer( + "index.md", "index.html", + ".md", "/index.html", + ) + urlReplacer := strings.NewReplacer( + "index.md", "", + ".md", "/", + ) for _, f := range files { fn := f.Name() if !f.IsDir() && path.Ext(fn) == ".md" {
A internal/server/app.go
@@ -0,0 +1,10 @@
+package server + +import ( + "net/http" +) + +type App struct { + Domain string + Handler http.Handler +}
M internal/server/dev.gointernal/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")
M internal/server/logging.gointernal/server/logging.go
@@ -27,9 +27,6 @@ func wrapHandlerWithLogging(wrappedHandler http.Handler, log *log.Logger) http.Handler {
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,
M internal/server/server.gointernal/server/server.go
@@ -3,26 +3,12 @@
import ( "context" "fmt" - "net" "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" "time" - "go.alanpearce.eu/website/internal/builder" cfg "go.alanpearce.eu/website/internal/config" - "go.alanpearce.eu/website/internal/storage/files" - "go.alanpearce.eu/website/internal/vcs" - "go.alanpearce.eu/website/internal/website" "go.alanpearce.eu/x/log" - "github.com/ardanlabs/conf/v3" - "github.com/osdevisnot/sorvor/pkg/livereload" "gitlab.com/tozd/go/errors" )
@@ -30,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 { - Root string `conf:"default:public"` - 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"` +type Options struct { + Development bool + ListenAddress string + Port int + TLSPort int + TLS bool - Development bool `conf:"default:false,flag:dev"` - ACMECA string `conf:"env:ACME_CA"` - ACMECACert string `conf:"env:ACME_CA_CERT"` - Domains string + ACMEIssuer string + ACMEIssuerCert string + + Config *cfg.Config } type Server struct { - *http.Server - runtimeConfig *Config - config *cfg.Config - log *log.Logger -} - -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)), - }, - } -} - -func updateCSPHashes(config *cfg.Config, r *builder.Result) { - for i, h := range r.Hashes { - config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h) - } + mux *http.ServeMux + options *Options + log *log.Logger + server *http.Server } func serverHeaderHandler(wrappedHandler http.Handler) http.Handler {
@@ -88,136 +50,37 @@ wrappedHandler.ServeHTTP(w, r)
}) } -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) +func New(options *Options, log *log.Logger) (*Server, error) { + fixupMIMETypes(log) - return nil + return &Server{ + mux: http.NewServeMux(), + log: log, + options: options, + }, nil } -func New(runtimeConfig *Config, log *log.Logger) (*Server, error) { - builderConfig := &builder.IOConfig{ - Destination: runtimeConfig.Root, - Development: runtimeConfig.Development, - } +func (s *Server) HostApp(app *App) { + s.mux.Handle(app.Domain+"/", app.Handler) +} - if !runtimeConfig.Development { - vcsConfig := &vcs.Config{} - _, err := conf.Parse("VCS", vcsConfig) - if err != nil { - return nil, err - } - if vcsConfig.LocalPath != "" { - _, err = vcs.CloneOrUpdate(vcsConfig, log.Named("vcs")) - if err != nil { - return nil, err - } - err = os.Chdir(runtimeConfig.Root) - if err != nil { - return nil, err - } +func (s *Server) HostFallbackApp(app *App) { + s.mux.Handle("/", app.Handler) +} - builderConfig.Source = vcsConfig.LocalPath - - publicDir := filepath.Join(runtimeConfig.Root, "public") - builderConfig.Destination = publicDir - runtimeConfig.Root = publicDir - } else { - log.Warn("in production mode without VCS configuration") - } +func (s *Server) serve(tls bool) error { + if tls { + return s.serveTLS() } - 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) - } + return s.serveTCP() +} +func (s *Server) Start() error { top := http.NewServeMux() - - err = rebuild(builderConfig, config, log) - if err != nil { - return nil, err - } - - 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) - } - }) - } - - loggingMux := http.NewServeMux() - - log.Debug("registering content files", "root", runtimeConfig.Root) - reader, err := files.NewReader(runtimeConfig.Root, log.Named("files")) - if err != nil { - return nil, errors.WithMessage(err, "could not create file reader") - } - - mux, err := website.NewMux(config, reader, log.Named("website")) - if err != nil { - return nil, errors.WithMessage(err, "could not create website mux") - } - - 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) - } - top.Handle("/", serverHeaderHandler( - wrapHandlerWithLogging(loggingMux, log), + wrapHandlerWithLogging(s.mux, s.log), ), )
@@ -225,30 +88,15 @@ top.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
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") }
@@ -264,7 +112,7 @@ go func() {
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:
M internal/server/tcp.gointernal/server/tcp.go
@@ -9,12 +9,12 @@ )
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) }
M internal/server/tls.gointernal/server/tls.go
@@ -24,31 +24,31 @@ Username string `conf:"default:default"`
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 @@ log.Warn("could not get system certificate pool", "error", err)
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 @@ Logger: cfg.Logger,
}, } - 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 @@ }
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 @@ return
} 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 @@ if err != nil {
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 @@ if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
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 @@ tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...)
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 @@ if err != nil {
return errors.WithMessage(err, "could not bind tls socket") } - return s.Serve(sln) + return s.server.Serve(sln) }
M internal/website/mux.gointernal/website/mux.go
@@ -2,19 +2,43 @@ 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/files" + "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 { + Source string + Destination 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 +75,68 @@ }
} } -func NewMux( - cfg *config.Config, - reader *files.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{ + Source: opts.Source, + Development: opts.Development, + Destination: opts.Destination, + } + + mux := &http.ServeMux{} templates.Setup() + cfg := opts.Config + + err := rebuild(builderOptions, cfg, log) + if err != nil { + return nil, errors.WithMessage(err, "could not build site") + } + + 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") + } + err = fw.AddRecursive(opts.Source) + if err != nil { + return nil, errors.WithMessage( + err, + "could not add directory to file watcher", + ) + } + + 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) + } + }) + } + + website.reader, err = files.NewReader(builderOptions.Destination, 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 { return &ihttp.Error{ Message: "Error reading file",
@@ -129,5 +199,57 @@ u := cfg.OIDCHost.JoinPath(oidcPath)
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 }