Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user