Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr. konto, hver med sine egne scopes — så en forbundet kalender rører ikke en Gmail, der virker, og at afbryde den ene tager ikke den anden med. Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde, hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket token-lager der skrives* bag et opslag — og går det galt, går det galt ved at overskrive en virkende Gmail-forbindelse med kalender-tokens. Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et månedsgitter bladrer, og et kald til Google pr. piletast er en kalender, der er langsommere end den kalender, den viser. Vinduet har en kant, og endpointet siger hvor den er: et tomt svar uden forklaring er en kalender, der lydløst påstår, at en dag er fri.
216 lines
7.4 KiB
Go
216 lines
7.4 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
|
|
|
|
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
|
|
// Empty means a key file beside the data — see internal/secret.
|
|
SecretKey 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
|
|
|
|
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
|
// from its own interface rather than from another browser tab.
|
|
//
|
|
// A container cannot replace its own image from the inside. Restarting is the
|
|
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
|
// what this needs is a way to ask. All three are required together; any one
|
|
// alone cannot do anything.
|
|
//
|
|
// The token is a panel credential with control over that server. Anybody who
|
|
// can read this environment can restart it, which is true of anybody with the
|
|
// host anyway. It is never sent to a browser: the settings page is told only
|
|
// whether it is set.
|
|
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
|
// front of this server. Configurable so a test can prove the budget is applied
|
|
// without spending the real one on every run.
|
|
GmailSyncBudget time.Duration
|
|
|
|
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
|
// the same reason: a slow Google held the request open past what Cloudflare is
|
|
// willing to wait, and the browser got Cloudflare's HTML instead of an error
|
|
// this server could explain.
|
|
CalendarSyncBudget time.Duration
|
|
|
|
// Where Google is. Empty in production; a test points them at a server it
|
|
// controls, which is the only way to exercise a Google that is slow or refuses.
|
|
// The token endpoint is shared — Gmail and Calendar sign in through the same
|
|
// registration — and the two APIs are not.
|
|
GoogleTokenURL string
|
|
GmailAPIURL string
|
|
CalendarAPIURL string
|
|
|
|
PanelURL string
|
|
PanelToken string
|
|
PanelServerID string
|
|
|
|
// 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", ""),
|
|
GmailSyncBudget: 25 * time.Second,
|
|
CalendarSyncBudget: 25 * time.Second,
|
|
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
|
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
|
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
|
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
|
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
|
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" }
|
|
|
|
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
|
|
//
|
|
// One callback that decided from server-side state which of the two flows had come
|
|
// back would save the operator one line in Google Cloud, and would put the choice
|
|
// of *which token store to write* behind a lookup. When that goes wrong it goes
|
|
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
|
|
// nobody would think to look for. Two paths cannot do that.
|
|
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/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
|
|
}
|