Merge pull request #67 from kristianwind/beacon-siger-det-hoejt
feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
This commit is contained in:
+83
-1
@@ -1564,7 +1564,15 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
installs:
|
||||
type: [integer, "null"]
|
||||
description: |
|
||||
Null below the collector's floor (`publish_min`, 25 by
|
||||
default). A number that *falls* cannot be published as a
|
||||
fact — a bad release, a DNS change that quietly stops the
|
||||
pings, or lost rows all look like "fewer people use this
|
||||
now" — and a small number mostly says how little it takes
|
||||
to move it.
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
@@ -1590,7 +1598,19 @@ paths:
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min:
|
||||
type: integer
|
||||
description: The floor under the published count.
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
last_error:
|
||||
type: string
|
||||
description: |
|
||||
Why the last attempt did not arrive; absent when the last
|
||||
one did. Shown rather than only logged: a beacon that
|
||||
cannot reach its collector otherwise fails in complete
|
||||
silence, and the only trace is a "last sent" date that
|
||||
quietly gets older.
|
||||
last_error_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
@@ -1610,12 +1630,74 @@ paths:
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
publish_min: { type: integer, minimum: 0 }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/beacon/notice:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether the disclosure is still owed, and what it says. Administrators only.
|
||||
description: |
|
||||
The beacon is on by default, and that is only defensible if it is said
|
||||
unprompted. A settings page where it is stated fully is not the same as
|
||||
saying it: that page is opened by people who already suspect there is
|
||||
something to read.
|
||||
|
||||
The three values are in the response — the real id, the real version, the
|
||||
real collector address — so the notice can show what is sent rather than
|
||||
say "anonymous data" and ask to be believed.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pending:
|
||||
type: boolean
|
||||
description: False once anybody has answered, panel-wide.
|
||||
enabled: { type: boolean }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
collector_url: { type: string }
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Answer the notice. Administrators only.
|
||||
description: |
|
||||
One call for both answers — "keep it on" and "turn it off" are the same
|
||||
act, somebody deciding, and two endpoints would mean one of them could
|
||||
forget to record that a decision was made.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [keep]
|
||||
properties:
|
||||
keep:
|
||||
type: boolean
|
||||
description: |
|
||||
Required, and rejected when absent. An empty body must not be
|
||||
able to mean "turn it off": an opt-out nobody chose is as much
|
||||
a broken promise as an opt-in nobody chose.
|
||||
responses:
|
||||
"200":
|
||||
description: Recorded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
"422":
|
||||
description: No answer was given.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
|
||||
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
|
||||
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
|
||||
const defaultPublishMin = 25
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
@@ -30,6 +35,33 @@ type beaconConfig struct {
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
|
||||
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
|
||||
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
|
||||
// kan man ikke se en udrulning i.
|
||||
LastVersion string `json:"last_version,omitempty"`
|
||||
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
|
||||
//
|
||||
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
|
||||
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
|
||||
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
|
||||
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
|
||||
// alt er sendt.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt int64 `json:"last_error_at,omitempty"`
|
||||
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
|
||||
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
|
||||
// det, der husker, at det er blevet sagt.
|
||||
NoticeAck bool `json:"notice_ack"`
|
||||
// PublishMin er gulvet under det offentliggjorte tal.
|
||||
//
|
||||
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
|
||||
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
|
||||
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
|
||||
// der ingenting frem for et lille tal, ingen kan tolke.
|
||||
PublishMin int `json:"publish_min"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
@@ -40,7 +72,9 @@ type beaconConfig struct {
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
cfg := beaconConfig{
|
||||
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["last_version"].(string); ok {
|
||||
cfg.LastVersion = v
|
||||
}
|
||||
if v, ok := stored["last_error"].(string); ok {
|
||||
cfg.LastError = v
|
||||
}
|
||||
if v, ok := stored["last_error_at"].(float64); ok {
|
||||
cfg.LastErrorAt = int64(v)
|
||||
}
|
||||
if v, ok := stored["notice_ack"].(bool); ok {
|
||||
cfg.NoticeAck = v
|
||||
}
|
||||
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
|
||||
cfg.PublishMin = int(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
"last_version": cfg.LastVersion,
|
||||
"last_error": cfg.LastError,
|
||||
"last_error_at": cfg.LastErrorAt,
|
||||
"notice_ack": cfg.NoticeAck,
|
||||
"publish_min": cfg.PublishMin,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
PublishMin int `json:"publish_min"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
|
||||
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
|
||||
// det eneste spor, et tavst beacon ellers efterlader.
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastErrorAt string `json:"last_error_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
PublishMin: cfg.PublishMin,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.LastError != "" {
|
||||
out.LastError = cfg.LastError
|
||||
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
@@ -129,6 +194,7 @@ type beaconRequest struct {
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
PublishMin *int `json:"publish_min"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.PublishMin != nil {
|
||||
if *req.PublishMin < 0 {
|
||||
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
|
||||
return
|
||||
}
|
||||
cfg.PublishMin = *req.PublishMin
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err == nil && n < cfg.PublishMin {
|
||||
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
|
||||
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
|
||||
// læst som "færre bruger det nu", også når det i virkeligheden var en
|
||||
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
|
||||
// blive ramt.
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
|
||||
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
|
||||
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
|
||||
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
|
||||
// et døgn efter at være opdateret.
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
|
||||
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
|
||||
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
|
||||
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
|
||||
// savne en.
|
||||
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
return s.beaconSent(ctx, cfg)
|
||||
}
|
||||
|
||||
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
|
||||
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
|
||||
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
|
||||
// siden om noget, der virker.
|
||||
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
cfg.LastVersion = Version
|
||||
cfg.LastError, cfg.LastErrorAt = "", 0
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
|
||||
|
||||
// handleBeaconNotice says whether the disclosure is still owed, and what it would
|
||||
// say.
|
||||
//
|
||||
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
|
||||
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
|
||||
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
|
||||
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
|
||||
// dem, der ved, at der er noget at slå fra.
|
||||
//
|
||||
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
|
||||
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
|
||||
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
|
||||
// løfte, der er noget værd: en, læseren selv kan kontrollere.
|
||||
//
|
||||
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
|
||||
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
|
||||
// ikke kan svare på det.
|
||||
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pending": !cfg.NoticeAck,
|
||||
"enabled": cfg.Enabled,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"version": Version,
|
||||
"collector_url": cfg.Collector,
|
||||
})
|
||||
}
|
||||
|
||||
type beaconNoticeRequest struct {
|
||||
// Keep er en peger og ikke en bool, og det er hele pointen.
|
||||
//
|
||||
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
|
||||
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
|
||||
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
|
||||
// tilsvarende ende og er blevet bidt af den.
|
||||
Keep *bool `json:"keep"`
|
||||
}
|
||||
|
||||
// handleAckBeaconNotice records that the notice has been seen, and turns the
|
||||
// beacon off if that was the answer.
|
||||
//
|
||||
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
|
||||
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
|
||||
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
|
||||
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconNoticeRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Keep == nil {
|
||||
writeFieldErrors(w, map[string]string{"keep": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
cfg.NoticeAck = true
|
||||
cfg.Enabled = *req.Keep
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
|
||||
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
|
||||
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent besked: %d", resp.StatusCode)
|
||||
}
|
||||
if notice["pending"] != true {
|
||||
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
|
||||
}
|
||||
if notice["enabled"] != true {
|
||||
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
|
||||
}
|
||||
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
|
||||
settings := beaconSettingsOf(t, ts)
|
||||
if notice["instance_id"] != settings["instance_id"] {
|
||||
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
|
||||
notice["instance_id"], settings["instance_id"])
|
||||
}
|
||||
if notice["collector_url"] != settings["collector_url"] {
|
||||
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
|
||||
notice["collector_url"], settings["collector_url"])
|
||||
}
|
||||
if notice["version"] == "" || notice["version"] == nil {
|
||||
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
|
||||
}
|
||||
}
|
||||
|
||||
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
|
||||
// ved næste login.
|
||||
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
|
||||
t.Fatalf("behold: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
|
||||
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
|
||||
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
|
||||
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
s := beaconSettingsOf(t, ts)
|
||||
if s["enabled"] != false {
|
||||
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
|
||||
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
|
||||
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
|
||||
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
|
||||
}
|
||||
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
|
||||
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
|
||||
}
|
||||
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
|
||||
t.Errorf("en tom krop talte som et svar: %v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
|
||||
// svares der ingenting frem for et lille tal, ingen kan tolke.
|
||||
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
|
||||
"is_collector": true, "publish_count": true,
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("bliv collector")
|
||||
}
|
||||
|
||||
// Instansen tæller sig selv, så der er én. Gulvet er højere.
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tælling: %d", resp.StatusCode)
|
||||
}
|
||||
if out["installs"] != nil {
|
||||
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
|
||||
}
|
||||
|
||||
// Sættes gulvet ned, står tallet der.
|
||||
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
|
||||
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatal("sænk gulvet")
|
||||
}
|
||||
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
|
||||
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
|
||||
t.Helper()
|
||||
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -512,6 +512,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// Beskeden om, hvad der bliver sendt, og svaret på den. I samme
|
||||
// gruppe som resten: den, der kan slå beaconet fra, er den, der
|
||||
// skal have beskeden, og at spørge alle andre ville være at
|
||||
// stille et spørgsmål, de ikke kan svare på.
|
||||
r.Get("/beacon/notice", s.handleBeaconNotice)
|
||||
r.Post("/beacon/notice", s.handleAckBeaconNotice)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -28,5 +28,20 @@ setup('opret den første konto og log ind', async ({ page }) => {
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// Beaconet er slået til som udgangspunkt, og det bliver sagt uopfordret her —
|
||||
// det er hele grunden til, at et default-on er til at forsvare. Prøvet her,
|
||||
// fordi det er det eneste sted, en helt frisk installation bliver logget ind
|
||||
// på for første gang, og fordi beskeden ellers ville stå i vejen for hver
|
||||
// eneste prøve nedenfor og skulle klikkes væk uden at nogen havde set efter,
|
||||
// om den overhovedet sagde det rigtige.
|
||||
const notice = page.getByRole('region', { name: /melder, at den findes/ });
|
||||
await expect(notice).toBeVisible();
|
||||
// Værdierne står i beskeden, ikke ordet "anonymt": et løfte om telemetri er
|
||||
// præcis så meget værd som læserens mulighed for at efterprøve det.
|
||||
await expect(notice.getByText('instance_id')).toBeVisible();
|
||||
await expect(notice.getByText('version')).toBeVisible();
|
||||
await notice.getByRole('button', { name: 'Behold den' }).click();
|
||||
await expect(notice).toBeHidden();
|
||||
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
});
|
||||
|
||||
@@ -280,6 +280,11 @@ export const api = {
|
||||
|
||||
beacon: () => get('/beacon/settings'),
|
||||
setBeacon: (body) => put('/beacon/settings', body),
|
||||
// Beskeden om, hvad installationen melder ind, og svaret på den. `keep` sendes
|
||||
// altid — en tom krop måtte ikke kunne betyde "slå det fra", og serveren
|
||||
// afviser den derfor.
|
||||
beaconNotice: () => get('/beacon/notice'),
|
||||
answerBeaconNotice: (keep) => post('/beacon/notice', { keep }),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
/**
|
||||
* Beskeden om, at denne installation melder ind — sagt uopfordret, første gang
|
||||
* en administrator er logget ind.
|
||||
*
|
||||
* Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
|
||||
* bliver sagt af sig selv. Siden under Indstillinger → Data siger det rigtigt
|
||||
* og fyldestgørende, og den bliver åbnet af dem, der i forvejen har en
|
||||
* mistanke om, at der er noget at læse; for alle andre er "du kan slå den fra"
|
||||
* et tilbud til nogen, der ikke ved, der er noget at slå fra.
|
||||
*
|
||||
* De tre værdier står i beskeden — det rigtige id, den rigtige version, den
|
||||
* rigtige adresse — frem for ordene "anonyme data". Et løfte om telemetri er
|
||||
* præcis så meget værd som læserens mulighed for at efterprøve det.
|
||||
*
|
||||
* Kun til administratorer, og hentet på `app.user` frem for i onMount: en
|
||||
* indlæsning, der kører, mens login-siden står, kører aldrig for den, der lige
|
||||
* er logget ind — og så er beskeden aldrig sagt til dem, den er skrevet til.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { humanMessage } from '$lib/api.js';
|
||||
|
||||
let notice = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const me = app.user;
|
||||
if (!me?.is_admin) {
|
||||
notice = null;
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
api
|
||||
.beaconNotice()
|
||||
.then((r) => {
|
||||
if (alive && r?.pending) notice = r;
|
||||
})
|
||||
.catch(() => {
|
||||
// En besked, der ikke kunne hentes, må ikke stå i vejen for appen.
|
||||
// Den er stadig ubesvaret næste gang, og bliver stillet igen.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
});
|
||||
|
||||
async function answer(keep) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.answerBeaconNotice(keep);
|
||||
notice = null;
|
||||
app.toast(keep ? t('beacon.noticeKept') : t('beacon.noticeStopped'));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if notice}
|
||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||
<h2>{t('beacon.noticeTitle')}</h2>
|
||||
<p>{t('beacon.noticeBody')}</p>
|
||||
|
||||
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||
<pre>{JSON.stringify(
|
||||
{ instance_id: notice.instance_id, version: notice.version },
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
<p class="to">{t('beacon.noticeTo', { url: notice.collector_url })}</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="button" disabled={busy} onclick={() => answer(true)}>
|
||||
{t('beacon.noticeKeep')}
|
||||
</button>
|
||||
<button class="secondary" disabled={busy} onclick={() => answer(false)}>
|
||||
{t('beacon.noticeStop')}
|
||||
</button>
|
||||
<a href="/indstillinger/data">{t('beacon.noticeMore')}</a>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Øverst i indholdet, i flowet — ikke svævende over det. En rude, der ligger
|
||||
oven på arbejdet, dækker knapper, og en besked, der er i vejen, bliver
|
||||
klikket væk uden at blive læst. Den her skubber i stedet, står til den
|
||||
bliver besvaret, og forsvinder så for altid. */
|
||||
.notice {
|
||||
margin: 0 0 var(--s3);
|
||||
max-width: 44rem;
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg, var(--radius));
|
||||
background: var(--surface-raised, var(--surface));
|
||||
box-shadow: var(--shadow-lg, 0 10px 30px rgb(0 0 0 / 0.25));
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.notice h2 {
|
||||
margin: 0 0 var(--s1);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
margin: 0 0 var(--s2);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notice pre {
|
||||
margin: 0 0 var(--s1);
|
||||
padding: var(--s2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-sunken, var(--surface));
|
||||
font-size: var(--text-xs);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notice .to {
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row a {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -90,6 +90,22 @@ export const da = {
|
||||
'beacon.active7': 'Aktive 7 dage',
|
||||
'beacon.active30': 'Aktive 30 dage',
|
||||
'beacon.byVersion': 'Efter version (30 dage):',
|
||||
// Beskeden, der bliver sagt uopfordret første gang. Værdierne står i den, ikke
|
||||
// ordet 'anonymt': et løfte om telemetri er præcis så meget værd som
|
||||
// læserens mulighed for at efterprøve det.
|
||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||
'beacon.noticeBody':
|
||||
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'beacon.noticeTo': 'Sendes til {url}',
|
||||
'beacon.noticeKeep': 'Behold den',
|
||||
'beacon.noticeStop': 'Slå den fra',
|
||||
'beacon.noticeMore': 'Læs mere',
|
||||
'beacon.noticeKept': 'Tak. Du kan altid slå den fra under Indstillinger → Data.',
|
||||
'beacon.noticeStopped': 'Meldingen er slået fra. Der bliver ikke sendt noget.',
|
||||
'beacon.lastError': 'Sidste forsøg nåede ikke frem ({when}): {why}',
|
||||
'beacon.publishMin': 'Offentliggør først fra',
|
||||
'beacon.publishMinHint':
|
||||
'Under det tal svares der ingenting. Et lille tal fortæller mest om, hvor lidt der skal til at flytte det — og et, der falder, ville blive læst som “færre bruger det nu”, også når det var en udrulning, der gik galt.',
|
||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.newHere': 'Ny note',
|
||||
|
||||
@@ -87,6 +87,22 @@ export const en = {
|
||||
'beacon.active7': 'Active 7 days',
|
||||
'beacon.active30': 'Active 30 days',
|
||||
'beacon.byVersion': 'By version (30 days):',
|
||||
// The notice said unprompted the first time. The values are in it rather than
|
||||
// the word 'anonymous': a promise about telemetry is worth exactly as much as
|
||||
// the reader's ability to check it.
|
||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||
'beacon.noticeBody':
|
||||
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'beacon.noticeTo': 'Sent to {url}',
|
||||
'beacon.noticeKeep': 'Keep it on',
|
||||
'beacon.noticeStop': 'Turn it off',
|
||||
'beacon.noticeMore': 'Read more',
|
||||
'beacon.noticeKept': 'Thank you. You can always turn it off under Settings → Data.',
|
||||
'beacon.noticeStopped': 'Reporting is off. Nothing is being sent.',
|
||||
'beacon.lastError': 'The last attempt did not arrive ({when}): {why}',
|
||||
'beacon.publishMin': 'Publish only from',
|
||||
'beacon.publishMinHint':
|
||||
'Below that number nothing is answered. A small number mostly says how little it takes to move it — and one that falls would read as “fewer people use this now”, even when it was a release that went wrong.',
|
||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.newHere': 'New note',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -192,6 +193,13 @@
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Sagt uopfordret, første gang en administrator er logget ind: hvad
|
||||
denne installation melder ind, og hvordan man siger nej.
|
||||
Øverst i indholdet og ikke svævende over det — en rude, der
|
||||
ligger oven på arbejdet, dækker knapper, og en besked, der er i
|
||||
vejen, bliver klikket væk uden at blive læst. Komponenten tegner
|
||||
ingenting, når der ikke er noget at fortælle. -->
|
||||
<BeaconNotice />
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -534,6 +534,18 @@
|
||||
<pre class="payload">{t('beacon.exactly')}
|
||||
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
|
||||
|
||||
{#if beacon.last_error}
|
||||
<!-- Et beacon, der ikke kan nå sin collector, fejler ellers i fuldstændig
|
||||
tavshed: det prøver igen i morgen, og "sidst sendt" bliver bare ved
|
||||
med at blive ældre. Ikke en fejl, der skal afbryde nogen — en linje,
|
||||
hvor siden ellers påstår, at alt er sendt. -->
|
||||
<p class="hint">
|
||||
{t('beacon.lastError', {
|
||||
when: new Date(beacon.last_error_at).toLocaleString(tag()),
|
||||
why: beacon.last_error
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if beacon.last_ping_at}
|
||||
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
|
||||
{/if}
|
||||
@@ -592,6 +604,20 @@
|
||||
/>
|
||||
<span>{t('beacon.publish')}</span>
|
||||
</label>
|
||||
|
||||
{#if beacon.publish_count}
|
||||
<div class="field">
|
||||
<label for="publishmin">{t('beacon.publishMin')}</label>
|
||||
<input
|
||||
id="publishmin"
|
||||
type="number"
|
||||
min="0"
|
||||
value={beacon.publish_min}
|
||||
onchange={(e) => saveBeacon({ publish_min: Number(e.currentTarget.value) })}
|
||||
/>
|
||||
<p class="hint">{t('beacon.publishMinHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">{t('beacon.publishHint')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user