CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til. **Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. **Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden: `notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk, og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads. **Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne; fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og delordssøgningen falder bort. bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest: titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren optælling laver, og den er let at lave og svær at få øje på. **En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — 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, og `updated_at` røres ikke: at lukke en konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste den rækkefølge væk, importen brugte en dag på at hente hjem. En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans, der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der siger fra. Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden: opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke projekter og opgaver en note peger på, men ikke hvad der står. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
5.6 KiB
Go
170 lines
5.6 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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|