diff --git a/Dockerfile b/Dockerfile index 4e01b89..a4ce457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,9 +109,9 @@ WORKDIR /app COPY --from=builder /build/bin/simplexcalc /app/simplexcalc # Data directory: the SimpleX database, which holds the bot's profile, -# its keys, its address and its contacts. Mount a volume over it; -# without one, the bot gets a new address every time the container is -# recreated. +# its keys, its address and its contacts, and the API's webhooks. Mount +# a volume over it; without one, the bot gets a new address and loses +# its webhooks every time the container is recreated. RUN mkdir -p /var/lib/simplexcalc && \ chown simplexcalc:simplexcalc /var/lib/simplexcalc diff --git a/README.md b/README.md index c7f8d06..331e690 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,10 @@ development. is never quietly replaced by the default. Defaults apply only to variables that are absent. -- `DATA_DIR` — where the SimpleX database lives: the bot's profile, its - keys, its address and its contacts. Default `./data`; the image sets +- `DATA_DIR` — where the bot keeps what it must not lose: the SimpleX + database, which holds the bot's profile, its keys, its address and its + contacts, and `webhooks.json`, which holds the webhooks registered + through the [API](#api). Default `./data`; the image sets `/var/lib/simplexcalc`. - `DEBUG` — `true` or `false`, default `false`. `true` logs every event the chat client sends. @@ -89,7 +91,8 @@ variables that are absent. ## API An HTTP API beside the chat client lets another program read the bot's -chats and send messages in them. It speaks JSON on `PORT`. +chats, send messages in them and register webhooks on them. It speaks +JSON on `PORT`. **Authentication.** Every request carries the credential from `API_TOKEN_FILE`: @@ -218,6 +221,88 @@ Nothing is sent, and the answer is: - `413` if the body is over 64 KiB, or the text is too long for one SimpleX message, which holds about 15,000 bytes. +### `POST /api/v1/chats/{id}/webhooks` + +Registers the `url` in the body as a webhook on the chat `id`, and +answers `201` with the webhook. If the chat already has a webhook with +the same `url`, character for character, the answer is `200` with that +webhook instead. + +```sh +curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"url":"https://example.com/hook"}' \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks +``` + +```json +{ + "id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80", + "chat_id": 3, + "url": "https://example.com/hook" +} +``` + +A webhook has: + +- `id` — 16 random bytes, written as 32 hexadecimal digits. +- `chat_id` — the chat it is registered on. +- `url` — the URL, as it was registered. + +The bot keeps the webhooks in `webhooks.json` in `DATA_DIR`, with mode +0600, and reads that file at startup, so they survive a restart. Without +the file there are none; a file that cannot be read, or that holds +anything but webhooks as the bot writes them, aborts startup, as +configuration that cannot be parsed does. The bot does not post anything +to a webhook yet. + +Nothing is registered, and the answer is: + +- `400` if the body is not JSON of that shape, or `url` is not an + absolute `http` or `https` URL with a host, at most 2048 bytes long; +- `404` if `GET /api/v1/chats` does not list `id`; +- `413` if the body is over 64 KiB; +- `500` if `webhooks.json` cannot be written. + +### `GET /api/v1/chats/{id}/webhooks` + +The webhooks registered on the chat `id`, in the order they were +registered; `404` if `GET /api/v1/chats` does not list `id`. A chat's +webhooks stay after its contact deletes the chat (`contact_deleted` is +`true`), until they are removed. + +```sh +curl -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks +``` + +```json +{ + "webhooks": [ + { + "id": "5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80", + "chat_id": 3, + "url": "https://example.com/hook" + } + ] +} +``` + +### `DELETE /api/v1/chats/{id}/webhooks/{webhook_id}` + +Removes the webhook `webhook_id` from the chat `id`, and answers `204` +with no body. + +```sh +curl -X DELETE -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8080/api/v1/chats/3/webhooks/5f0c7a1e9b2d4c8e3a6f1b7d2e9c4a80 +``` + +Nothing is removed, and the answer is: + +- `404` if `GET /api/v1/chats` does not list `id`, or the chat has no + webhook `webhook_id`; +- `500` if `webhooks.json` cannot be written. + ## Entrypoints This repo adheres to the @@ -315,6 +400,12 @@ container. the one that delivers events, which also delivers the chat client's answers. When the bot stops, requests in progress get 5 seconds to finish before the chat client is stopped. +- **Webhooks** (`internal/api`) are read from `$DATA_DIR/webhooks.json` + before the chat client starts. Changes are made one at a time, and + each rewrites the file whole: the bot writes a temporary file beside + it, named `webhooks.json.` and digits, and renames it over the old + one. A crash leaves the old file or the new one, never part of one, + and at worst a stray temporary file, which the bot ignores. - **Replies**: for each text message a contact sends in a direct chat, the bot sends back the result, as a reply quoting the message. Group messages, files and the bot's own messages are ignored. @@ -359,12 +450,13 @@ container. ## Operating it **Backup.** Everything durable is on the volume: the SimpleX database, -`simplex_chat.db` and `simplex_agent.db`, and the API credential, -`api-token`. Stop the container before copying the database, since a -copy taken from under the running client can be inconsistent. The -database holds the bot's keys, so a copy lets its holder answer as the -bot, and the credential lets its holder use the API; keep both as -private as the running instance. +`simplex_chat.db` and `simplex_agent.db`; the API credential, +`api-token`; and the webhooks, `webhooks.json`. Stop the container +before copying the database, since a copy taken from under the running +client can be inconsistent. The database holds the bot's keys, so a copy +lets its holder answer as the bot; the credential lets its holder use +the API; and a webhook's URL can hold a secret of the program it points +at. Keep all three as private as the running instance. **Upgrade.** Rebuild the image and recreate the container with the same volume. The chat client migrates its database on start. A newer diff --git a/docs/TODO.md b/docs/TODO.md index 7fa040e..b2160f5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,8 @@ with no deprecation warning. # Completed Steps +- 2026-09-29 `POST`, `GET` and `DELETE` on a chat's webhooks, under + `/api/v1/chats/{id}/webhooks`, kept in `$DATA_DIR/webhooks.json` - 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's latest messages, and sending one - 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`, diff --git a/internal/api/api.go b/internal/api/api.go index c05bad9..36d8eac 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,5 +1,6 @@ // Package api is the bot's HTTP API, through which another program -// reads the bot's chats and sends messages in them. +// reads the bot's chats, sends messages in them and registers webhooks +// on them. // // Every request must carry the credential, as "Authorization: Bearer // {credential}"; with no credential configured, every request is @@ -14,6 +15,8 @@ import ( "context" "crypto/subtle" "encoding/json" + "errors" + "io" "log/slog" "net/http" "strconv" @@ -75,6 +78,10 @@ type Params struct { // Token is the credential every request must carry. Empty refuses // every request. Token string + + // Webhooks holds the webhooks registered on the chats; ReadWebhooks + // makes it. + Webhooks *Webhooks } // New returns the API's server. The caller starts it with @@ -84,7 +91,13 @@ func New(p Params) *http.Server { p.Log.Warn("API_TOKEN_FILE is not set, so the API refuses every request") } - h := &handlers{log: p.Log, client: p.Client, userID: p.UserID, token: p.Token} + h := &handlers{ + log: p.Log, + client: p.Client, + userID: p.UserID, + token: p.Token, + webhooks: p.Webhooks, + } router := chi.NewRouter() router.Use(securityHeaders, h.authenticate, @@ -99,6 +112,9 @@ func New(p Params) *http.Server { r.Get("/chats", h.handleChats()) r.Get("/chats/{id}/messages", h.handleMessages()) r.Post("/chats/{id}/messages", h.handleSend()) + r.Get("/chats/{id}/webhooks", h.handleWebhooks()) + r.Post("/chats/{id}/webhooks", h.handleRegister()) + r.Delete("/chats/{id}/webhooks/{webhook_id}", h.handleRemove()) }) return &http.Server{ @@ -119,10 +135,11 @@ func New(p Params) *http.Server { // handlers holds what the handlers share. type handlers struct { - log *slog.Logger - client ChatClient - userID int64 - token string + log *slog.Logger + client ChatClient + userID int64 + token string + webhooks *Webhooks } // authenticate lets a request through only if it carries the @@ -165,6 +182,30 @@ func (h *handlers) respondError(w http.ResponseWriter, status int, sentence stri }{sentence}) } +// decodeBody decodes the request's JSON body into v. If the body is too +// large, or is not JSON of v's shape, it answers the request itself, 413 +// or 400 naming example as the shape wanted, and returns false. +func (h *handlers) decodeBody( + w http.ResponseWriter, r *http.Request, v any, example string, +) bool { + body, err := io.ReadAll(r.Body) + + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large") + + return false + } + + if err != nil || json.Unmarshal(body, v) != nil { + h.respondError(w, http.StatusBadRequest, "the body must be JSON such as "+example) + + return false + } + + return true +} + // securityHeaders go on every response. The API returns JSON to // programs, so a browser may not frame, sniff, cache or refer from it, // nor give it the camera, microphone or location, and must reach it diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ece56ba..97cebd9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -23,6 +23,7 @@ const ( chatsPath = "/api/v1/chats" messagesPath = "/api/v1/chats/3/messages" + webhooksPath = "/api/v1/chats/3/webhooks" unauthorized = `{"error":"unauthorized"}` + "\n" ) @@ -197,6 +198,12 @@ func TestNoPathIsExempt(t *testing.T) { http.MethodPost, messagesPath, "", http.StatusUnauthorized, unauthorized, }, + {http.MethodGet, webhooksPath, "", http.StatusUnauthorized, unauthorized}, + {http.MethodPost, webhooksPath, "", http.StatusUnauthorized, unauthorized}, + { + http.MethodDelete, webhooksPath + "/" + unknownID, "", + http.StatusUnauthorized, unauthorized, + }, } { rec := request(t, srv, tc.method, tc.path, tc.auth) if rec.Code != tc.want || rec.Body.String() != tc.body { diff --git a/internal/api/messages.go b/internal/api/messages.go index 8c0fb34..19547b9 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -1,9 +1,7 @@ package api import ( - "encoding/json" "errors" - "io" "net/http" "net/url" "strconv" @@ -124,21 +122,8 @@ func (h *handlers) handleSend() http.HandlerFunc { return } - body, err := io.ReadAll(r.Body) - - var tooLarge *http.MaxBytesError - if errors.As(err, &tooLarge) { - h.respondError(w, http.StatusRequestEntityTooLarge, "the body is too large") - - return - } - var req request - - if err != nil || json.Unmarshal(body, &req) != nil { - h.respondError(w, http.StatusBadRequest, - `the body must be JSON such as {"text":"hello"}`) - + if !h.decodeBody(w, r, &req, `{"text":"hello"}`) { return } diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go new file mode 100644 index 0000000..5578ce7 --- /dev/null +++ b/internal/api/webhooks.go @@ -0,0 +1,306 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + + "github.com/go-chi/chi/v5" +) + +const ( + // webhooksFile, in the data directory, keeps the webhooks. + webhooksFile = "webhooks.json" + + // idBytes is how many random bytes make a webhook's id. + idBytes = 16 + + // maxURLBytes caps a webhook's URL. + maxURLBytes = 2048 +) + +// errWebhooksFile is the error for a webhooks file that does not hold +// webhooks as the bot writes them. +var errWebhooksFile = errors.New("not a list of webhooks as the bot writes it") + +// webhook is a URL registered on a chat. +type webhook struct { + ID string `json:"id"` + ChatID int64 `json:"chat_id"` + URL string `json:"url"` +} + +// valid reports whether w is a webhook that registering could make. +func (w webhook) valid() bool { + id, err := hex.DecodeString(w.ID) + + return err == nil && len(id) == idBytes && w.ChatID > 0 && validURL(w.URL) +} + +// webhookList is how a list of webhooks is written: in the answer to GET, +// and in the webhooks file. +type webhookList struct { + Webhooks []webhook `json:"webhooks"` +} + +// Webhooks holds the webhooks registered on every chat, and keeps them in +// the webhooks file. It is safe for concurrent use. +type Webhooks struct { + path string + + // mu is held across each change, the write of the file included, so + // the changes reach the file one at a time and in order. + mu sync.Mutex + + // all is in the order the webhooks were registered. It is never nil, + // which would be written as null, a file ReadWebhooks refuses. + all []webhook +} + +// ReadWebhooks returns the webhooks kept in dir, and none if dir has no +// webhooks file. A file that cannot be read, or that holds anything but +// webhooks as the bot writes them, is an error, so that the bot neither +// starts without them nor later writes over them. +func ReadWebhooks(dir string) (*Webhooks, error) { + path := filepath.Join(dir, webhooksFile) + + b, err := os.ReadFile(path) //nolint:gosec // G304: the data directory's own file. + if errors.Is(err, fs.ErrNotExist) { + return &Webhooks{path: path, all: []webhook{}}, nil + } + + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var file webhookList + + err = json.Unmarshal(b, &file) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + // The list is nil only if the file has none, as in {} or null; [] is + // an empty list. + if file.Webhooks == nil { + return nil, fmt.Errorf("reading %s: %w", path, errWebhooksFile) + } + + for _, w := range file.Webhooks { + if !w.valid() { + return nil, fmt.Errorf("reading %s: %w", path, errWebhooksFile) + } + } + + return &Webhooks{path: path, all: file.Webhooks}, nil +} + +// register registers hookURL on the chat chatID, and returns the new +// webhook and true; or, if that chat has a webhook with that URL already, +// that webhook and false. +func (s *Webhooks) register(chatID int64, hookURL string) (webhook, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + i := slices.IndexFunc(s.all, func(w webhook) bool { + return w.ChatID == chatID && w.URL == hookURL + }) + if i >= 0 { + return s.all[i], false, nil + } + + id := make([]byte, idBytes) + // Never fails: crypto/rand ends the program instead. + _, _ = rand.Read(id) + + added := webhook{ID: hex.EncodeToString(id), ChatID: chatID, URL: hookURL} + all := append(slices.Clone(s.all), added) + + err := s.write(all) + if err != nil { + return webhook{}, false, err + } + + s.all = all + + return added, true, nil +} + +// list returns the webhooks registered on the chat chatID, in the order +// they were registered. +func (s *Webhooks) list(chatID int64) []webhook { + s.mu.Lock() + defer s.mu.Unlock() + + hooks := make([]webhook, 0, len(s.all)) + + for _, w := range s.all { + if w.ChatID == chatID { + hooks = append(hooks, w) + } + } + + return hooks +} + +// remove removes the webhook id from the chat chatID, and returns false if +// that chat has no such webhook. +func (s *Webhooks) remove(chatID int64, id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + all := slices.DeleteFunc(slices.Clone(s.all), func(w webhook) bool { + return w.ChatID == chatID && w.ID == id + }) + if len(all) == len(s.all) { + return false, nil + } + + err := s.write(all) + if err != nil { + return false, err + } + + s.all = all + + return true, nil +} + +// write replaces the webhooks file with one holding all. It writes a +// temporary file in the same directory and renames it over the old one, +// so a crash leaves the old file or the new one, never part of one, and a +// failure leaves the old file as it was. os.CreateTemp creates the +// temporary file with mode 0600, which the file keeps. +func (s *Webhooks) write(all []webhook) error { + b, err := json.MarshalIndent(webhookList{Webhooks: all}, "", " ") + if err != nil { + return fmt.Errorf("writing %s: %w", s.path, err) + } + + tmp, err := os.CreateTemp(filepath.Dir(s.path), webhooksFile+".*") + if err != nil { + return fmt.Errorf("writing %s: %w", s.path, err) + } + + // Synced before the rename, or a crash could leave the new file + // without its contents. + _, err = tmp.Write(append(b, '\n')) + + err = errors.Join(err, tmp.Sync(), tmp.Close()) + if err == nil { + err = os.Rename(tmp.Name(), s.path) + } + + if err != nil { + _ = os.Remove(tmp.Name()) + + return fmt.Errorf("writing %s: %w", s.path, err) + } + + return nil +} + +// validURL reports whether s can be a webhook's URL: an absolute http or +// https URL with a host, at most maxURLBytes long. +func validURL(s string) bool { + if len(s) > maxURLBytes { + return false + } + + u, err := url.Parse(s) + + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && + u.Hostname() != "" +} + +// handleWebhooks lists a chat's webhooks. +func (h *handlers) handleWebhooks() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + h.respond(w, http.StatusOK, webhookList{Webhooks: h.webhooks.list(chatID)}) + } +} + +// handleRegister registers the URL in the request's body on a chat, and +// answers with the webhook: 201 if it is new, 200 if the chat had it. +func (h *handlers) handleRegister() http.HandlerFunc { + type request struct { + URL string `json:"url"` + } + + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + var req request + if !h.decodeBody(w, r, &req, `{"url":"https://example.com/hook"}`) { + return + } + + if !validURL(req.URL) { + h.respondError(w, http.StatusBadRequest, "url must be an http or https URL "+ + "with a host, at most "+strconv.Itoa(maxURLBytes)+" bytes long") + + return + } + + hook, added, err := h.webhooks.register(chatID, req.URL) + if err != nil { + h.log.Error("registering a webhook", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the webhooks could not be saved") + + return + } + + status := http.StatusOK + if added { + status = http.StatusCreated + } + + h.respond(w, status, hook) + } +} + +// handleRemove removes a webhook from a chat. +func (h *handlers) handleRemove() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + chatID, ok := h.chatID(w, r) + if !ok { + return + } + + removed, err := h.webhooks.remove(chatID, chi.URLParam(r, "webhook_id")) + if err != nil { + h.log.Error("removing a webhook", "error", err) + h.respondError(w, http.StatusInternalServerError, + "the webhooks could not be saved") + + return + } + + if !removed { + h.respondError(w, http.StatusNotFound, "no such webhook") + + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/api/webhooks_test.go b/internal/api/webhooks_test.go new file mode 100644 index 0000000..e0a86a0 --- /dev/null +++ b/internal/api/webhooks_test.go @@ -0,0 +1,510 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "testing" + + "sneak.berlin/go/simplexcalc/internal/api" + "sneak.berlin/go/simplexcalc/internal/simplex" +) + +const ( + hookURL = "https://example.com/hook" + + // unknownID is shaped like a webhook's id, and no webhook has it. + unknownID = "00112233445566778899aabbccddeeff" +) + +// hook is a webhook as the API answers with it. +type hook struct { + ID string `json:"id"` + ChatID int64 `json:"chat_id"` + URL string `json:"url"` +} + +// String returns h as the API writes it. +func (h hook) String() string { + return `{"id":"` + h.ID + `","chat_id":` + strconv.FormatInt(h.ChatID, 10) + + `,"url":"` + h.URL + `"}` +} + +// twoChats returns the bot's contacts in these tests: chat 3, which +// webhooksPath names, and chat 4, whose contact has deleted it. +func twoChats() []simplex.Contact { + return append(oneChat(), simplex.Contact{ContactID: 4, Status: "deleted"}) +} + +// readWebhooks returns the webhooks kept in dir. +func readWebhooks(t *testing.T, dir string) *api.Webhooks { + t.Helper() + + webhooks, err := api.ReadWebhooks(dir) + if err != nil { + t.Fatal(err) + } + + return webhooks +} + +// webhookAPI returns an API with webhooks, whose chats are those of +// twoChats. +func webhookAPI(webhooks *api.Webhooks) *http.Server { + return api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: &fakeClient{contacts: twoChats()}, + UserID: 1, + Port: 8080, + Token: credential, + Webhooks: webhooks, + }) +} + +// register registers u on the chat whose webhooks are at path, and +// returns the webhook answered with. The answer must have status. +func register(t *testing.T, srv *http.Server, path, u string, status int) hook { + t.Helper() + + rec := post(t, srv, path, `{"url":"`+u+`"}`) + + var h hook + + err := json.Unmarshal(rec.Body.Bytes(), &h) + if err != nil || rec.Code != status || rec.Body.String() != h.String()+"\n" { + t.Fatalf("registering %s: %d %q, want %d and a webhook", + u, rec.Code, rec.Body.String(), status) + } + + return h +} + +// listed fails the test unless the chat whose webhooks are at path lists +// want, in that order. +func listed(t *testing.T, srv *http.Server, path string, want ...hook) { + t.Helper() + + hooks := make([]string, 0, len(want)) + for _, h := range want { + hooks = append(hooks, h.String()) + } + + body := `{"webhooks":[` + strings.Join(hooks, ",") + "]}\n" + + rec := request(t, srv, http.MethodGet, path, bearer) + if rec.Code != http.StatusOK || rec.Body.String() != body { + t.Errorf("GET %s: %d %q, want 200 %q", path, rec.Code, rec.Body.String(), body) + } +} + +// eachEndpoint lists the webhooks of the chat whose webhooks are at path, +// registers one and removes one, and returns the three answers. +func eachEndpoint( + t *testing.T, srv *http.Server, path string, +) []*httptest.ResponseRecorder { + t.Helper() + + return []*httptest.ResponseRecorder{ + request(t, srv, http.MethodGet, path, bearer), + post(t, srv, path, `{"url":"`+hookURL+`"}`), + request(t, srv, http.MethodDelete, path+"/"+unknownID, bearer), + } +} + +// onlyFile fails the test unless dir holds the webhooks file and nothing +// else: no temporary file is left. +func onlyFile(t *testing.T, dir string) { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 || entries[0].Name() != "webhooks.json" { + t.Errorf("the directory holds %v (%v), want webhooks.json alone", entries, err) + } +} + +// noFile fails the test if dir has a webhooks file. +func noFile(t *testing.T, dir string) { + t.Helper() + + _, err := os.Stat(filepath.Join(dir, "webhooks.json")) + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("webhooks.json: %v, want it not written", err) + } +} + +// TestWebhooks: a URL registered on a chat gets 201 and a new id of 32 +// hexadecimal digits; the same URL again gets 200 and the same webhook. +// Each chat lists its own, in the order registered, the chat of a contact +// who deleted it included. +func TestWebhooks(t *testing.T) { + t.Parallel() + + srv := webhookAPI(readWebhooks(t, t.TempDir())) + + listed(t, srv, webhooksPath) + + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + again := register(t, srv, webhooksPath, hookURL, http.StatusOK) + second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + other := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated) + + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first.ID) || + first.ChatID != 3 || first.URL != hookURL { + t.Errorf("registered %v, want a new id, chat 3 and %s", first, hookURL) + } + + if again != first { + t.Errorf("registered again: %v, want %v", again, first) + } + + if second.ID == first.ID || other.ID == first.ID || other.ID == second.ID { + t.Errorf("ids repeat: %v, %v, %v", first, second, other) + } + + listed(t, srv, webhooksPath, first, second) + listed(t, srv, "/api/v1/chats/4/webhooks", other) +} + +// TestRemoveWebhook: a removed webhook gets 204 with no body and is no +// longer listed. Removing it again, removing a webhook through a chat it +// is not on, or removing an id no webhook has, gets 404. +func TestRemoveWebhook(t *testing.T) { + t.Parallel() + + srv := webhookAPI(readWebhooks(t, t.TempDir())) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + + rec := request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer) + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Errorf("removing: %d %q, want 204 and no body", rec.Code, rec.Body.String()) + } + + noSuchWebhook := `{"error":"no such webhook"}` + "\n" + + for _, path := range []string{ + webhooksPath + "/" + first.ID, + "/api/v1/chats/4/webhooks/" + second.ID, + webhooksPath + "/" + unknownID, + } { + rec := request(t, srv, http.MethodDelete, path, bearer) + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchWebhook { + t.Errorf("DELETE %s: %d %q, want 404 %q", + path, rec.Code, rec.Body.String(), noSuchWebhook) + } + } + + listed(t, srv, webhooksPath, second) +} + +// TestRegisterRefused: a body that is not JSON with a URL a webhook can +// have, or that is over 64 KiB, registers nothing. A URL of 2048 bytes +// is registered. +func TestRegisterRefused(t *testing.T) { + t.Parallel() + + notJSON := `{"error":"the body must be JSON such as ` + + `{\"url\":\"https://example.com/hook\"}"}` + "\n" + badURL := `{"error":"url must be an http or https URL with a host, ` + + `at most 2048 bytes long"}` + "\n" + longest := hookURL + "/" + strings.Repeat("a", 2048-len(hookURL)-1) + + for name, tc := range map[string]struct { + body string + status int + answer string + }{ + "no body": {"", http.StatusBadRequest, notJSON}, + "a bare URL": {hookURL, http.StatusBadRequest, notJSON}, + "url not a string": {`{"url":5}`, http.StatusBadRequest, notJSON}, + "no url": {`{}`, http.StatusBadRequest, badURL}, + "relative": {`{"url":"/hook"}`, http.StatusBadRequest, badURL}, + "no scheme": {`{"url":"example.com/hook"}`, http.StatusBadRequest, badURL}, + "another scheme": {`{"url":"ftp://example.com/"}`, http.StatusBadRequest, badURL}, + "no host": {`{"url":"https:///hook"}`, http.StatusBadRequest, badURL}, + "a port, no host": {`{"url":"http://:80/hook"}`, http.StatusBadRequest, badURL}, + "not a URL": {`{"url":"https://a b/"}`, http.StatusBadRequest, badURL}, + "over 2048 bytes": {`{"url":"` + longest + `a"}`, http.StatusBadRequest, badURL}, + "over 64 KiB": { + `{"url":"` + hookURL + "/" + strings.Repeat("a", 64<<10) + `"}`, + http.StatusRequestEntityTooLarge, `{"error":"the body is too large"}` + "\n", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rec := post(t, webhookAPI(readWebhooks(t, dir)), webhooksPath, tc.body) + + if rec.Code != tc.status || rec.Body.String() != tc.answer { + t.Errorf("response = %d %q, want %d %q", + rec.Code, rec.Body.String(), tc.status, tc.answer) + } + + noFile(t, dir) + }) + } + + register(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath, longest, + http.StatusCreated) +} + +// TestWebhooksNoSuchChat: an id that GET /api/v1/chats does not list gets +// 404 from each webhook endpoint, 1 and 2 included, and nothing is +// written. When the chats cannot be read to look the id up, the answer +// is 500. +func TestWebhooksNoSuchChat(t *testing.T) { + t.Parallel() + + for _, id := range []string{ + "1", "2", "5", "tester", "0", "-3", "3.5", "99999999999999999999", + } { + dir := t.TempDir() + + for _, rec := range eachEndpoint(t, webhookAPI(readWebhooks(t, dir)), + "/api/v1/chats/"+id+"/webhooks") { + if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat { + t.Errorf("chat %q: %d %q, want 404 %q", + id, rec.Code, rec.Body.String(), noSuchChat) + } + } + + noFile(t, dir) + } + + srv := api.New(api.Params{ + Log: slog.New(slog.DiscardHandler), + Client: &fakeClient{contactsErr: errChat}, + Token: credential, + Webhooks: readWebhooks(t, t.TempDir()), + }) + want := `{"error":"the chats could not be read"}` + "\n" + + for _, rec := range eachEndpoint(t, srv, webhooksPath) { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("chats unreadable: %d %q, want 500 %q", + rec.Code, rec.Body.String(), want) + } + } +} + +// TestWebhooksKept: a new read of the directory finds the webhooks +// registered, and none once they are removed. +func TestWebhooksKept(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + srv := webhookAPI(readWebhooks(t, dir)) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + second := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated) + + srv = webhookAPI(readWebhooks(t, dir)) + listed(t, srv, webhooksPath, first) + listed(t, srv, "/api/v1/chats/4/webhooks", second) + + for _, path := range []string{ + webhooksPath + "/" + first.ID, "/api/v1/chats/4/webhooks/" + second.ID, + } { + rec := request(t, srv, http.MethodDelete, path, bearer) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE %s: %d %q, want 204", path, rec.Code, rec.Body.String()) + } + } + + srv = webhookAPI(readWebhooks(t, dir)) + listed(t, srv, webhooksPath) + listed(t, srv, "/api/v1/chats/4/webhooks") +} + +// TestWebhooksFile: the file has mode 0600, and a change replaces it with +// a new file rather than writing into it: through a second name, the old +// file still holds what it held. No temporary file is left. +func TestWebhooksFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "webhooks.json") + srv := webhookAPI(readWebhooks(t, dir)) + register(t, srv, webhooksPath, hookURL, http.StatusCreated) + + info, err := os.Stat(file) + if err != nil || info.Mode() != 0o600 { + t.Errorf("webhooks.json: %v (%v), want mode 0600", info, err) + } + + before, err := os.ReadFile(file) //nolint:gosec // G304: the test's own file. + if err != nil { + t.Fatal(err) + } + + old := filepath.Join(t.TempDir(), "old") + + err = os.Link(file, old) + if err != nil { + t.Fatal(err) + } + + register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated) + + after, err := os.ReadFile(old) //nolint:gosec // G304: the test's own file. + if err != nil || !bytes.Equal(after, before) { + t.Errorf("the old file holds %q (%v), want %q as before", after, err, before) + } + + onlyFile(t, dir) +} + +// TestWebhooksWriteFailure: a change whose file cannot be replaced gets +// 500 and is not made, and its temporary file is removed. A directory +// stands where the file goes, which a rename cannot replace; a directory +// without write permission would not do, as tests may run as root. +func TestWebhooksWriteFailure(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "webhooks.json") + srv := webhookAPI(readWebhooks(t, dir)) + first := register(t, srv, webhooksPath, hookURL, http.StatusCreated) + + err := os.Remove(file) + if err == nil { + err = os.Mkdir(file, 0o700) + } + + if err != nil { + t.Fatal(err) + } + + want := `{"error":"the webhooks could not be saved"}` + "\n" + + for _, rec := range []*httptest.ResponseRecorder{ + post(t, srv, webhooksPath, `{"url":"`+hookURL+`/2"}`), + request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer), + } { + if rec.Code != http.StatusInternalServerError || rec.Body.String() != want { + t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want) + } + } + + listed(t, srv, webhooksPath, first) + onlyFile(t, dir) +} + +// TestReadWebhooksRefuses: a webhooks file that holds anything but +// webhooks as the bot writes them cannot be read, and the error names +// it. An absent file is no webhooks. +func TestReadWebhooksRefuses(t *testing.T) { + t.Parallel() + + record := func(id, chatID, url string) string { + return `{"webhooks":[{"id":"` + id + `","chat_id":` + chatID + + `,"url":"` + url + `"}]}` + } + + for name, contents := range map[string]string{ + "empty file": "", + "a word": "webhooks", + "cut short": `{"webhooks":[`, + "more after it": `{"webhooks":[]} {}`, + "null": "null", + "no list": "{}", + "null list": `{"webhooks":null}`, + "list alone": "[]", + "empty webhook": `{"webhooks":[{}]}`, + "id not hex": record("not-an-id", "3", hookURL), + "id too short": record("0011", "3", hookURL), + "chat 0": record(unknownID, "0", hookURL), + "url of another kind": record(unknownID, "3", "ftp://example.com/"), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "webhooks.json"), []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } + + _, err = api.ReadWebhooks(dir) + if err == nil || !strings.Contains(err.Error(), "webhooks.json") { + t.Errorf("ReadWebhooks = %v, want an error naming webhooks.json", err) + } + }) + } + + listed(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath) +} + +// TestWebhooksConcurrent: registrations at the same time, among listings +// and removals, each get their own webhook, all kept; the same URL +// registered at the same time is registered once. +func TestWebhooksConcurrent(t *testing.T) { + t.Parallel() + + const n = 20 + + dir := t.TempDir() + webhooks := readWebhooks(t, dir) + answers := make([]*httptest.ResponseRecorder, 2*n) + + var wg sync.WaitGroup + + for i := range n { + wg.Go(func() { + answers[i] = post(t, webhookAPI(webhooks), webhooksPath, + `{"url":"`+hookURL+"/"+strconv.Itoa(i)+`"}`) + }) + wg.Go(func() { + answers[n+i] = post(t, webhookAPI(webhooks), webhooksPath, + `{"url":"`+hookURL+`"}`) + }) + wg.Go(func() { + srv := webhookAPI(webhooks) + request(t, srv, http.MethodGet, webhooksPath, bearer) + request(t, srv, http.MethodDelete, webhooksPath+"/"+unknownID, bearer) + }) + } + + wg.Wait() + + created := 0 + ids := map[string]bool{} + + for _, rec := range answers { + var h hook + + _ = json.Unmarshal(rec.Body.Bytes(), &h) + ids[h.ID] = true + + if rec.Code == http.StatusCreated { + created++ + } + } + + if created != n+1 || len(ids) != n+1 { + t.Errorf("%d registered, %d ids; want %d of each", created, len(ids), n+1) + } + + rec := request(t, webhookAPI(readWebhooks(t, dir)), + http.MethodGet, webhooksPath, bearer) + + var got struct { + Webhooks []hook `json:"webhooks"` + } + + err := json.Unmarshal(rec.Body.Bytes(), &got) + if err != nil || len(got.Webhooks) != n+1 { + t.Errorf("read again: %d webhooks (%v), want %d", len(got.Webhooks), err, n+1) + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index ce7924e..263ab51 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -54,12 +54,12 @@ const ( var errExited = errors.New("simplex-chat exited") -// Run starts the chat client with its database in cfg.DataDir, serving -// its API on localhost at chatPort, connects to it, sets up the bot's -// address, then answers messages and serves the bot's API until ctx is -// cancelled — which is a clean stop and returns nil — or until the chat -// client, the connection to it or the API's listener fails, which -// returns the error. +// Run reads the webhooks kept in cfg.DataDir, starts the chat client with +// its database there, serving its API on localhost at chatPort, connects +// to it, sets up the bot's address, then answers messages and serves the +// bot's API until ctx is cancelled — which is a clean stop and returns +// nil — or until the chat client, the connection to it or the API's +// listener fails, which returns the error. func Run( ctx context.Context, log *slog.Logger, cfg *config.Config, chatPort int, ) error { @@ -68,6 +68,13 @@ func Run( return fmt.Errorf("creating data directory: %w", err) } + // Before the chat client starts, so that a file that cannot be read + // stops the bot as configuration that cannot be read does. + webhooks, err := api.ReadWebhooks(cfg.DataDir) + if err != nil { + return err + } + // Cancelling this stops the chat client; the deferred wait makes // Run return only once it has exited, whatever path Run takes. // Cancelling ctx does not reach it, so that the API, stopped first, @@ -100,11 +107,12 @@ func Run( } srv := api.New(api.Params{ - Log: log, - Client: client, - UserID: user.UserID, - Port: cfg.Port, - Token: cfg.APIToken, + Log: log, + Client: client, + UserID: user.UserID, + Port: cfg.Port, + Token: cfg.APIToken, + Webhooks: webhooks, }) served := make(chan error, 1) diff --git a/internal/bot/run_test.go b/internal/bot/run_test.go index 0c89299..76f78a8 100644 --- a/internal/bot/run_test.go +++ b/internal/bot/run_test.go @@ -182,6 +182,25 @@ func TestStopDuringRequest(t *testing.T) { } } +// TestUnreadableWebhooks: a webhooks file that cannot be read stops Run +// before it starts the chat client. +func TestUnreadableWebhooks(t *testing.T) { + // No chat client on PATH: starting one would fail with another error. + t.Setenv("PATH", t.TempDir()) + + cfg := &config.Config{DataDir: t.TempDir(), Port: freePort(t)} + + err := os.WriteFile(filepath.Join(cfg.DataDir, "webhooks.json"), []byte("{"), 0o600) + if err != nil { + t.Fatal(err) + } + + err = bot.Run(t.Context(), slog.New(slog.DiscardHandler), cfg, freePort(t)) + if err == nil || !strings.Contains(err.Error(), "webhooks.json") { + t.Errorf("Run = %v, want an error naming webhooks.json", err) + } +} + // freePort returns a TCP port that nothing listens on at the moment. func freePort(t *testing.T) int { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index 0b5eb56..b670dd3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,8 +64,9 @@ var ErrInvalidConfig = errors.New("invalid configuration") // later, so there is exactly one moment at which configuration can be // wrong, and it is before anything starts. type Config struct { - // DataDir holds the SimpleX Chat database: the bot's profile, its - // address and its contacts. Losing it loses the address. + // DataDir holds the SimpleX Chat database, with the bot's profile, + // its address and its contacts, and the API's webhooks. Losing it + // loses the address. DataDir string Debug bool