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