HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m1s
check / check (push) Successful in 1m1s
The bot now serves an HTTP API on PORT (default 8080) beside the chat client. Every request needs the credential read at startup from the file named by API_TOKEN_FILE, sent as a bearer token; without one, every request is refused. Responses carry the security headers, bodies are capped at 64 KiB and each request's work at 10 seconds. GET /api/v1/chats lists the bot's contacts from the chat client's /_contacts command, ordered by id, and marks the contacts who deleted their chat with the bot, which the chat client keeps listing. bot.Run starts the API after set-up and stops it within 5 seconds; a listener failure ends the bot as a chat client failure does. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// Package api is the bot's HTTP API, through which another program
|
||||
// reads the bot's chats.
|
||||
//
|
||||
// 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"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
return &http.Server{
|
||||
Addr: ":" + strconv.Itoa(p.Port),
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// securityHeaders go on every response. The API returns JSON to
|
||||
// programs, so a browser may not frame, sniff, cache or refer from it,
|
||||
// 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("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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user