Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 96e1fa251b fix(beacon): beskeden skubber i stedet for at dække
Den lå som en rude i hjørnet, oven på arbejdet, og dækkede knapperne i
notens fod og i delepanelet — seks e2e-prøver klikkede forgæves på noget,
der lå under den.

Det var ikke kun et prøveproblem. En besked, der er i vejen, bliver
klikket væk uden at blive læst, og så er den uopfordrede oplysning en
formalitet frem for noget, nogen har set. Nu står den øverst i indholdet
og skubber siden ned, indtil den bliver besvaret.

Og e2e beviser den nu frem for at klikke den væk: den første konto skal
møde beskeden, og der skal stå et instance_id og en version i den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:51:47 +02:00
kristianwindandClaude Opus 5 0cf5c8c281 feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det
bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og
fyldestgørende under Indstillinger → Data, som er en side, der bliver
åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse.
For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke
vidste, der var noget at slå fra.

Nu møder en administrator beskeden første gang: hvad der bliver sendt,
med de rigtige værdier i — det rigtige id, den rigtige version, den
rigtige adresse — og to knapper. Værdierne står der frem for ordet
"anonymt", fordi et løfte om telemetri er præcis så meget værd som
læserens mulighed for at efterprøve det.

`keep` er en peger og ikke en bool. En tom krop måtte 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.

Og tre ting mere, som Yggdrasil har lært på den hårde måde:

Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed.
Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der
bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og
vist.

