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 } func New( opts *Options, log *log.Logger, ) (*Website, error) { website := &Website{ log: log, App: &server.App{ Shutdown: func() {}, }, } builderOptions := &builder.Options{ Source: opts.Source, Development: opts.Development, Destination: opts.Destination, } 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") } mux := ihttp.NewServeMux() mux.HandleError(func(err *ihttp.Error, w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get("Accept"), "text/html") { w.WriteHeader(err.Code) err := templates.Error(cfg, 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) } }) 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.HandleFunc("/", 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", "zstd": 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 }) 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) *ihttp.Error { if r.URL.Query().Get("resource") == acctResource { w.Header().Add("Content-Type", "application/jrd+json") w.Header().Add("Access-Control-Allow-Origin", "*") if err := json.NewEncoder(w).Encode(me); err != nil { return &ihttp.Error{ Code: http.StatusInternalServerError, Cause: err, } } } return nil }, ) const oidcPath = "/.well-known/openid-configuration" mux.Handle(oidcPath, ihttp.RedirectHandler(cfg.OIDCHost.JoinPath(oidcPath), 302)) website.config = cfg website.App.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) ihttp.Redirect( w, r, website.config.BaseURL.JoinPath(path), http.StatusMovedPermanently, ) case re.MatchString(r.Host): url := website.config.BaseURL.JoinPath() url.Host = re.ReplaceAllString(r.Host, replace) ihttp.Redirect(w, r, url, http.StatusTemporaryRedirect) case true: http.NotFound(w, r) } }) return &server.App{ WildcardDomain: website.config.WildcardDomain, Domains: website.config.Domains, 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 }