feat(store): SQLite schema, migrations and Danish-aware full-text search
Initial data model for verdande: users, sessions, invites, projects, sections, tasks, labels, reminders, comments, attachments, activity and filters, plus personal API tokens. Search indexes a generated `fold` column alongside the original text. FTS5's remove_diacritics does not touch ø and æ — Unicode treats them as letters rather than accented forms — so without it a Danish user cannot find "grøn" by typing "gron". MatchExpr quotes every term so free text containing FTS5 operators cannot reach the query planner as syntax. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// Package config loads verdande's settings from the environment.
|
||||
//
|
||||
// Everything is env-driven because verdande ships as a Rune: the panel hands the
|
||||
// container an environment and a /data volume, and there is nowhere to put a config
|
||||
// file that the operator would ever see.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// BaseURL is the address verdande answers on from outside. Invite links,
|
||||
// password resets, ICS feeds and OAuth redirects are all built from it, so it
|
||||
// has to be the public address, not the listen address.
|
||||
BaseURL string
|
||||
Addr string
|
||||
DataDir string
|
||||
|
||||
SessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
Dev bool
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
// StartTLS is the common case for a self-hosted Mailcow on 587. Port 465 is
|
||||
// implicit TLS instead and sets this false.
|
||||
StartTLS bool
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// Configured reports whether outbound mail can be sent. Without a host verdande
|
||||
// still runs — it just surfaces invite links in the UI instead of mailing them.
|
||||
func (s SMTP) Configured() bool { return s.Host != "" }
|
||||
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
BaseURL: env("VERDANDE_BASE_URL", "http://localhost:8080"),
|
||||
Addr: env("VERDANDE_ADDR", ":8080"),
|
||||
DataDir: env("VERDANDE_DATA_DIR", "/data"),
|
||||
TrashRetention: 30 * 24 * time.Hour,
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.ResetTTL, err = envDuration("VERDANDE_RESET_TTL", time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := envInt("VERDANDE_SMTP_PORT", 587)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SMTP = SMTP{
|
||||
Host: env("VERDANDE_SMTP_HOST", ""),
|
||||
Port: port,
|
||||
Username: env("VERDANDE_SMTP_USER", ""),
|
||||
Password: env("VERDANDE_SMTP_PASS", ""),
|
||||
From: env("VERDANDE_SMTP_FROM", "verdande@localhost"),
|
||||
// Port 465 is implicit TLS from the first byte; everything else negotiates
|
||||
// with STARTTLS. An operator can still override the guess.
|
||||
StartTLS: envBool("VERDANDE_SMTP_STARTTLS", port != 465),
|
||||
Insecure: envBool("VERDANDE_SMTP_INSECURE", false),
|
||||
}
|
||||
|
||||
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
|
||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||
func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) (int, error) {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q is not a number", key, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func envDuration(key string, def time.Duration) (time.Duration, error) {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q is not a duration (try 720h)", key, v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
Reference in New Issue
Block a user