Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.
Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.
Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.
Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.
Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.
Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.
Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.5 KiB
Go
187 lines
6.5 KiB
Go
// Command verdande is the whole application: API, WebSocket, background jobs and
|
|
// the web interface, in one binary with SQLite underneath. There is no second
|
|
// process to run and no database to provision.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
"github.com/kristianwind/verdande/internal/httpapi"
|
|
"github.com/kristianwind/verdande/internal/jobs"
|
|
"github.com/kristianwind/verdande/internal/secret"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// version is stamped at build time: -ldflags "-X main.version=v1.2.3"
|
|
var version = "dev"
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
// Not the edition's name: this fires before the configuration is read, so
|
|
// the edition is not known yet — and one of the things it reports is a
|
|
// VERDANDE_EDITION the program refused.
|
|
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log := httpapi.NewLogger(cfg.Dev)
|
|
// The edition's own name, so an urd instance's log says urd. The full edition
|
|
// still logs "starting verdande", which is what the release check reads.
|
|
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
|
|
|
// The data volume is the only state that matters; create the whole layout up
|
|
// front so a failure here is a startup error rather than a surprise at the
|
|
// first upload or the first backup at three in the morning.
|
|
for _, dir := range []string{cfg.DataDir, cfg.FilesDir(), cfg.BackupsDir()} {
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return fmt.Errorf("create %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
db, err := store.Open(cfg.DBPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
log.Info("database ready", "path", db.Path())
|
|
|
|
// Before anything reads a setting. The key lives outside the database on
|
|
// purpose: backups can be downloaded through the interface, so a token stored
|
|
// in the clear leaves with every copy anyone takes.
|
|
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db.UseSecrets(box)
|
|
|
|
// Noter, der stadig ligger åbne, lukkes nu.
|
|
//
|
|
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
|
|
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
|
|
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
|
|
// så den koster et gennemløb af id'er hver anden gang.
|
|
//
|
|
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
|
|
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
|
|
// er værre end en, der siger fra.
|
|
if sealed, err := db.SealNotes(context.Background()); err != nil {
|
|
return fmt.Errorf("seal notes: %w", err)
|
|
} else if sealed > 0 {
|
|
log.Info("notes sealed", "count", sealed)
|
|
}
|
|
|
|
// Names created before the app capitalised them on the way in — "andreas"
|
|
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
|
// caught up. A failure here does not stop the start: a lower-case first name is
|
|
// a blemish, not a broken instance.
|
|
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
|
log.Error("normalize user names", "err", err)
|
|
} else if fixed > 0 {
|
|
log.Info("user names normalized", "count", fixed)
|
|
}
|
|
|
|
httpapi.Version = version
|
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
|
|
|
// Background work: reminders, the nightly backup, and the trash emptying
|
|
// itself. Started before the listener so a reminder that came due while the
|
|
// process was down goes out immediately rather than on the next tick.
|
|
jobCtx, stopJobs := context.WithCancel(context.Background())
|
|
defer stopJobs()
|
|
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
|
runner.SyncGmail = api.SyncGmail
|
|
runner.SyncMailbox = api.SyncMailbox
|
|
runner.SyncCalendars = api.SyncCalendars
|
|
runner.Push = api.PushToUser
|
|
runner.SendBeacon = api.SendBeacon
|
|
runner.DailyPlans = api.DailyPlans
|
|
runner.NotifyOfUpdates = api.NotifyOfUpdates
|
|
runner.Start(jobCtx)
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: api,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
// No WriteTimeout: it would also cap the WebSocket and any long file
|
|
// download. Per-handler timeouts do that job with the right granularity.
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
// The version is repeated here rather than only on the "starting" line
|
|
// above: this is the line the Rune's done_regex matches, so it is the one
|
|
// sitting at the bottom of the panel console after a deploy — which is
|
|
// exactly when somebody wants to know what actually started.
|
|
log.Info("listening", "addr", cfg.Addr, "version", version)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}()
|
|
|
|
// SIGTERM is what Docker — and so the Yggdrasil panel — sends on stop. Draining
|
|
// in-flight requests before exiting is what keeps a restart from showing up as
|
|
// a handful of failed saves.
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
|
|
select {
|
|
case err := <-errc:
|
|
return fmt.Errorf("listen: %w", err)
|
|
case sig := <-stop:
|
|
log.Info("shutting down", "signal", sig.String())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
return fmt.Errorf("shutdown: %w", err)
|
|
}
|
|
|
|
// Let the background jobs finish the pass they are in. A shutdown during a
|
|
// backup would otherwise leave a partial file that looks like a backup.
|
|
stopJobs()
|
|
runner.Wait()
|
|
|
|
// WAL mode leaves recent writes in the -wal file. Checkpointing on the way out
|
|
// means a backup taken of a stopped container is complete.
|
|
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
|
|
log.Warn("wal checkpoint on shutdown", "err", err)
|
|
}
|
|
log.Info("stopped")
|
|
return nil
|
|
}
|
|
|
|
// webAssets returns the built frontend if it was compiled into this binary.
|
|
//
|
|
// The web build is embedded through a build tag so that `go build ./...` works on a
|
|
// clean checkout with no Node toolchain: without the tag the app serves the API
|
|
// alone and says so, rather than failing to compile because web/build is missing.
|
|
func webAssets(log logger) fs.FS {
|
|
assets, err := embeddedWeb()
|
|
if err != nil {
|
|
log.Warn("no web assets in this build; serving the API only", "err", err)
|
|
return nil
|
|
}
|
|
return assets
|
|
}
|
|
|
|
type logger interface {
|
|
Warn(msg string, args ...any)
|
|
}
|