Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
// 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
|
|
|
|
// Gmail is the OAuth client the operator registered in Google Cloud. It
|
|
// belongs to the instance: one registration, any number of users.
|
|
GmailClientID string
|
|
GmailClientSecret string
|
|
|
|
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
|
|
// for: a self-hosted app that reaches out without being told to has broken
|
|
// the deal its operator made by self-hosting.
|
|
UpdateCheck bool
|
|
|
|
// 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),
|
|
|
|
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", 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") }
|
|
|
|
// GmailRedirectURL has to match what was registered in Google Cloud exactly,
|
|
// including the scheme — which is why it is derived from BaseURL rather than
|
|
// configured separately and left to drift out of step with it.
|
|
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
|
|
|
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
|
|
}
|