// 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. Deliveries posts to those webhooks each message a contact // sends. // // 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). // For the same reason, the event handler hands messages to Deliveries // without waiting. 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)) }) }