HTTP API: register, list and remove webhooks, kept across restarts (closes #6)
check / check (push) Successful in 1m19s
check / check (push) Successful in 1m19s
An external app can register webhooks on a chat (`POST /api/v1/chats/{id}/webhooks` with a URL), list them, and remove one. Registering the same URL again returns the existing registration. Registrations are kept in `$DATA_DIR/webhooks.json`, rewritten whole on each change through a file created 0600, synced and renamed, so a crash leaves the old file or the new one; a file present but unreadable stops startup. Delivery of incoming messages is the next unit.
Disclosures: the JSON body reading is now one helper shared with the send endpoint; gosec G304 is suppressed on reading the webhooks file; the 0600-from-creation claim rests on `os.CreateTemp`'s source, not a system-call trace.
Model: opus-5-5
This commit was merged in pull request #17.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user