all repos — homestead @ c4502da96d8899b770a621ce07b0a636a84f6f53

Code for my website

domain/web/website.go (view raw)

package website

import (
	"context"
	"net/http"
	"os"
	"path/filepath"
	"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/sqlite"
	"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 := fetcher.New(log.Named("fetcher"), &fetcher.Options{
		FetchURL:     opts.FetchURL,
		RedisEnabled: opts.Redis.Address != "",
		Root:         opts.Root,
		Listener:     listener,
	})
	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()

			siteDB := filepath.Join(root, "site.db")
			db, err := sqlite.OpenDB(siteDB)
			if err != nil {
				log.Panic("could not open database", "error", err)
			}

			website.reader, err = sqlite.NewReader(db, 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
}