Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77994eff09 | ||
|
|
b4038356f0 | ||
|
|
8a87cfeb44 | ||
|
|
3e52eea2da | ||
|
|
acd6784333 |
@@ -12,6 +12,25 @@ nowhere to put one that the operator would ever see.
|
|||||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||||
|
|
||||||
|
## Behind a proxy
|
||||||
|
|
||||||
|
verdande keys its login rate limit and its audit log on the caller's address, so
|
||||||
|
that address has to be one the caller cannot forge. A forwarded-for header is
|
||||||
|
believed only when the machine that connected is a trusted proxy; a connection
|
||||||
|
straight off the internet is keyed on the address it really came from.
|
||||||
|
|
||||||
|
| Variable | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
|
||||||
|
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
|
||||||
|
|
||||||
|
!!! warning "A proxy that does not overwrite the header"
|
||||||
|
The default is safe when your proxy replaces a client-supplied
|
||||||
|
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
|
||||||
|
do. If yours passes the client's own header through, narrow
|
||||||
|
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
|
||||||
|
choose the address they are rate-limited and logged as.
|
||||||
|
|
||||||
## Sessions and links
|
## Sessions and links
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|
|||||||
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPStep returns the step a code matched, and it must be the same step across
|
||||||
|
// the tolerance window — that number is what makes a code single-use, so a code
|
||||||
|
// entered a little early or late has to spend the same step, not a new one.
|
||||||
|
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
|
||||||
|
secret, _, err := NewTOTPSecret("verdande", "k@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
|
||||||
|
code := codeAt(t, secret, now)
|
||||||
|
|
||||||
|
want, err := TOTPStep(secret, code, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the current code did not match: %v", err)
|
||||||
|
}
|
||||||
|
// Verified one step late — the code is still valid, and must report the step it
|
||||||
|
// was issued at, not the step it was checked at.
|
||||||
|
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("code rejected one step late: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := TOTPStep(secret, "000000", now); err == nil {
|
||||||
|
t.Error("a wrong code returned a step")
|
||||||
|
}
|
||||||
|
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
|
||||||
|
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// codeAt produces the code an authenticator app would display at that moment,
|
// codeAt produces the code an authenticator app would display at that moment,
|
||||||
// using the same settings VerifyTOTP checks against.
|
// using the same settings VerifyTOTP checks against.
|
||||||
func codeAt(t *testing.T, secret string, now time.Time) string {
|
func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -82,6 +83,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTPStep verifies a code and returns the counter of the step it matched, so the
|
||||||
|
// caller can refuse to accept the same step twice.
|
||||||
|
//
|
||||||
|
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
|
||||||
|
// window is not enough to stop a replay: a code seen in flight stays valid for the
|
||||||
|
// rest of its period, and the fix is to record which step was spent and never
|
||||||
|
// accept that step or an earlier one again. That needs the number, so this walks
|
||||||
|
// the same window ValidateCustom would — one step either side — and reports the
|
||||||
|
// counter of the match. The comparison is constant time for the same reason the
|
||||||
|
// password check is: a code is a secret being checked against a computed value.
|
||||||
|
func TOTPStep(secret, code string, now time.Time) (int64, error) {
|
||||||
|
if secret == "" {
|
||||||
|
return 0, ErrNoTOTPSecret
|
||||||
|
}
|
||||||
|
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
|
||||||
|
|
||||||
|
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
|
||||||
|
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
|
||||||
|
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
|
||||||
|
Period: totpPeriod,
|
||||||
|
Skew: 0,
|
||||||
|
Digits: totpDigits,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||||
|
return at.Unix() / totpPeriod, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ErrInvalidCode
|
||||||
|
}
|
||||||
|
|
||||||
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
|
||||||
// and the hashes to store. Without these, losing a device means losing the account,
|
// and the hashes to store. Without these, losing a device means losing the account,
|
||||||
// and the only fix left is editing the database by hand.
|
// and the only fix left is editing the database by hand.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -80,6 +81,26 @@ type Config struct {
|
|||||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||||
TrashRetention time.Duration
|
TrashRetention time.Duration
|
||||||
|
|
||||||
|
// TrustedProxies are the peers whose forwarded-for header may be believed.
|
||||||
|
//
|
||||||
|
// The rate limiter and the audit log key on the caller's address, and that
|
||||||
|
// address is only as honest as wherever it was read. Read it from a header that
|
||||||
|
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
|
||||||
|
// every request and to write someone else's IP into the log. So the header is
|
||||||
|
// believed only when the machine that *connected* is one of these — a reverse
|
||||||
|
// proxy or tunnel the operator put there — and ignored when the connection came
|
||||||
|
// straight off the internet.
|
||||||
|
//
|
||||||
|
// The default is "the peer is on a private network", which is exactly the shape
|
||||||
|
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
|
||||||
|
// host or the docker bridge, so its address is loopback or RFC1918. A directly
|
||||||
|
// exposed instance sees a public peer, trusts nothing, and keys on the real
|
||||||
|
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
|
||||||
|
// or "none" to trust no proxy at all.
|
||||||
|
TrustedProxies []netip.Prefix
|
||||||
|
// RealIPHeader is where the client address is read from, for a trusted peer.
|
||||||
|
RealIPHeader string
|
||||||
|
|
||||||
Dev bool
|
Dev bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +170,70 @@ func Load() (*Config, error) {
|
|||||||
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
|
||||||
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
|
||||||
|
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
|
||||||
|
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
|
||||||
|
// families' link-local. A proxy an operator stands up in front of this server is
|
||||||
|
// on one of these; a stranger connecting directly is on none.
|
||||||
|
var defaultTrustedProxies = mustPrefixes(
|
||||||
|
"127.0.0.0/8", "::1/128",
|
||||||
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"fc00::/7",
|
||||||
|
"169.254.0.0/16", "fe80::/10",
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTrustedProxies reads the override. Empty keeps the private-network default;
|
||||||
|
// the literal "none" trusts no proxy at all, for an instance that is exposed
|
||||||
|
// directly and must never believe a forwarded header.
|
||||||
|
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return defaultTrustedProxies, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(raw, "none") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
|
||||||
|
// naming one proxy does not have to know CIDR notation to do it.
|
||||||
|
if !strings.Contains(part, "/") {
|
||||||
|
addr, err := netip.ParseAddr(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
|
||||||
|
}
|
||||||
|
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := netip.ParsePrefix(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
|
||||||
|
}
|
||||||
|
out = append(out, p.Masked())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustPrefixes(cidrs ...string) []netip.Prefix {
|
||||||
|
out := make([]netip.Prefix, 0, len(cidrs))
|
||||||
|
for _, c := range cidrs {
|
||||||
|
out = append(out, netip.MustParsePrefix(c))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
|
// 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) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
|
||||||
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
|
||||||
|
|||||||
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||||
|
if err == nil {
|
||||||
|
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||||
|
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||||
|
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||||
|
// the step here makes it single-use: a second presentation of the same code,
|
||||||
|
// or an earlier one, finds the step already gone.
|
||||||
|
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||||
|
if cerr != nil {
|
||||||
|
s.internal(w, r, "consume totp step", cerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !fresh {
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||||
|
"that verification code has already been used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A recovery code is accepted in the same field. Making somebody find a
|
// A recovery code is accepted in the same field. Making somebody find a
|
||||||
// different form while locked out of their account is a poor time to
|
// different form while locked out of their account is a poor time to
|
||||||
|
|||||||
@@ -372,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A TOTP code works once. A code seen in flight and replayed inside its window
|
||||||
|
// must be refused the second time, or the second factor is only as good as the
|
||||||
|
// thirty seconds an attacker has to reuse what they captured.
|
||||||
|
func TestTOTPCodeIsSingleUse(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||||
|
"password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
secret, _ := body["secret"].(string)
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||||
|
"code": codeFor(t, secret),
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One code, captured once and presented twice.
|
||||||
|
code := codeFor(t, secret)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second login, same code, same window.
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||||
|
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||||
ts := newTestServer(t)
|
ts := newTestServer(t)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||||
@@ -50,6 +52,39 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HSTS følger med, når instansen kører over HTTPS — og kun der.
|
||||||
|
//
|
||||||
|
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
|
||||||
|
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
|
||||||
|
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
|
||||||
|
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
|
||||||
|
func TestHSTSTracksHTTPS(t *testing.T) {
|
||||||
|
overHTTP := newTestServer(t) // BaseURL er http://localhost
|
||||||
|
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
|
||||||
|
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||||
|
t.Errorf("HSTS sat over ren HTTP: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
overHTTPS := newTestServerWith(t, func(c *config.Config) {
|
||||||
|
c.BaseURL = "https://todo.example.dk"
|
||||||
|
})
|
||||||
|
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
|
||||||
|
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
|
||||||
|
t.Error("ingen HSTS over HTTPS")
|
||||||
|
} else if !strings.Contains(got, "includeSubDomains") {
|
||||||
|
t.Errorf("HSTS uden includeSubDomains: %q", got)
|
||||||
|
}
|
||||||
|
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
|
||||||
|
// mikrofon og placering, som appen aldrig beder om.
|
||||||
|
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
|
||||||
|
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
|
||||||
|
}
|
||||||
|
// COOP isolerer browserkonteksten og står på hvert svar.
|
||||||
|
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
||||||
|
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Et bilag må ikke kunne blive læst som et dokument.
|
// Et bilag må ikke kunne blive læst som et dokument.
|
||||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||||
|
|||||||
@@ -360,8 +360,19 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|||||||
Description: msg.Snippet + "\n\n" + msg.Link,
|
Description: msg.Snippet + "\n\n" + msg.Link,
|
||||||
Priority: 4,
|
Priority: 4,
|
||||||
CreatedBy: user.ID,
|
CreatedBy: user.ID,
|
||||||
|
// The thread, not the message: a reply arriving later is the same
|
||||||
|
// conversation, and a second task for it would be the duplicate this
|
||||||
|
// key exists to prevent.
|
||||||
|
SourceKey: "gmail:thread:" + msg.ThreadID,
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||||
|
if errors.Is(err, store.ErrDuplicate) {
|
||||||
|
// Somebody got there first — an earlier sweep, or another
|
||||||
|
// assistant reading the same mailbox. Mark it seen and move on;
|
||||||
|
// it is handled, which is all this loop wanted.
|
||||||
|
seen[id] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
s.log.Warn("gmail create task", "err", err)
|
s.log.Warn("gmail create task", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,8 +202,20 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
|||||||
Description: msg.Snippet,
|
Description: msg.Snippet,
|
||||||
Priority: 4,
|
Priority: 4,
|
||||||
CreatedBy: user.ID,
|
CreatedBy: user.ID,
|
||||||
|
// Host and uid together: a uid is only unique within one mailbox on
|
||||||
|
// one server, and two accounts will hand out the same small numbers.
|
||||||
|
SourceKey: fmt.Sprintf("imap:%s:%s:%d", m.Host, m.Folder, msg.UID),
|
||||||
}
|
}
|
||||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||||
|
if errors.Is(err, store.ErrDuplicate) {
|
||||||
|
// Already a task. Still count it as read, or last_uid never moves
|
||||||
|
// past it and every sweep from here on stops at the same message.
|
||||||
|
done = append(done, msg.UID)
|
||||||
|
if msg.UID > highest {
|
||||||
|
highest = msg.UID
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// realIP rewrites r.RemoteAddr to the client address a trusted proxy reported,
|
||||||
|
// and does nothing at all when the peer is not trusted.
|
||||||
|
//
|
||||||
|
// This replaces chi's middleware.RealIP, which is deprecated precisely because it
|
||||||
|
// believes a forwarded header from anyone: an attacker sets X-Forwarded-For to a
|
||||||
|
// new value on every request and gets a fresh rate-limit bucket each time, and
|
||||||
|
// writes whatever address they like into the audit log. The one control that
|
||||||
|
// exists for when a password is already gone is keyed on this address, so it has
|
||||||
|
// to be an address the caller cannot choose.
|
||||||
|
//
|
||||||
|
// The gate is the peer. A header is read only when the machine that opened the
|
||||||
|
// connection is one of cfg.TrustedProxies — a reverse proxy or tunnel the operator
|
||||||
|
// put there. A connection straight off the internet is trusted for nothing, so its
|
||||||
|
// header is ignored and it is keyed on the address it actually came from. Behind a
|
||||||
|
// proxy that overwrites the client's own header (nginx, Caddy and cloudflared all
|
||||||
|
// do) the value is then the real client and cannot be forged from outside.
|
||||||
|
func realIP(cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
|
trusted := cfg.TrustedProxies
|
||||||
|
header := cfg.RealIPHeader
|
||||||
|
if header == "" {
|
||||||
|
header = "X-Forwarded-For"
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ip := trustedClientIP(r, trusted, header); ip != "" {
|
||||||
|
// Port preserved off the original peer only for shape; downstream
|
||||||
|
// clientIP() splits it away, and the forwarded value carries none.
|
||||||
|
r.RemoteAddr = ip
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedClientIP returns the forwarded client address if the peer may be believed,
|
||||||
|
// or "" to leave RemoteAddr untouched.
|
||||||
|
func trustedClientIP(r *http.Request, trusted []netip.Prefix, header string) string {
|
||||||
|
peer := r.RemoteAddr
|
||||||
|
if host, _, err := net.SplitHostPort(peer); err == nil {
|
||||||
|
peer = host
|
||||||
|
}
|
||||||
|
peerAddr, err := netip.ParseAddr(peer)
|
||||||
|
if err != nil || !isTrusted(peerAddr, trusted) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := r.Header.Get(header)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The leftmost entry is the original client. That entry is only as trustworthy
|
||||||
|
// as the proxy that wrote it — but the trusted proxy is exactly the one that
|
||||||
|
// replaces a client-supplied header, so on the deployments this is written for
|
||||||
|
// it is the real client, and on a direct connection we never reach this line.
|
||||||
|
first := raw
|
||||||
|
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||||
|
first = raw[:i]
|
||||||
|
}
|
||||||
|
first = strings.TrimSpace(first)
|
||||||
|
if _, err := netip.ParseAddr(first); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrusted(addr netip.Addr, trusted []netip.Prefix) bool {
|
||||||
|
if !addr.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for _, p := range trusted {
|
||||||
|
if p.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The address the rate limiter keys on must be one the caller cannot choose. A
|
||||||
|
// forwarded header is believed only from a trusted proxy, and ignored from a
|
||||||
|
// stranger connecting directly — the difference between a control and a suggestion.
|
||||||
|
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := ""
|
||||||
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = clientIP(r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
xff string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// A private peer is a proxy the operator put there: believe it.
|
||||||
|
name: "trusted proxy is believed",
|
||||||
|
remoteAddr: "10.1.2.3:5000",
|
||||||
|
xff: "203.0.113.9",
|
||||||
|
want: "203.0.113.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A public peer connected straight to us; its header is a guess.
|
||||||
|
name: "public peer header ignored",
|
||||||
|
remoteAddr: "198.51.100.7:5000",
|
||||||
|
xff: "203.0.113.9",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// No header from a trusted proxy leaves the peer as the answer.
|
||||||
|
name: "trusted peer without header",
|
||||||
|
remoteAddr: "127.0.0.1:5000",
|
||||||
|
xff: "",
|
||||||
|
want: "127.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A spoofed value from a stranger cannot become the key.
|
||||||
|
name: "spoof from outside does not win",
|
||||||
|
remoteAddr: "198.51.100.7:5000",
|
||||||
|
xff: "127.0.0.1",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||||
|
r.RemoteAddr = tc.remoteAddr
|
||||||
|
if tc.xff != "" {
|
||||||
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if seen != tc.want {
|
||||||
|
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "none" turns the trust off entirely, for an instance exposed directly.
|
||||||
|
func TestRealIPTrustNone(t *testing.T) {
|
||||||
|
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := ""
|
||||||
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = clientIP(r)
|
||||||
|
}))
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
||||||
|
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||||
|
if seen != "10.1.2.3" {
|
||||||
|
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
// Not middleware.RealIP: that believes a forwarded header from anyone, which
|
||||||
|
// lets a caller pick the address the rate limiter and audit log key on. This
|
||||||
|
// one reads the header only from a trusted proxy — see realip.go.
|
||||||
|
r.Use(realIP(cfg))
|
||||||
r.Use(requestLogger(log))
|
r.Use(requestLogger(log))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
@@ -665,6 +668,31 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Referrer-Policy", "same-origin")
|
h.Set("Referrer-Policy", "same-origin")
|
||||||
h.Set("Content-Security-Policy", s.csp)
|
h.Set("Content-Security-Policy", s.csp)
|
||||||
|
// The session lives in a __Host- cookie that only exists over HTTPS, so the
|
||||||
|
// whole security model already assumes TLS. HSTS is what makes that
|
||||||
|
// assumption hold against a network attacker: without it, the very first
|
||||||
|
// navigation can be stripped to http:// and the cookie handed over in the
|
||||||
|
// clear. Gated on the same condition as the cookie's Secure flag, so a
|
||||||
|
// plain-HTTP dev instance is never told to demand a certificate it cannot
|
||||||
|
// present. Two years, subdomains included; no preload directive, because
|
||||||
|
// asking to be hard-coded into browsers is a decision the operator makes
|
||||||
|
// for their domain, not one a self-hosted app makes for them.
|
||||||
|
if s.secureCookies() {
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
|
}
|
||||||
|
// verdande uses none of the powerful browser features, so the safest policy
|
||||||
|
// is to turn them all off: an injected element cannot ask for the camera,
|
||||||
|
// the microphone or the location if the page itself has already declined
|
||||||
|
// them. Named explicitly rather than with a wildcard because the wildcard
|
||||||
|
// form of this header is not universally understood, and a directive a
|
||||||
|
// browser ignores is not a control.
|
||||||
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+
|
||||||
|
"payment=(), usb=(), interest-cohort=()")
|
||||||
|
// Isolate the browsing context: a window this page opened, or one that
|
||||||
|
// opened it, cannot keep a handle across the origin boundary. verdande
|
||||||
|
// never scripts another window, so there is nothing to lose and a whole
|
||||||
|
// class of cross-window attacks to close.
|
||||||
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- Kvasir: what an assistant did, and what it acted on.
|
||||||
|
--
|
||||||
|
-- Two things, and they are separate on purpose: one is about the outside world,
|
||||||
|
-- and would be worth having even if Kvasir never existed.
|
||||||
|
|
||||||
|
-- 1. Where a task came from, out there.
|
||||||
|
--
|
||||||
|
-- Nothing today can answer "has this mail already become a task?". A second
|
||||||
|
-- reader of the same mailbox — another assistant, a rerun of the same sweep,
|
||||||
|
-- a person — makes a second task, and the only sign is the duplicate itself.
|
||||||
|
-- That has happened in practice, twice in one run.
|
||||||
|
--
|
||||||
|
-- Namespaced rather than a bare id: `gmail:thread:18f2c…`, `imap:<host>:<uid>`,
|
||||||
|
-- `ics:<uid>`. A Gmail thread id and an IMAP uid look nothing alike until the
|
||||||
|
-- day they collide, and a namespace costs one colon. It also means the next
|
||||||
|
-- source needs no column — which is the point of a key rather than a foreign
|
||||||
|
-- key. There is no messages table to point at, and this has to work without one.
|
||||||
|
ALTER TABLE tasks ADD COLUMN source_key TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- The index is the interlock, not an optimisation. Two agents reading the same
|
||||||
|
-- inbox must be unable to make two tasks, and "must be unable" is a constraint
|
||||||
|
-- rather than a convention somebody remembers to follow.
|
||||||
|
--
|
||||||
|
-- Scoped to the author: two people on one instance connect their own mailboxes,
|
||||||
|
-- and the same thread reaching both of them is two tasks, correctly. Partial
|
||||||
|
-- because `created_by` is ON DELETE SET NULL — a task outlives its author, and
|
||||||
|
-- rows that have lost theirs must not start colliding with each other.
|
||||||
|
CREATE UNIQUE INDEX tasks_once_per_source
|
||||||
|
ON tasks (created_by, source_key)
|
||||||
|
WHERE source_key <> '' AND created_by IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Kvasir's ledger.
|
||||||
|
--
|
||||||
|
-- The dial that lets an assistant act on its own is only bearable because of
|
||||||
|
-- this table. Two questions have to be answerable at any moment: what did it do
|
||||||
|
-- today, and how do I undo it. Neither is answerable from the tasks themselves.
|
||||||
|
--
|
||||||
|
-- A suggestion and a completed action are the SAME ROW in different states. That
|
||||||
|
-- is the whole design: "suggest" and "act" are not two code paths, they are one
|
||||||
|
-- path that writes a different `state`. Accepting a suggestion moves it to done;
|
||||||
|
-- undoing an action moves it to undone. Nothing has to be written twice, and the
|
||||||
|
-- feed reads the same either way.
|
||||||
|
--
|
||||||
|
-- `before_json` is the undo. Not a diff and not a description — the fields as
|
||||||
|
-- they were, so putting them back needs no reasoning about what changed. It is
|
||||||
|
-- also the honest record: a task edited by hand afterwards will not match, and
|
||||||
|
-- an undo that would overwrite newer work should say so rather than win.
|
||||||
|
CREATE TABLE kvasir_actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 'file', 'rewrite', 'split', 'nudge', 'prioritise'. Free text rather than a
|
||||||
|
-- CHECK: a new capability must not need a migration to be recorded, and an
|
||||||
|
-- unknown kind in the feed is a row nobody rendered, not a broken database.
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- One sentence, written when the action was decided. Stored rather than
|
||||||
|
-- rebuilt: it says what Kvasir believed at the time, and the project it
|
||||||
|
-- names may since have been renamed or deleted.
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
-- Why it thought so. Shown on request, because a suggestion you cannot
|
||||||
|
-- interrogate is one you either trust blindly or ignore entirely.
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
before_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
after_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- suggested → done | dismissed, and done → undone.
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('suggested', 'done', 'dismissed', 'undone')),
|
||||||
|
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
decided_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The feed asks "what is waiting for me, newest first", and the corrections
|
||||||
|
-- Kvasir learns from ask "what did I get wrong lately" — both read by user and
|
||||||
|
-- order by time.
|
||||||
|
CREATE INDEX kvasir_actions_by_user ON kvasir_actions (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
|
||||||
|
-- verifier must reject a code that has already been accepted, or a code observed in
|
||||||
|
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
|
||||||
|
-- for the rest of its thirty-second window.
|
||||||
|
--
|
||||||
|
-- totp_last_step records the counter of the most recently accepted code (Unix time
|
||||||
|
-- divided by the period). A login accepts a code only when its step is strictly
|
||||||
|
-- greater, so the same code cannot be spent twice and time cannot run backwards.
|
||||||
|
-- Null means no code has been accepted yet, which is every account until its owner
|
||||||
|
-- next signs in with one.
|
||||||
|
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One mail, one task — however many readers there are.
|
||||||
|
//
|
||||||
|
// Nothing could answer "has this already become a task?" before, so a second
|
||||||
|
// reader of the same mailbox made a second task. That is not hypothetical: a
|
||||||
|
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
|
||||||
|
// separate assistant reading the same inbox does the same thing every sweep.
|
||||||
|
func TestOneMailBecomesOneTask(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, user.ID)
|
||||||
|
|
||||||
|
const thread = "gmail:thread:18f2cabc"
|
||||||
|
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||||
|
if err := db.CreateTask(ctx, first, nil); err != nil {
|
||||||
|
t.Fatalf("first task: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same thread again — a rerun, or somebody else's assistant.
|
||||||
|
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
||||||
|
err := db.CreateTask(ctx, second, nil)
|
||||||
|
if !errors.Is(err, ErrDuplicate) {
|
||||||
|
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the question that makes the other agent safe.
|
||||||
|
id, err := db.TaskIDBySource(ctx, user.ID, thread)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != first.ID {
|
||||||
|
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
|
||||||
|
}
|
||||||
|
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
|
||||||
|
t.Errorf("an unseen thread answered %q, %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task somebody typed has no source, and there can be any number of those.
|
||||||
|
// The index is partial for exactly this reason — without the WHERE clause the
|
||||||
|
// second hand-written task in the database would be refused.
|
||||||
|
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, user.ID)
|
||||||
|
|
||||||
|
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
|
||||||
|
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
|
||||||
|
t.Fatalf("task %d (%s): %v", i, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
|
||||||
|
t.Errorf("an empty source key answered %q, %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people on one instance, each with their own mailbox. The same thread id
|
||||||
|
// reaching both of them is two tasks, correctly — the key is scoped to whoever
|
||||||
|
// the mail arrived for.
|
||||||
|
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for _, email := range []string{"kristian@example.dk", "andreas@example.dk"} {
|
||||||
|
u := &User{Email: email, Name: email, PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox := newProject(t, db, u.ID)
|
||||||
|
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
|
||||||
|
SourceKey: "gmail:thread:delt"}
|
||||||
|
if err := db.CreateTask(ctx, task, nil); err != nil {
|
||||||
|
t.Fatalf("%s: %v", email, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, task.ID)
|
||||||
|
}
|
||||||
|
if ids[0] == ids[1] {
|
||||||
|
t.Error("the two people got the same task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project to hang the tasks on. The tests are about the source key, not about
|
||||||
|
// where a task lives.
|
||||||
|
func newProject(t *testing.T, db *DB, ownerID string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
|
||||||
|
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
+37
-3
@@ -32,6 +32,13 @@ type Task struct {
|
|||||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
|
// SourceKey names the thing out there this task was made from, namespaced:
|
||||||
|
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
|
||||||
|
//
|
||||||
|
// It exists so that the second reader of a mailbox — another assistant, a
|
||||||
|
// rerun, a person — can ask "has this already become a task?" and get an
|
||||||
|
// answer. CreateTask returns ErrDuplicate rather than making a second one.
|
||||||
|
SourceKey string
|
||||||
SortOrder float64
|
SortOrder float64
|
||||||
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||||
// that there is something underneath it, and how much of it is left. Counted
|
// that there is something underneath it, and how much of it is left. Counted
|
||||||
@@ -452,21 +459,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
|||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
||||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||||
recurrence_rule, assignee_id, created_by, sort_order,
|
recurrence_rule, assignee_id, created_by, source_key, sort_order,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||||
t.SortOrder, now.Unix(), now.Unix())
|
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// The one broken constraint that is not a bug: this source has already
|
||||||
|
// become a task. The caller skips it rather than making a duplicate,
|
||||||
|
// which is the entire reason the index exists.
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrDuplicate
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskIDBySource answers "has this mail already become a task?" — the question
|
||||||
|
// nothing could answer before, and the one that stops two assistants reading the
|
||||||
|
// same inbox from making two of everything. Empty id and no error means no.
|
||||||
|
//
|
||||||
|
// Deleted tasks count. A task that was made from a mail and then thrown away is
|
||||||
|
// an answer — remaking it on the next sweep would be the same duplicate wearing
|
||||||
|
// a different hat.
|
||||||
|
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
|
||||||
|
if sourceKey == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
|
||||||
|
userID, sourceKey).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
||||||
var max sql.NullFloat64
|
var max sql.NullFloat64
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -466,6 +466,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
|
||||||
|
// fresh. It answers false when the step has already been spent — the same code, or
|
||||||
|
// an earlier one, being presented a second time.
|
||||||
|
//
|
||||||
|
// The guard is in the statement, not in a read-then-write: two logins racing with
|
||||||
|
// the same stolen code must not both find the old value and both proceed. The row
|
||||||
|
// moves forward only when the new step is strictly greater, so exactly one of them
|
||||||
|
// updates a row and the other sees nothing change.
|
||||||
|
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET totp_last_step = ? WHERE id = ?
|
||||||
|
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
|
||||||
|
step, userID, step)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InboxID returns the user's Inbox project.
|
// InboxID returns the user's Inbox project.
|
||||||
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
||||||
var id string
|
var id string
|
||||||
|
|||||||
+23
-3
@@ -558,15 +558,35 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
|||||||
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
||||||
// hours of every day they disagree about what today is.
|
// hours of every day they disagree about what today is.
|
||||||
await page.goto('/upcoming');
|
await page.goto('/upcoming');
|
||||||
|
|
||||||
|
// Taller than the default window, and it is not cosmetic: the month grid is
|
||||||
|
// six rows, and in 720 px the last one is cut off by the fold. A synthetic
|
||||||
|
// drag drops at the target's centre without the auto-scroll a real pointer
|
||||||
|
// gets from the browser — so a target below the fold is never hit.
|
||||||
|
//
|
||||||
|
// That made this test depend on the date. The task sits on tomorrow, and when
|
||||||
|
// tomorrow is a Sunday, the day after is the first cell of the next row, down
|
||||||
|
// in the cut-off part. It passed for months and failed the morning tomorrow
|
||||||
|
// happened to be a Sunday, on a commit that had touched nothing near a
|
||||||
|
// calendar.
|
||||||
|
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
|
|
||||||
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
||||||
await expect(chip).toBeVisible();
|
await expect(chip).toBeVisible();
|
||||||
|
|
||||||
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
|
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
|
||||||
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
|
|
||||||
.toISOString()
|
// The neighbouring cell is taken from the grid, not worked out from the date.
|
||||||
.slice(0, 10);
|
// The day after tomorrow is not always in the month on screen — at the end of
|
||||||
|
// a short month it falls outside it entirely — and the cell next door always
|
||||||
|
// is. Falls back to the cell before, for the one day a month when there is no
|
||||||
|
// cell after.
|
||||||
|
const dates = await page
|
||||||
|
.locator('.day[data-date]')
|
||||||
|
.evaluateAll((cells) => cells.map((cell) => cell.dataset.date));
|
||||||
|
const at = dates.indexOf(from);
|
||||||
|
const to = dates[at + 1] ?? dates[at - 1];
|
||||||
|
|
||||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
||||||
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
|
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user