Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel: kalenderen læses, og der skrives ingenting tilbage. **OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som *Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord, man kan handle på, frem for at vise Googles egen skærm, der ikke gør. Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det næste stykke, og det eneste, der låser den private konto op. `internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails: én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved at overskrive en Gmail-forbindelse, der virkede. Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer alle på "hvad gør en besked til en opgave", og ingenting her bliver til en opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021 skulle igennem. Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit, og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret ikke stille viser noget forkert. `CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre færdigt ved en andens møde. To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den må stå i en style, og linket bliver kun til et href, hvis det begynder med https. Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
4.9 KiB
Go
154 lines
4.9 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 {
|
|
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)
|
|
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
|
|
|
// 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)
|
|
|
|
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.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)
|
|
}
|