diff options
author | Alan Pearce | 2025-01-30 22:16:09 +0100 |
---|---|---|
committer | Alan Pearce | 2025-01-30 22:16:09 +0100 |
commit | 99f8047ef20a64f948ac2b703c81eb49bed091c0 (patch) | |
tree | a0365a7b2e477467a91bef247db09624028e1807 | |
parent | 4566db657dab6af43f8fce814cd0e42cbcc788bf (diff) | |
download | website-sqlite.tar.lz website-sqlite.tar.zst website-sqlite.zip |
re-organise everything sqlite
-rw-r--r-- | cmd/build/main.go | 24 | ||||
-rw-r--r-- | cmd/server/main.go | 91 | ||||
-rw-r--r-- | internal/builder/builder.go | 21 | ||||
-rw-r--r-- | internal/server/app.go | 10 | ||||
-rw-r--r-- | internal/server/logging.go | 3 | ||||
-rw-r--r-- | internal/server/server.go | 217 | ||||
-rw-r--r-- | internal/server/tcp.go | 4 | ||||
-rw-r--r-- | internal/server/tls.go | 52 | ||||
-rw-r--r-- | internal/storage/sqlite/reader.go | 7 | ||||
-rw-r--r-- | internal/storage/sqlite/writer.go | 11 | ||||
-rw-r--r-- | internal/watcher/watcher.go (renamed from internal/server/dev.go) | 4 | ||||
-rw-r--r-- | internal/website/mux.go | 143 |
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 } |