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')}