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:
+47
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user