package website import ( "encoding/json" "fmt" "net/http" "os" "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/vcs" "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 `conf:"default:../website"` Destination string `conf:"default:public"` Redirect bool `conf:"default:true"` Development bool `conf:"default:false,flag:dev"` BaseURL config.URL `conf:"default:localhost"` VCS struct { Branch string `conf:"default:main"` RemoteURL config.URL `conf:"default:https://git.alanpearce.eu/website"` } } 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 { config *config.Config handler webHandler log *log.Logger } func wrapHandler(cfg *config.Config, webHandler webHandler, log *log.Logger) WrappedWebHandler { return WrappedWebHandler{ config: cfg, handler: webHandler, log: log, } } func (fn WrappedWebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { if fail := recover(); fail != nil { w.WriteHeader(http.StatusInternalServerError) fn.log.Error("runtime panic!", "error", fail) } }() if err := fn.handler(w, r); err != nil { if strings.Contains(r.Header.Get("Accept"), "text/html") { w.WriteHeader(err.Code) err := templates.Error(fn.config, r.URL.Path, err).Render(r.Context(), w) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } else { http.Error(w, err.Message, err.Code) } } } func New( opts *Options, log *log.Logger, ) (*Website, error) { mux := http.NewServeMux() website := &Website{ log: log, App: &server.App{ Handler: mux, }, } builderOptions := &builder.Options{ Source: opts.Source, Development: opts.Development, Destination: opts.Destination, } templates.Setup() repo, err, exists := vcs.CloneOrOpen(&vcs.Options{ LocalPath: opts.Source, RemoteURL: opts.VCS.RemoteURL, Branch: opts.VCS.Branch, }, log.Named("vcs")) if err != nil { return nil, errors.WithMessage(err, "could not open repository") } if exists && !opts.Development { _, err := repo.Update() if err != nil { return nil, errors.WithMessage(err, "could not update repository") } } log.Debug("getting config from source", "source", opts.Source) cfg, err := config.GetConfig(opts.Source, log) if err != nil { return nil, errors.WithMessage(err, "could not load configuration") } if opts.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) website.App.Shutdown = func() { os.RemoveAll(tmpdir) } builderOptions.Destination = tmpdir cfg.CSP.ScriptSrc = slices.Insert(cfg.CSP.ScriptSrc, 0, "'unsafe-inline'") cfg.CSP.ConnectSrc = slices.Insert(cfg.CSP.ConnectSrc, 0, "'self'") cfg.BaseURL = opts.BaseURL } website.Domain = cfg.BaseURL.Hostname() 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 := website.reader.CanonicalisePath(r.URL.Path) if shouldRedirect { http.Redirect(w, r, urlPath, 302) return nil } file, err := website.reader.GetFile(urlPath) if err != nil { return &ihttp.Error{ Message: "Error reading file", Code: http.StatusInternalServerError, } } if file == nil { return &ihttp.Error{ Message: "File not found", Code: http.StatusNotFound, } } w.Header().Add("ETag", file.Etag) w.Header().Add("Vary", "Accept-Encoding") w.Header().Add("Content-Security-Policy", cfg.CSP.String()) for k, v := range cfg.Extra.Headers { w.Header().Add(k, v) } enc := nego.NegotiateContentEncoding(r, file.AvailableEncodings()...) switch enc { case "br", "gzip": w.Header().Add("Content-Encoding", enc) } w.Header().Add("Content-Type", file.ContentType) http.ServeContent(w, r, file.Path, file.LastModified, file.Encodings[enc]) return nil }, log)) var acctResource = "acct:" + cfg.Email me := digit.NewResource(acctResource). Link("http://openid.net/specs/connect/1.0/issuer", "", cfg.OIDCHost.String()) mux.HandleFunc("/.well-known/webfinger", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("resource") == acctResource { obj, err := json.Marshal(me) if err != nil { http.Error( w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError, ) return } w.Header().Add("Content-Type", "application/jrd+json") w.Header().Add("Access-Control-Allow-Origin", "*") _, err = w.Write(obj) if err != nil { log.Warn("error writing webfinger request", "error", err) } } }) const oidcPath = "/.well-known/openid-configuration" mux.HandleFunc( oidcPath, func(w http.ResponseWriter, r *http.Request) { u := cfg.OIDCHost.JoinPath(oidcPath) http.Redirect(w, r, u.String(), 302) }) website.config = cfg 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 }