From d063425cae53812a0a5f4da5d7f534670659b40c Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 8 Sep 2026 14:03:32 +0200 Subject: [PATCH] feat(indbakke): en adresse, andre programmer kan skubbe en opgave til MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi.yaml | 91 ++++++++ internal/httpapi/inbound_hook_test.go | 194 ++++++++++++++++++ internal/httpapi/integrations_handlers.go | 187 +++++++++++++++++ internal/httpapi/server.go | 19 ++ .../0035_en_krog_ind_i_indbakken.sql | 21 ++ internal/store/notifications.go | 46 +++++ web/src/lib/api.js | 5 + web/src/lib/locales/da.js | 11 + web/src/lib/locales/en.js | 11 + .../indstillinger/integrationer/+page.svelte | 48 +++++ 10 files changed, 633 insertions(+) create mode 100644 internal/httpapi/inbound_hook_test.go create mode 100644 internal/store/migrations/0035_en_krog_ind_i_indbakken.sql diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 400ac6c..10057a6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2718,6 +2718,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] @@ -3741,6 +3760,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 +4089,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 diff --git a/internal/httpapi/inbound_hook_test.go b/internal/httpapi/inbound_hook_test.go new file mode 100644 index 0000000..2e4b049 --- /dev/null +++ b/internal/httpapi/inbound_hook_test.go @@ -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") + } +} diff --git a/internal/httpapi/integrations_handlers.go b/internal/httpapi/integrations_handlers.go index b55d5e4..5a8731f 100644 --- a/internal/httpapi/integrations_handlers.go +++ b/internal/httpapi/integrations_handlers.go @@ -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 ". func mailToken(address string) string { if i := strings.LastIndex(address, "<"); i >= 0 { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 697752e..369bc9a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) { diff --git a/internal/store/migrations/0035_en_krog_ind_i_indbakken.sql b/internal/store/migrations/0035_en_krog_ind_i_indbakken.sql new file mode 100644 index 0000000..b0b79a9 --- /dev/null +++ b/internal/store/migrations/0035_en_krog_ind_i_indbakken.sql @@ -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; diff --git a/internal/store/notifications.go b/internal/store/notifications.go index 2dc1d6b..0990d32 100644 --- a/internal/store/notifications.go +++ b/internal/store/notifications.go @@ -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 diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 196a688..f56cdef 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -418,6 +418,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'), diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 5f5aaf8..9340418 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -746,6 +746,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.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index c14cd53..95a49d5 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -743,6 +743,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.', diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index 0b296b4..f948921 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -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 @@ +
+
+

{t('int.hookToTask')}

+

+ {t('int.hookHint')} +

+
+ +
+ + +

+ {t('int.hookHow')} +

+
+ + +
+ + +
+ +
+ + +
+
+

{t('int.caldav')}