Files
verdande/internal/update/update.go
T
Kristian WindandClaude Opus 5 5546e191ac feat(gmail,update): finish the OAuth2 flow, and tell admins about new releases
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>
2026-08-17 08:21:51 +02:00

200 lines
5.4 KiB
Go

// Package update checks whether a newer verdande has been released.
//
// It asks GitHub's public releases API and nothing else. No identifier is sent, no
// telemetry is collected, and the request carries only a user agent naming the
// project — a self-hosted app that phones home with anything about its instance has
// broken the deal its operator made by self-hosting.
//
// The check is off unless the operator turns it on, and the result is cached for
// six hours so a busy instance does not become a burst of requests to GitHub.
package update
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const releasesURL = "https://api.github.com/repos/kristianwind/verdande/releases/latest"
// Status is what the interface shows.
type Status struct {
Current string `json:"current"`
Latest string `json:"latest,omitempty"`
Available bool `json:"update_available"`
URL string `json:"url,omitempty"`
// Notes is the release's own description, so somebody can decide whether the
// update is worth a restart tonight or next week.
Notes string `json:"notes,omitempty"`
CheckedAt string `json:"checked_at,omitempty"`
// Disabled distinguishes "no update" from "we never looked", which the
// interface has to show differently or it implies a guarantee it cannot make.
Disabled bool `json:"disabled"`
}
type Checker struct {
current string
enabled bool
mu sync.RWMutex
cached Status
fetched time.Time
}
func New(currentVersion string, enabled bool) *Checker {
return &Checker{
current: currentVersion,
enabled: enabled,
cached: Status{Current: currentVersion, Disabled: !enabled},
}
}
// Status returns what is known, refreshing at most every six hours.
func (c *Checker) Status(ctx context.Context) Status {
if !c.enabled {
return Status{Current: c.current, Disabled: true}
}
c.mu.RLock()
cached, fetched := c.cached, c.fetched
c.mu.RUnlock()
if time.Since(fetched) < 6*time.Hour && fetched != (time.Time{}) {
return cached
}
status, err := c.fetch(ctx)
if err != nil {
// A failed check is not an error anybody needs to see. GitHub being
// briefly unreachable is not a problem with this instance, and the last
// known answer is better than a red banner.
c.mu.Lock()
// Backed off by marking it fetched, so a network outage does not mean a
// request to GitHub on every page load.
c.fetched = time.Now()
result := c.cached
c.mu.Unlock()
return result
}
c.mu.Lock()
c.cached, c.fetched = status, time.Now()
c.mu.Unlock()
return status
}
func (c *Checker) fetch(ctx context.Context) (Status, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil)
if err != nil {
return Status{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
// The only thing sent about this instance is the project name. Not the base
// URL, not the version, not a count of anything.
req.Header.Set("User-Agent", "verdande")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return Status{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Status{}, fmt.Errorf("update: github said %s", resp.Status)
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return Status{}, err
}
if release.Draft || release.Prerelease {
return Status{Current: c.current, CheckedAt: nowRFC3339()}, nil
}
return Status{
Current: c.current,
Latest: release.TagName,
Available: IsNewer(c.current, release.TagName),
URL: release.HTMLURL,
Notes: truncate(release.Body, 2000),
CheckedAt: nowRFC3339(),
}, nil
}
// IsNewer compares two semver tags.
//
// A development build never reports an update. Somebody running `go run` against
// their own working tree is ahead of every release, not behind it, and telling them
// otherwise every time they open the app would train them to ignore the notice.
func IsNewer(current, latest string) bool {
if current == "" || latest == "" {
return false
}
if current == "dev" || strings.Contains(current, "-dirty") {
return false
}
c := parseVersion(current)
l := parseVersion(latest)
if c == nil || l == nil {
return false
}
for i := 0; i < 3; i++ {
if l[i] != c[i] {
return l[i] > c[i]
}
}
return false
}
// parseVersion reads "v1.2.3" into its three numbers, ignoring any pre-release or
// build suffix. Returns nil for anything that is not a version.
func parseVersion(v string) []int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
// Drop a pre-release or build suffix: 1.2.3-rc1 compares as 1.2.3, which is
// close enough for "is there something newer" and avoids implementing the
// whole precedence section of the semver spec for a notice in a corner.
if i := strings.IndexAny(v, "-+"); i >= 0 {
v = v[:i]
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return nil
}
out := make([]int, 3)
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return nil
}
out[i] = n
}
return out
}
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339) }
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[:max] + "…"
}