check / check (push) Successful in 1m2s
POST, GET and DELETE under /api/v1/chats/{id}/webhooks, for the chats
that GET /api/v1/chats lists. The webhooks are kept in
$DATA_DIR/webhooks.json, mode 0600, which each change replaces whole
through a temporary file in the same directory and a rename. bot.Run
reads the file before it starts the chat client: absent means none, and
a file that cannot be read aborts startup. Reading a JSON request body
moved into decodeBody, which the messages endpoint now shares. Nothing
is posted to a webhook yet.
Model: opus-5-5
241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
// Package api is the bot's HTTP API, through which another program
|
|
// 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
|
|
// refused. No path is exempt.
|
|
//
|
|
// Handlers call the chat client on the request's own goroutine. Doing
|
|
// so from the chat client's event handler would wait forever, since
|
|
// that goroutine also delivers the responses (see simplex.EventHandler).
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
|
)
|
|
|
|
const (
|
|
// maxBodyBytes caps a request body.
|
|
maxBodyBytes = 64 << 10
|
|
|
|
// requestTimeout bounds the work behind one request, which is
|
|
// mostly waiting for the chat client.
|
|
requestTimeout = 10 * time.Second
|
|
|
|
// Limits on clients that send or read slowly. writeTimeout outlasts
|
|
// requestTimeout, so a handler that ran out of time can still
|
|
// answer.
|
|
readHeaderTimeout = 5 * time.Second
|
|
readTimeout = 10 * time.Second
|
|
writeTimeout = requestTimeout + 5*time.Second
|
|
idleTimeout = 60 * time.Second
|
|
)
|
|
|
|
// ChatClient is the part of the chat client the API uses.
|
|
// *simplex.Client provides it.
|
|
type ChatClient interface {
|
|
// Contacts returns the contacts of the user userID.
|
|
Contacts(ctx context.Context, userID int64) ([]simplex.Contact, error)
|
|
|
|
// ChatItems returns the last count items of the chat with a
|
|
// contact, oldest first.
|
|
ChatItems(
|
|
ctx context.Context, contactID int64, count int,
|
|
) ([]simplex.ChatItem, error)
|
|
|
|
// SendMessage sends a text message to a contact and returns it.
|
|
SendMessage(
|
|
ctx context.Context, contactID int64, text string,
|
|
) (simplex.ChatItem, error)
|
|
}
|
|
|
|
// Params configures New.
|
|
type Params struct {
|
|
Log *slog.Logger
|
|
|
|
// Client is the chat client, and UserID the bot's user profile in
|
|
// it.
|
|
Client ChatClient
|
|
UserID int64
|
|
|
|
// Port is the TCP port to listen on, on all interfaces.
|
|
Port int
|
|
|
|
// 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
|
|
// ListenAndServe and stops it with Shutdown.
|
|
func New(p Params) *http.Server {
|
|
if p.Token == "" {
|
|
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,
|
|
webhooks: p.Webhooks,
|
|
}
|
|
|
|
router := chi.NewRouter()
|
|
router.Use(securityHeaders, h.authenticate,
|
|
middleware.RequestSize(maxBodyBytes), withTimeout)
|
|
router.NotFound(func(w http.ResponseWriter, _ *http.Request) {
|
|
h.respondError(w, http.StatusNotFound, "not found")
|
|
})
|
|
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
|
h.respondError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
})
|
|
router.Route("/api/v1", func(r chi.Router) {
|
|
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{
|
|
Addr: ":" + strconv.Itoa(p.Port),
|
|
Handler: router,
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
// Otherwise net/http answers "OPTIONS *" itself, with 200 and
|
|
// without the credential check or the headers.
|
|
DisableGeneralOptionsHandler: true,
|
|
// net/http's own messages, such as a handler's panic, go to the
|
|
// same JSON log as everything else.
|
|
ErrorLog: slog.NewLogLogger(p.Log.Handler(), slog.LevelError),
|
|
}
|
|
}
|
|
|
|
// handlers holds what the handlers share.
|
|
type handlers struct {
|
|
log *slog.Logger
|
|
client ChatClient
|
|
userID int64
|
|
token string
|
|
webhooks *Webhooks
|
|
}
|
|
|
|
// authenticate lets a request through only if it carries the
|
|
// credential. With no credential it refuses everything, and must:
|
|
// ConstantTimeCompare finds two empty strings equal, so an empty bearer
|
|
// would get in.
|
|
func (h *handlers) authenticate(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
scheme, credential, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
|
|
|
if h.token == "" || !strings.EqualFold(scheme, "Bearer") ||
|
|
subtle.ConstantTimeCompare([]byte(credential), []byte(h.token)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
h.respondError(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// respond sends v as the JSON body of a response with status.
|
|
func (h *handlers) respond(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
|
|
err := json.NewEncoder(w).Encode(v)
|
|
if err != nil {
|
|
h.log.Warn("sending a response", "error", err)
|
|
}
|
|
}
|
|
|
|
// respondError sends status with a chosen sentence. An error's own text
|
|
// never goes to the client, since it can describe the machine; it goes
|
|
// to the log.
|
|
func (h *handlers) respondError(w http.ResponseWriter, status int, sentence string) {
|
|
h.respond(w, status, struct {
|
|
Error string `json:"error"`
|
|
}{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
|
|
// over HTTPS.
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
header := w.Header()
|
|
header.Set("X-Content-Type-Options", "nosniff")
|
|
header.Set("Content-Security-Policy",
|
|
"default-src 'none'; frame-ancestors 'none'")
|
|
header.Set("X-Frame-Options", "DENY")
|
|
header.Set("Referrer-Policy", "no-referrer")
|
|
header.Set("Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()")
|
|
header.Set("Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains")
|
|
header.Set("Cache-Control", "no-store")
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// withTimeout ends each request's context after requestTimeout, so a
|
|
// handler waiting on the chat client gives up and answers.
|
|
func withTimeout(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
|
|
defer cancel()
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|