package website
import (
"context"
"net/http"
"os"
"sync"
"time"
"alin.ovh/homestead/domain/analytics"
"alin.ovh/homestead/domain/analytics/goatcounter"
"alin.ovh/homestead/domain/analytics/nullcounter"
"alin.ovh/homestead/domain/calendar"
"alin.ovh/homestead/domain/content/fetcher"
"alin.ovh/homestead/domain/identity"
"alin.ovh/homestead/domain/web/server"
"alin.ovh/homestead/domain/web/templates"
"alin.ovh/homestead/shared/config"
"alin.ovh/homestead/shared/events"
"alin.ovh/homestead/shared/file"
ihttp "alin.ovh/homestead/shared/http"
"alin.ovh/homestead/shared/storage"
"alin.ovh/homestead/shared/storage/files"
"alin.ovh/x/log"
"github.com/Southclaws/fault"
"github.com/Southclaws/fault/fmsg"
"github.com/osdevisnot/sorvor/pkg/livereload"
)
type Options struct {
Root string `conf:"default:./website"`
Redirect bool `conf:"default:true"`
Development bool `conf:"default:false,flag:dev"`
FetchURL config.URL `conf:"default:https://ci.alin.ovh/archive/website/"`
BaseURL config.URL
CalendarURL config.URL
GoatcounterToken string
Redis *events.RedisOptions
LiveReload *livereload.LiveReload `conf:"-"`
}
type Website struct {
config *config.Config
siteSettings *templates.SiteSettings
counter analytics.Counter
log *log.Logger
reader storage.Reader
calendar *calendar.Calendar
identity *identity.Service
*server.App
}
var ExtraHeaders = map[string]string{
"Cache-Control": "max-age=14400",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cross-Origin-Resource-Policy": "same-site",
}
func New(
opts *Options,
log *log.Logger,
) (*Website, error) {
website := &Website{
log: log,
App: &server.App{
Shutdown: func() {},
},
}
err := prepareRootDirectory(opts.Root)
if err != nil {
return nil, fault.Wrap(err, fmsg.With("could not prepare root directory"))
}
var listener events.Listener
if opts.Redis.Address != "" {
log.Debug("using redis listener")
listener, err = events.NewRedisListener(opts.Redis, log.Named("redis"))
} else {
log.Debug("using file watcher")
listener, err = events.NewFileWatcher(log.Named("events"), "website")
}
if err != nil {
return nil, fault.Wrap(err, fmsg.With("could not create update listener"))
}
var cfg *config.Config
fetcher, err := fetcher.New(log.Named("fetcher"), &fetcher.Options{
FetchURL: opts.FetchURL,
RedisEnabled: opts.Redis.Address != "",
Root: opts.Root,
Listener: listener,
})
if err != nil {
return nil, fault.Wrap(err, fmsg.With("could not create fetcher"))
}
roots, err := fetcher.Subscribe()
if err != nil {
return nil, fault.Wrap(err, fmsg.With("could not set up fetcher"))
}
firstUpdate := make(chan bool)
go func() {
updated := sync.OnceFunc(func() {
firstUpdate <- true
close(firstUpdate)
for range time.Tick(15 * time.Minute) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := website.calendar.FetchIfNeeded(ctx)
if err != nil {
log.Warn("could not update calendar", "error", err)
}
cancel()
}
})
for root := range roots {
log.Debug("getting config from source", "source", root)
cfg, err = config.GetConfig(root, log)
if err != nil {
log.Panic("could not load configuration", "error", err)
}
website.config = cfg
website.siteSettings = &templates.SiteSettings{
Title: cfg.Title,
Language: cfg.Language,
Timezone: cfg.Timezone,
Menu: cfg.Menu,
InjectLiveReload: opts.Development,
}
website.calendar = calendar.New(&calendar.Options{
URL: opts.CalendarURL,
Timezone: cfg.Timezone,
Cache: !opts.Development,
}, log.Named("calendar"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := website.calendar.FetchIfNeeded(ctx); err != nil {
log.Error("could not fetch calendar", "error", err)
}
cancel()
if opts.GoatcounterToken == "" {
if !opts.Development {
log.Warn("in production without a goatcounter token")
}
website.counter = nullcounter.New(&nullcounter.Options{
Logger: log.Named("counter"),
})
} else {
website.counter = goatcounter.New(&goatcounter.Options{
Logger: log.Named("counter"),
URL: &cfg.GoatCounter,
Token: opts.GoatcounterToken,
})
}
if opts.BaseURL.URL != nil && opts.BaseURL.Hostname() != "" {
cfg.BaseURL = opts.BaseURL
}
website.Domain = cfg.BaseURL.Hostname()
website.reader, err = files.NewReader(root, log.Named("reader"))
if err != nil {
log.Panic("could not create database reader", "error", err)
}
updated()
}
}()
<-firstUpdate
website.identity = identity.New(cfg, log.Named("identity"))
mux := ihttp.NewServeMux(log.Named("http"))
mux.HandleError(website.ErrorHandler)
mux.Handle("/", website)
mux.HandleFunc("/calendar", website.Calendar)
website.identity.RegisterHandlers(mux)
if opts.Development {
staticHandler := func(w http.ResponseWriter, r *http.Request) ihttp.Error {
http.ServeFileFS(w, r, templates.Files, r.URL.Path)
return nil
}
mux.HandleFunc("/style.css", staticHandler)
}
website.Handler = analytics.CounterMiddleware(website.counter, mux)
return website, nil
}
func prepareRootDirectory(root string) error {
if !file.Exists(root) {
err := os.MkdirAll(root, 0o750)
if err != nil {
return fault.Wrap(err, fmsg.With("could not create root directory"))
}
}
return nil
}
domain/web/website.go (view raw)