En installation, der opdaterer sig selv om natten, meldte gårsdagens
version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede
melding. En version, der er skiftet, sender nu med det samme.

Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke
offentliggøres som en kendsgerning — en dårlig udrulning, en
DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser
alle sammen ud som "færre bruger det nu".

Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com,
ikke til panelets egen collector. De to tæller hver sin slags
installation, og en fælles tabel har intet felt til at holde dem ude fra
hinanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:39:14 +02:00
Kristian Wind 445d9de57a Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 11m3s
CI / Docker image builds (push) Successful in 2m17s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 15m6s
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
2026-09-08 14:12:17 +02:00
Kristian Wind d5ad459eba Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste
2026-09-08 14:04:12 +02:00
Kristian Wind c297d256e4 Merge pull request #64 from kristianwind/tag-en-person-i-en-note
feat(noter): nævn en person i en note, og den er delt med dem
2026-09-08 14:04:08 +02:00
kristianwindandClaude Opus 5 d063425cae feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:03:32 +02:00
Kristian Wind 0764fbd566 Merge pull request #63 from kristianwind/del-note-med-en-uden-konto
feat(noter): del en note med en, der ikke har en konto endnu
2026-09-08 12:13:34 +02:00
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:12:54 +02:00
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:08:12 +02:00
Kristian Wind 61634a6bb5 Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
2026-09-08 11:59:04 +02:00
30 changed files with 1912 additions and 21 deletions
+185 -1
View File
@@ -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]
@@ -2718,6 +2800,25 @@ paths:
responses:
"200": { $ref: "#/components/responses/MailAddress" }
/api/v1/hook-url:
get:
tags: [integrations]
summary: Your personal address for pushing a task in from somewhere else.
description: >
Minted on first ask. Its own token rather than the mail one: the two are
the same kind of secret, but they leak separately — a URL that has been
sitting in a shortcut on a lost phone has to be replaceable without
changing the address other people have in their address books.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/hook-url/rotate:
post:
tags: [integrations]
summary: Issue a new push address, retiring the old one at once.
responses:
"200": { $ref: "#/components/responses/HookURL" }
/api/v1/notifications:
get:
tags: [integrations]
@@ -3017,6 +3118,12 @@ paths:
post:
tags: [notes]
summary: Write a note.
description: >
`#Projekt` files the note against a project and `[[Another note]]` links to
one. A third, `@Name`, shares the note with that person as a viewer and
tells them — only the owner's mentions share, and only a name that has just
appeared in the text counts, so a note saved on every typing pause does not
share itself twice.
requestBody:
required: true
content:
@@ -3080,6 +3187,11 @@ paths:
patch:
tags: [notes]
summary: Change a note. Every field is optional; what is sent is what changes.
description: >
A name written with an `@` that was not in the text before shares the note
with that person as a viewer and tells them. Only the owner's mentions
share — an editor may write the name, but giving somebody else's note away
is the owner's alone — and a role already granted is never lowered by one.
requestBody:
required: true
content:
@@ -3741,6 +3853,69 @@ paths:
task_id: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
/inbound/hook/{token}:
post:
tags: [integrations]
summary: Push a task into somebody's inbox from another program.
description: |
The token in the path is the whole credential — the caller is a shortcut on
a phone or a script, and neither can sign in. A token here can do one
thing: create a task in that person's inbox. An API token can do everything,
which is why this is not one.
The body is read three ways, because those are the three you meet: JSON from
a service, a form field from a webhook form, and plain text from a shortcut
that just sends what was selected. The first line becomes the task and the
rest sits underneath it, and the line goes through the quick-add parser — so
"Ring til Anders i morgen p1 #Firma" means here what it means in the box at
the top of the app.
POST only. A GET would be easier to call, and that is exactly the problem:
a browser prefetch, a link check in a chat, and a revisited history entry
would all create tasks nobody asked for.
security: []
parameters:
- name: token
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
text/plain:
schema:
type: string
description: First line the task, the rest its description.
application/json:
schema:
type: object
properties:
text: { type: string }
title: { description: Same as text. }
content: { description: Same as text. }
note: { type: string }
body: { description: Same as note. }
description: { description: Same as note. }
application/x-www-form-urlencoded:
schema:
type: object
properties:
text: { type: string }
note: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
task_id: { type: string }
content: { type: string }
"404": { description: "No such address. Identical to a malformed one, deliberately." }
"422": { $ref: "#/components/responses/Validation" }
"429": { description: Too many pushes; the address is rate limited. }
/oauth/gmail/callback:
get:
tags: [integrations]
@@ -4007,6 +4182,15 @@ components:
type: array
items: { type: string }
HookURL:
description: The address another program pushes to.
content:
application/json:
schema:
type: object
properties:
url: { type: string }
schemas:
Calendar:
type: object
+191 -13
View File
@@ -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})
}
+132
View File
@@ -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
}
+194
View File
@@ -0,0 +1,194 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
// doRaw sender en krop, der ikke er JSON — en genvej på en telefon sender ren
// tekst, og en webhook-formular sender felter. Det er halvdelen af det, der
// prøves her.
func (ts *testServer) doRaw(t *testing.T, method, path, contentType, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", contentType)
resp, err := ts.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
// hookURL henter personens egen krog-adresse, som fladen viser den.
func (ts *testServer) hookURL(t *testing.T) string {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/hook-url", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent krog-URL: %d", resp.StatusCode)
}
url, _ := out["url"].(string)
if !strings.Contains(url, "/inbound/hook/") {
t.Fatalf("url = %q", url)
}
return url
}
// hookPath er stien, testserveren kan kaldes på — URL'en er absolut, fordi den
// skal kunne kopieres ind i en genvej på en telefon.
func hookPath(url string) string {
return url[strings.Index(url, "/inbound/hook/"):]
}
// En linje ren tekst, skubbet ind udefra, bliver til en opgave i indbakken — og
// betyder det samme, som den ville betyde i feltet øverst i fladen.
func TestAPushFromAnotherAppLandsInTheInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, out := anon.doRaw(t, "POST", path, "text/plain",
"Ring til Anders p1\nhan ringede i går")
if resp.StatusCode != http.StatusCreated {
t.Fatalf("skub: %d, %v", resp.StatusCode, out)
}
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
tasks, _ := list["tasks"].([]any)
var found map[string]any
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] == "Ring til Anders" {
found = task
}
}
if found == nil {
t.Fatalf("opgaven kom ikke i indbakken: %v", tasks)
}
// Parseren er den samme som fladens: p1 er en prioritet og ikke en del af
// teksten, og resten af kroppen står under opgaven.
if found["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", found["priority"])
}
if found["description"] != "han ringede i går" {
t.Errorf("beskrivelse = %v", found["description"])
}
}
// JSON fra en tjeneste, med de feltnavne folk faktisk sender.
func TestTheHookReadsJSON(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
for _, body := range []string{
`{"text": "Køb mælk"}`,
`{"title": "Køb mælk"}`,
`{"content": "Køb mælk"}`,
} {
resp, out := anon.doRaw(t, "POST", path, "application/json", body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("%s: %d, %v", body, resp.StatusCode, out)
}
if out["content"] != "Køb mælk" {
t.Errorf("%s gav %v", body, out["content"])
}
}
// En krop, der siger JSON og ikke er det, læses ikke som tekst bagefter: en
// opgave, der hedder `{"txt": …}`, er en dårligere fejlmeddelelse end et 422.
resp, _ := anon.doRaw(t, "POST", path, "application/json", `{"txt": "skrevet forkert"}`)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("forkert JSON gav %d, want 422", resp.StatusCode)
}
}
// Et token, der ikke findes, er det samme svar som et, der aldrig har eksisteret.
// Alt andet er en maskine til at gætte tokens med.
func TestAnUnknownHookTokenIsNotFound(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", "/inbound/hook/deteringentingogsaaslet", "text/plain", "hej")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("ukendt token gav %d, want 404", resp.StatusCode)
}
}
// Den gamle URL holder op med at virke, når man skifter den. Det er den eneste vej
// tilbage: adressen står i en genvej på en telefon eller i et script.
func TestRotatingTheHookRetiresTheOldURL(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
old := hookPath(ts.hookURL(t))
resp, out := ts.do(t, "POST", "/api/v1/hook-url/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("skift: %d", resp.StatusCode)
}
fresh := hookPath(out["url"].(string))
if fresh == old {
t.Fatal("den nye URL er den gamle")
}
anon := newSignedOut(t, ts)
if resp, _ := anon.doRaw(t, "POST", old, "text/plain", "gammel"); resp.StatusCode != http.StatusNotFound {
t.Errorf("den gamle URL virker stadig: %d", resp.StatusCode)
}
if resp, _ := anon.doRaw(t, "POST", fresh, "text/plain", "ny"); resp.StatusCode != http.StatusCreated {
t.Errorf("den nye URL virker ikke: %d", resp.StatusCode)
}
}
// Ingen tekst er ikke en opgave. En tom opgave i indbakken er værre end en fejl,
// fordi den ser ud som om integrationen virker.
func TestAnEmptyPushIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
path := hookPath(ts.hookURL(t))
anon := newSignedOut(t, ts)
resp, _ := anon.doRaw(t, "POST", path, "text/plain", " \n ")
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom tekst gav %d, want 422", resp.StatusCode)
}
}
// Et token kan én ting. Det ligger i et script på en anden maskine, og hvad der
// ellers kan nås med det, er hvad der er sluppet ud sammen med det.
func TestTheHookTokenIsNotAnAPIToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
url := ts.hookURL(t)
token := url[strings.LastIndex(url, "/")+1:]
anon := newSignedOut(t, ts)
req, err := http.NewRequest("GET", anon.URL+"/api/v1/tasks", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := anon.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatal("krog-tokenet gav adgang til API'et")
}
}
+187
View File
@@ -2,7 +2,9 @@ package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
@@ -155,6 +157,191 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
}
// --- en krog ind i indbakken --------------------------------------------------------
type hookURLResponse struct {
URL string `json:"url"`
}
// handleGetHookURL returns the personal address another program can push a task
// to, minting the token on first ask.
//
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
// — fra en genvej på telefonen, et script, en anden tjeneste.
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
// handleRotateHookURL mints a new one and retires the old.
//
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
// tilbage end tokenet selv.
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, r, "generate hook token", err)
return
}
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, r, "set hook token", err)
return
}
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
}
func (s *Server) hookURL(token string) string {
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
}
type hookRequest struct {
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
// op.
Text string `json:"text"`
Title string `json:"title"`
Content string `json:"content"`
// Note er det, der står under opgaven. `body` og `description` ligeså.
Note string `json:"note"`
Body string `json:"body"`
Description string `json:"description"`
}
func (h hookRequest) line() string {
for _, v := range []string{h.Text, h.Title, h.Content} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func (h hookRequest) note() string {
for _, v := range []string{h.Note, h.Body, h.Description} {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
//
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
// den ene persons indbakke — hvor et API-token kan alt.
//
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
//
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
if err != nil {
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
// kender forskel, er en maskine til at gætte tokens med.
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
return
}
line, note := s.readHookBody(r)
if strings.TrimSpace(line) == "" {
writeFieldErrors(w, map[string]string{"text": "required"})
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
content := parsed.Content
if strings.TrimSpace(content) == "" {
content = line
}
projectID := ""
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
task := &store.Task{
ProjectID: projectID, Content: content, Priority: parsed.Priority,
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from hook", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
s.log.Info("task created from hook", "user", user.ID)
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
}
// readHookBody er de tre måder, en linje tekst kommer ind ad.
//
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
//
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
func (s *Server) readHookBody(r *http.Request) (line, note string) {
const maxBody = 64 << 10
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil {
return "", ""
}
kind := r.Header.Get("Content-Type")
switch {
case strings.Contains(kind, "application/json"):
var req hookRequest
if err := json.Unmarshal(body, &req); err != nil {
return "", ""
}
return req.line(), req.note()
case strings.Contains(kind, "application/x-www-form-urlencoded"):
values, err := url.ParseQuery(string(body))
if err != nil {
return "", ""
}
req := hookRequest{
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
}
return req.line(), req.note()
}
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
// man har delt fra en anden app, ud — en overskrift og noget under.
text := strings.TrimSpace(string(body))
first, rest, _ := strings.Cut(text, "\n")
return first, strings.TrimSpace(rest)
}
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
func mailToken(address string) string {
if i := strings.LastIndex(address, "<"); i >= 0 {
+3 -1
View File
@@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
"is lost. Two things in the text are read out of it and become links: " +
"`#Projekt` files the note against that project, so it appears on the " +
"project's page without anybody filing it there, and `[[Another note]]` " +
"links to a note by its title. Use this to bring notes in from elsewhere — " +
"links to a note by its title. A third, `@Navn`, shares the note with that " +
"person and tells them — so only write one when the note is meant for them. " +
"Use this to bring notes in from elsewhere — " +
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
InputSchema: mcp.Schema(map[string]any{
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
+108 -1
View File
@@ -215,6 +215,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
if strings.TrimSpace(n.Body) != "" {
s.followLinks(r, n)
s.shareWithMentioned(r, n, "")
}
writeJSON(w, http.StatusCreated, n)
}
@@ -247,6 +248,108 @@ func (s *Server) followLinks(r *http.Request, n *store.Note) {
}
}
// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @.
//
// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var
// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den
// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den.
//
// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde
// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville
// hver eneste gemning være en ny deling og en ny besked om det samme navn.
//
// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den
// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele
// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet
// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver
// kun besked til en, der kan se noten i forvejen.
//
// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der
// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet,
// og en rolle, der er hævet dér, sænkes ikke igen af en omtale.
// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også
// fortæller dem, at noten er blevet rettet.
func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool {
told := map[string]bool{}
if n == nil || n.CreatedBy == "" {
return told
}
// Slået op mod alle konti på instansen — det er dem, noten kan deles med, og
// derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke
// deles med den, der har den.
people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy)
if err != nil {
s.log.Warn("people for mentions", "err", err, "note", n.ID)
return told
}
was := map[string]bool{}
for _, id := range store.MentionsIn(before, people) {
was[id] = true
}
fresh := []string{}
for _, id := range store.MentionsIn(n.Body, people) {
if !was[id] {
fresh = append(fresh, id)
}
}
if len(fresh) == 0 {
return told
}
actor := userFrom(r.Context())
owner := actor.ID == n.CreatedBy
shared := false
for _, id := range fresh {
if !owner {
continue
}
// En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der
// ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren
// skrev deres navn i teksten.
if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil {
s.log.Warn("mention share role", "err", err, "note", n.ID)
continue
} else if has {
continue
}
if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil {
s.log.Warn("share with mentioned", "err", err, "note", n.ID)
continue
}
shared = true
}
if shared {
// Og det, noten peger på, følger med delingen — samme vej som en deling
// sat i panelet.
s.followLinks(r, n)
}
// Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har
// givet adgang, er med — og kun til dem, så en medredaktørs omtale af en
// fremmed ikke bliver en besked om en note, de ikke kan åbne.
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for mention", "err", err, "note", n.ID)
return told
}
canSee := map[string]bool{}
for _, id := range audience {
canSee[id] = true
}
for _, id := range fresh {
if !canSee[id] {
continue
}
s.notify(r, &store.Notification{
UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title,
})
told[id] = true
}
return told
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
@@ -310,7 +413,11 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
// det ville lære folk at overse dem alle sammen.
if before != n.Body {
s.notifyNoteChanged(r, n)
// Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og
// ikke også "rettede en note" om den samme gemning — det er én handling, og
// den ene sætning siger mere end den anden.
mentioned := s.shareWithMentioned(r, n, before)
s.notifyNoteChanged(r, n, mentioned)
}
writeJSON(w, http.StatusOK, n)
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler
// noten, og den, der bliver nævnt, får besked om det.
func TestMentioningSomebodySharesTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale om levering\nikke noget endnu")
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret noten: %d", resp.StatusCode)
}
// Delt, som læser.
role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || !has || role != "viewer" {
t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err)
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK {
t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode)
}
// Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én
// handling, og den ene sætning siger mere end den anden.
list, unread := notifications(t, other)
if len(list) != 1 || unread != 1 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
}
// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved
// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder.
func TestTheSameMentionOnlyCountsOnce(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
noteID := owner.createNote(t, "# Aftale\n@Sofie kigger")
// Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen.
if list, _ := notifications(t, other); len(list) != 1 {
t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list))
}
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
}
// Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre
// omtaler mere.
list, _ := notifications(t, other)
if len(list) != 2 {
t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list))
}
if firstOf(t, list)["kind"] != "note.changed" {
t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list))
}
}
// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den,
// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed.
func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
editor := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
peterClient := owner.newUser(t, "peter@example.dk", "Peter")
peter := userID(t, owner, "peter@example.dk")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren retter")
}
if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has {
t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err)
}
// Og Peter får ikke besked om en note, han ikke kan åbne.
if list, _ := notifications(t, peterClient); len(list) != 0 {
t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list))
}
// Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse.
if list, _ := notifications(t, owner); len(list) == 0 {
t.Error("ejeren hørte ikke, at medredaktøren skrev i noten")
}
}
// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke
// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn.
func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale\nintet")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "editor" {
t.Errorf("rolle = %v (%v), want editor uændret", role, err)
}
}
+15 -3
View File
@@ -143,10 +143,13 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string)
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
// `except` er dem, den samme gemning allerede har sagt noget mere præcist til:
// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet.
//
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) {
if n == nil {
return
}
@@ -159,7 +162,7 @@ func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
title := actor.Name + " rettede en note"
body := n.Title
for _, userID := range audience {
if userID == actor.ID {
if userID == actor.ID || except[userID] {
continue
}
// Fold first, and only write a row when there was nothing to fold into.
@@ -234,7 +237,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
// stadig en besked.
unread, err := s.db.UnreadNotificationCount(ctx, userID)
if err != nil {
s.log.Warn("unread count for push", "err", err)
}
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
+25
View File
@@ -66,6 +66,7 @@ type Server struct {
// Sat kun af prøver; se icsClient().
icsFetch *http.Client
mailLimiter *limiter
hookLimiter *limiter
resetLimiter *limiter
// Second factors are counted against the *account*, not the address. An
// address bucket is defeated by anybody with more than one address, which is
@@ -93,6 +94,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// day. Sixty a minute never touches real delivery and still puts a ceiling
// on the one public write path that has none.
mailLimiter: newLimiter(60, time.Minute),
hookLimiter: newLimiter(60, time.Minute),
resetLimiter: newLimiter(5, time.Hour),
// Tighter than the password bucket, and for a whole hour. Somebody typing
// their own code gets it right on the first or second try; ten in an hour is
@@ -143,6 +145,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/inbound/mail",
s.rateLimit(s.mailLimiter, http.HandlerFunc(s.handleInboundMail)))
// En krog ind i indbakken: en URL, et andet program kan skubbe en linje tekst
// til. Uden for /api/v1 og uden for sessionen af samme grund som feedet og
// posten ovenfor — den, der kalder, er en genvej på en telefon eller et script,
// og kan ikke logge ind, så tokenet i stien er hele legitimationen.
//
// Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet:
// forudindlæsning i en browser, en linkkontrol i en chat og en historik, der
// bliver besøgt igen, ville alle lave opgaver, ingen har bedt om.
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
@@ -319,6 +335,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
r.Get("/hook-url", s.handleGetHookURL)
r.Post("/hook-url/rotate", s.handleRotateHookURL)
// Personal API tokens. Behind requireSession rather than the ambient
// auth: a token must not be able to mint or revoke another.
r.Route("/tokens", func(r chi.Router) {
@@ -493,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.
+7
View File
@@ -56,6 +56,13 @@ type Payload struct {
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
//
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
// er serverens, og serveren ved det i forvejen.
Unread int `json:"unread,omitempty"`
}
var b64 = base64.RawURLEncoding
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
}
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
}
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
// ikke lykkedes.
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(with), `"unread":3`) {
t.Errorf("payload = %s, want the unread count in it", with)
}
without, err := json.Marshal(Payload{Title: "hej"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(without), "unread") {
t.Errorf("payload = %s, want no count when there is none to send", without)
}
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"sort"
"strings"
"unicode"
)
// MentionsIn reads the people named with an @ out of a body, and returns their
// ids in the order they are written.
//
// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum,
// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville
// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der
// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var
// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke.
//
// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang,
// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse.
//
// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et
// efternavn hængende bagefter — også når begge findes som konti.
func MentionsIn(body string, people []Person) []string {
if body == "" || len(people) == 0 {
return nil
}
lower := strings.ToLower(body)
byLength := make([]Person, len(people))
copy(byLength, people)
sort.SliceStable(byLength, func(i, j int) bool {
return len(byLength[i].Name) > len(byLength[j].Name)
})
// Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville
// "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og
// noten ville blive delt med en, der ikke står i den.
claimed := make([]bool, len(body))
found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres
for _, p := range byLength {
name := strings.ToLower(strings.TrimSpace(p.Name))
if name == "" || p.ID == "" {
continue
}
needle := "@" + name
for at := 0; ; {
i := strings.Index(lower[at:], needle)
if i < 0 {
break
}
i += at
at = i + len(needle)
end := i + len(needle)
if overlaps(claimed, i, end) || !isMention(body, i, end) {
continue
}
for j := i; j < end; j++ {
claimed[j] = true
}
if _, seen := found[p.ID]; !seen {
found[p.ID] = i
}
}
}
out := make([]string, 0, len(found))
for id := range found {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] })
return out
}
func overlaps(claimed []bool, from, to int) bool {
for i := from; i < to && i < len(claimed); i++ {
if claimed[i] {
return true
}
}
return false
}
// isMention er de to kanter om navnet.
//
// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en
// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn
// efter snabel-a'et.
//
// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og
// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men
// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat
// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s.
func isMention(body string, from, to int) bool {
runes := []rune(body[:from])
if len(runes) > 0 {
prev := runes[len(runes)-1]
if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' {
return false
}
}
rest := body[to:]
if rest == "" {
return true
}
next := []rune(rest)
if next[0] == 's' || next[0] == 'S' {
next = next[1:]
if len(next) == 0 {
return true
}
}
return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0])
}
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"reflect"
"testing"
)
func TestMentionsIn(t *testing.T) {
people := []Person{
{ID: "sofie", Name: "Sofie"},
{ID: "sofie-jensen", Name: "Sofie Jensen"},
{ID: "kristian", Name: "Kristian"},
}
for _, tc := range []struct {
name string
body string
want []string
}{
{"ingen omtale", "En helt almindelig note om noget", nil},
{"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}},
{"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}},
{"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}},
{"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}},
{"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}},
// Det lange navn vinder, og det korte tælles ikke med oveni: noten deles
// med den, der står i den, og ikke også med en, der ikke gør.
{"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}},
// En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse
// i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a.
{"e-mailadresse", "skriv til hej@Sofie.dk", nil},
{"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}},
{"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil},
{"ukendt navn", "@Ingen her", nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := MentionsIn(tc.body, people)
if len(got) == 0 && len(tc.want) == 0 {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want)
}
})
}
}
// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et
// tomt svar frem for et gæt på, hvad et @ mon betød.
func TestMentionsInWithoutPeople(t *testing.T) {
if got := MentionsIn("@Sofie hej", nil); got != nil {
t.Errorf("got %v, want nothing to resolve against", got)
}
}
@@ -0,0 +1,21 @@
-- En personlig adresse, andre programmer kan skubbe en opgave ind ad.
--
-- Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
-- jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
-- en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
-- mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der
-- manglede, var en URL, man kan smide en linje tekst efter.
--
-- Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed,
-- men de bliver kompromitteret hver for sig: en URL, der har ligget i en genvej på
-- en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i
-- andres adressebøger — skifter med. Og omvendt.
--
-- Ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons
-- indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på
-- en anden maskine, og en, man ikke kan.
ALTER TABLE users ADD COLUMN hook_token TEXT;
-- Opslaget er "hvem er det her token", og det er også dét, der skal være entydigt:
-- to konti med det samme token er to konti, en opgave kan lande i.
CREATE UNIQUE INDEX idx_users_hook_token ON users (hook_token) WHERE hook_token IS NOT NULL;
+46
View File
@@ -330,6 +330,52 @@ func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
return err
}
// --- krogen ind i indbakken ------------------------------------------------------
// EnsureHookToken returns the token in the person's webhook URL, minting one the
// first time it is asked for.
//
// Sit eget token frem for mailens. De to er den samme slags hemmelighed, men de
// slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der
// blev væk, skal kunne skiftes uden at mailadressen — som står i andres
// adressebøger — skifter med.
func (db *DB) EnsureHookToken(ctx context.Context, userID string) (string, error) {
var token sql.NullString
err := db.QueryRowContext(ctx, `SELECT hook_token FROM users WHERE id = ?`, userID).Scan(&token)
if err != nil {
return "", err
}
if token.Valid && token.String != "" {
return token.String, nil
}
fresh, err := auth.NewToken()
if err != nil {
return "", err
}
if err := db.SetHookToken(ctx, userID, fresh); err != nil {
return "", err
}
return fresh, nil
}
func (db *DB) SetHookToken(ctx context.Context, userID, token string) error {
_, err := db.ExecContext(ctx,
`UPDATE users SET hook_token = ?, updated_at = ? WHERE id = ?`,
token, time.Now().Unix(), userID)
return err
}
// UserByHookToken is who a webhook call is on behalf of. Tokenet er hele
// legitimationen, så et tomt et er et nej med det samme frem for et opslag, der
// kunne ramme en række, hvor søjlen aldrig er sat.
func (db *DB) UserByHookToken(ctx context.Context, token string) (*User, error) {
if token == "" {
return nil, ErrNotFound
}
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE hook_token = ?`, token)
}
func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
if token == "" {
return nil, ErrNotFound
+15
View File
@@ -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 });
});
+10
View 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'),
@@ -418,6 +423,11 @@ export const api = {
getMailAddress: () => get('/mail-address'),
rotateMailAddress: () => post('/mail-address/rotate'),
// Krogen ind i indbakken. Selve skubbet sker ikke herfra — det er et andet
// program, der gør det — så der er kun adressen at hente og at skifte.
getHookURL: () => get('/hook-url'),
rotateHookURL: () => post('/hook-url/rotate'),
getGmail: () => get('/gmail'),
setGmail: (data) => put('/gmail', data),
disconnectGmail: () => del('/gmail'),
+46
View File
@@ -0,0 +1,46 @@
/**
* Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc.
*
* Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det
* halve af pointen med at få besked er at få den, mens man laver noget andet, og
* det eneste sted, appen kan sige noget dér, er på sit eget ikon.
*
* Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved
* hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`-
* lytteren i service-worker.js, som får tallet med i beskeden.
*
* Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der
* lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst.
*/
/**
* Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en
* eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør
* ingenting. Firefox kan ikke endnu.
*/
export function supported() {
return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
}
/**
* Sætter mærket til `n`, eller fjerner det ved nul.
*
* Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken,
* der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren
* siger nej.
*/
export function setBadge(n) {
if (!supported()) return;
const count = Number(n) || 0;
try {
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
done?.catch?.(() => {});
} catch {
// Nogle browsere kaster i stedet for at afvise. Det er det samme svar.
}
}
/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */
export function clearBadge() {
setBadge(0);
}
+142
View File
@@ -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>
+38 -2
View File
@@ -20,7 +20,10 @@
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, notes = [], onchange, onsave, onopennote } = $props();
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
// to hentninger af den samme liste er to, der kan blive uenige.
let { note, notes = [], people = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -87,6 +90,7 @@
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '@', what: 'notes.syntaxPerson' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
@@ -509,6 +513,19 @@
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
}
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
// læse sætningen efter navnet med.
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
if (person) {
return {
kind: 'person',
node,
start: selection.anchorOffset - person[1].length,
term: person[1]
};
}
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (project) {
return {
@@ -562,7 +579,12 @@
// pixels for hvert bogstav, mens listen snævres ind.
if (!suggestions.length) menuAt = tagPosition(partial);
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
suggestions =
partial.kind === 'note'
? noteMatches(term)
: partial.kind === 'person'
? personMatches(term)
: projectMatches(term);
chosen = 0;
}
@@ -586,6 +608,16 @@
.sort((a, b) => rank(a.label, b.label, term));
}
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
function personMatches(term) {
return people
.filter((p) => p.name && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
.sort((a, b) => rank(a.label, b.label, term));
}
function rank(a, b, term) {
const al = a.toLowerCase();
const bl = b.toLowerCase();
@@ -1011,6 +1043,10 @@
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else if item.kind === 'person'}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@@ -24,6 +24,7 @@
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
comment: 'notif.comment'
};
+4
View File
@@ -1,6 +1,7 @@
<script>
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { clearBadge } from '$lib/badge.js';
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
@@ -31,6 +32,9 @@
async function signOut() {
await api.logout();
// Mærket på ikonet er den forriges ulæste. Det står i Dock'en, også når
// ingen er logget ind, så det skal væk sammen med resten af dem.
clearBadge();
// Empty the offline data cache before leaving: the service worker keeps the
// last-seen task and note lists so a network drop does not blank the app, and
// the next person to sign in on this browser must not find them.
+32
View File
@@ -31,6 +31,7 @@ export const da = {
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
@@ -62,6 +63,10 @@ export const da = {
'notes.inviteRevoke': 'Træk invitationen tilbage',
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
// Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her,
// fordi det sker uden at nogen åbner delepanelet.
'notes.syntaxPerson': 'en person',
'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
@@ -85,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',
@@ -746,6 +767,17 @@ export const da = {
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
'int.yourAddress': 'Din adresse',
'int.newAddress': 'Ny adresse',
// Krogen ind i indbakken: en URL, et andet program kan skubbe en opgave til.
'int.hookToTask': 'Skub fra andre programmer',
'int.hookHint':
'En adresse, andre programmer kan sende en opgave til — en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Linjen læses som i feltet øverst i fladen, så “i morgen”, “p1” og “#Projekt” betyder det samme her.',
'int.yourHook': 'Din adresse',
'int.hookHow':
'Send en POST med teksten som krop — ren tekst, JSON med et {text}-felt, eller et formularfelt. Første linje bliver opgaven, resten står under den. Adressen er en nøgle: den, der har den, kan lægge opgaver i din indbakke og ikke andet.',
'int.hookExample': 'Sådan ser det ud',
'int.newHook': 'Ny adresse',
'int.newHookQuestion':
'Vil du have en ny adresse? Den gamle holder op med at virke med det samme, og alt, der bruger den, skal rettes.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
+32
View File
@@ -28,6 +28,7 @@ export const en = {
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
@@ -59,6 +60,10 @@ export const en = {
'notes.inviteRevoke': 'Withdraw the invitation',
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
// A mention. `@Name` in a note shares it with the person named — said here,
// because it happens without anybody opening the share panel.
'notes.syntaxPerson': 'a person',
'notes.mentionShared': 'The note has been shared with {names}, because you named them.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
'notes.format': 'Format',
@@ -82,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',
@@ -743,6 +764,17 @@ export const en = {
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
'int.yourAddress': 'Your address',
'int.newAddress': 'New address',
// The hook into the inbox: a URL another program can push a task to.
'int.hookToTask': 'Push from other programs',
'int.hookHint':
'An address other programs can send a task to — a shortcut on your phone, a script, any service that can call a URL. The line is read the way the box at the top of the app reads it, so “tomorrow”, “p1” and “#Project” mean the same here.',
'int.yourHook': 'Your address',
'int.hookHow':
'Send a POST with the text as the body — plain text, JSON with a {text} field, or a form field. The first line becomes the task and the rest sits underneath it. The address is a key: whoever has it can put tasks in your inbox, and nothing else.',
'int.hookExample': 'What it looks like',
'int.newHook': 'New address',
'int.newHookQuestion':
'Do you want a new address? The old one stops working immediately, and anything using it has to be changed.',
'int.caldav': 'CalDAV',
'int.caldavHint':
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
+16
View File
@@ -10,6 +10,8 @@
import SignIn from '$lib/components/SignIn.svelte';
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();
@@ -53,6 +55,13 @@
app.load();
});
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
$effect(() => {
setBadge(app.unread);
});
/**
* Global keys.
*
@@ -184,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}
@@ -289,6 +289,9 @@
let feedURL = $state('');
let mailAddress = $state('');
// Krogen ind i indbakken: den URL, en genvej på telefonen eller et script kan
// skubbe en linje tekst til.
let hookURL = $state('');
/** Null until loaded, so the warning does not flash before the answer is in. */
let mailConfigured = $state(null);
@@ -301,6 +304,10 @@
mailConfigured = r.configured;
})
.catch(() => {});
api
.getHookURL()
.then((r) => (hookURL = r.url))
.catch(() => {});
});
async function rotateFeed() {
@@ -323,6 +330,15 @@
}
}
async function rotateHook() {
if (!confirm(t('int.newHookQuestion'))) return;
try {
hookURL = (await api.rotateHookURL()).url;
} catch (e) {
app.toast(humanMessage(e));
}
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
@@ -674,6 +690,38 @@
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
{t('int.hookHint')}
</p>
</header>
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly />
<p class="hint">
{t('int.hookHow')}
</p>
</div>
<!-- Vist som den kommando, den er. Den, der skal sætte det op, skal kunne
prøve det af ét sted, før de bygger genvejen på telefonen — og se, at
kroppen bare er den linje, opgaven skal hedde. -->
<div class="field">
<label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
>
</div>
<div class="row">
<button class="secondary" onclick={() => copy(hookURL)}>{t('int.copy')}</button>
<button class="danger" onclick={rotateHook}>{t('int.newHook')}</button>
</div>
</section>
<section class="panel">
<header>
<h2>{t('int.caldav')}</h2>
+50
View File
@@ -209,6 +209,21 @@
let shareInviteLink = $state('');
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// Dem, et `@` kan betyde i den åbne note.
//
// I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt
// med: en omtale af en, der læser med i forvejen, er en almindelig ting at
// skrive, og den giver dem besked selv om den ikke giver dem adgang.
//
// I en andens note er det dem, man deler projekter med. En medredaktørs omtale
// deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens,
// og skal heller ikke være det.
const mentionable = $derived(
ownsSelected
? [...shareCandidates, ...shares.map((sh) => sh.user)]
: app.people.filter((p) => p.id !== app.user?.id)
);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
@@ -659,6 +674,7 @@
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
await mentionsShared(id);
} catch (e) {
app.toast(humanMessage(e));
} finally {
@@ -666,6 +682,39 @@
}
}
/**
* Hvem gemningen delte noten med, fordi de blev nævnt i den.
*
* Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting
* blandt mange gemninger, og en tastepause skal ikke koste et opslag i
* delingerne, hver gang den falder. Panelet spørges i stedet — og kun når
* teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv
* bruger for at kigge efter.
*
* Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man
* skrev navnet på, skal sige det — også når panelet er lukket.
*/
async function mentionsShared(id) {
if (!ownsSelected || !draft.includes('@')) return;
const known = new Set(shares.map((sh) => sh.user.id));
try {
const r = await api.noteShares(id);
if (selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
const added = shares.filter((sh) => !known.has(sh.user.id));
if (added.length) {
app.toast(
t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') })
);
}
} catch {
// En liste, der ikke kunne hentes, er ikke en gemning, der gik galt.
}
}
/**
* Favourites are the pinned ones. The list already puts them first, so marking
* one is the same act as saying "keep this where I can see it" — one idea, not
@@ -1028,6 +1077,7 @@
<NoteEditor
note={selected}
notes={notes}
people={mentionable}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
+13
View File
@@ -145,6 +145,19 @@ self.addEventListener('push', (event) => {
}
const title = payload.title || 'verdande';
// Mærket på appens ikon — i Dock'en på en Mac, på proceslinjen på en pc.
//
// Sat her, fordi det her er det eneste, der kører, når appen ikke er åben: det
// er netop dér, et tal på ikonet er det, der siger, at der er noget nyt. Tallet
// kommer med beskeden frem for at blive talt op her; en service-worker husker
// ingenting mellem to opvågninger, så et mærke, den selv talte, ville kun kunne
// gå opad.
//
// Kan browseren ikke sætte mærker, sker der ingenting. Det er ikke en fejl at
// melde om: beskeden selv kom frem, og den er hovedsagen.
if (typeof payload.unread === 'number' && 'setAppBadge' in self.navigator) {
self.navigator.setAppBadge(payload.unread).catch(() => {});
}
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',