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))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/api"
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
const (
|
||||
// credential is what the API under test is configured with.
|
||||
credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests
|
||||
bearer = "Bearer " + credential
|
||||
|
||||
chatsPath = "/api/v1/chats"
|
||||
unauthorized = `{"error":"unauthorized"}` + "\n"
|
||||
)
|
||||
|
||||
var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc")
|
||||
|
||||
// fakeClient stands in for the chat client. It answers with contacts,
|
||||
// or with err, and remembers what it was asked.
|
||||
type fakeClient struct {
|
||||
contacts []simplex.Contact
|
||||
err error
|
||||
|
||||
userID int64
|
||||
hadDeadline bool
|
||||
}
|
||||
|
||||
func (f *fakeClient) Contacts(
|
||||
ctx context.Context, userID int64,
|
||||
) ([]simplex.Contact, error) {
|
||||
f.userID = userID
|
||||
_, f.hadDeadline = ctx.Deadline()
|
||||
|
||||
return f.contacts, f.err
|
||||
}
|
||||
|
||||
func newAPI(token string, client api.ChatClient) *http.Server {
|
||||
return api.New(api.Params{
|
||||
Log: slog.New(slog.DiscardHandler),
|
||||
Client: client,
|
||||
UserID: 1,
|
||||
Port: 8080,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// request sends srv one request, with the Authorization header auth
|
||||
// unless that is empty.
|
||||
func request(
|
||||
t *testing.T, srv *http.Server, method, path, auth string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCredential: only the configured credential, sent as a bearer,
|
||||
// gets in. With none configured, nothing does, an empty bearer
|
||||
// included.
|
||||
func TestCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
token, auth string
|
||||
in bool
|
||||
}{
|
||||
"right": {credential, bearer, true},
|
||||
"right, scheme in lower case": {credential, "bearer " + credential, true},
|
||||
"wrong": {credential, strings.ToUpper(bearer), false},
|
||||
"missing": {credential, "", false},
|
||||
"another scheme": {credential, "Basic " + credential, false},
|
||||
"no scheme": {credential, credential, false},
|
||||
"empty bearer": {credential, "Bearer ", false},
|
||||
"none configured": {"", bearer, false},
|
||||
"none configured, empty bearer": {"", "Bearer ", false},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := request(t, newAPI(tc.token, &fakeClient{}),
|
||||
http.MethodGet, chatsPath, tc.auth)
|
||||
|
||||
if tc.in {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" {
|
||||
t.Errorf("WWW-Authenticate = %q, want Bearer", got)
|
||||
}
|
||||
|
||||
if rec.Body.String() != unauthorized {
|
||||
t.Errorf("body = %q, want %q", rec.Body.String(), unauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoCredentialWarns: an API without a credential says at startup
|
||||
// that it refuses every request.
|
||||
func TestNoCredentialWarns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logged bytes.Buffer
|
||||
|
||||
api.New(api.Params{
|
||||
Log: slog.New(slog.NewJSONHandler(&logged, nil)),
|
||||
Client: &fakeClient{},
|
||||
Port: 8080,
|
||||
})
|
||||
|
||||
if !strings.Contains(logged.String(), `"level":"WARN"`) ||
|
||||
!strings.Contains(logged.String(), "API_TOKEN_FILE is not set") {
|
||||
t.Errorf("log = %q, want a warning that API_TOKEN_FILE is not set",
|
||||
logged.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPathIsExempt: an unknown path or method needs the credential
|
||||
// like everything else, and then gets a JSON error.
|
||||
func TestNoPathIsExempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newAPI(credential, &fakeClient{})
|
||||
|
||||
for _, tc := range []struct {
|
||||
method, path, auth string
|
||||
want int
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/", "", http.StatusUnauthorized, unauthorized},
|
||||
{
|
||||
http.MethodGet, "/.well-known/healthcheck", "",
|
||||
http.StatusUnauthorized, unauthorized,
|
||||
},
|
||||
{
|
||||
http.MethodGet, "/api/v1/nothing", bearer,
|
||||
http.StatusNotFound, `{"error":"not found"}` + "\n",
|
||||
},
|
||||
{
|
||||
http.MethodPost, chatsPath, bearer,
|
||||
http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n",
|
||||
},
|
||||
} {
|
||||
rec := request(t, srv, tc.method, tc.path, tc.auth)
|
||||
if rec.Code != tc.want || rec.Body.String() != tc.body {
|
||||
t.Errorf("%s %s: %d %q, want %d %q", tc.method, tc.path,
|
||||
rec.Code, rec.Body.String(), tc.want, tc.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaders: every response, whatever its status, carries the
|
||||
// security headers, and none lets another origin in.
|
||||
func TestHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
client *fakeClient
|
||||
path, auth string
|
||||
}{
|
||||
{&fakeClient{}, chatsPath, bearer},
|
||||
{&fakeClient{}, chatsPath, ""},
|
||||
{&fakeClient{}, "/nothing", bearer},
|
||||
{&fakeClient{err: errChat}, chatsPath, bearer},
|
||||
} {
|
||||
rec := request(t, newAPI(credential, tc.client),
|
||||
http.MethodGet, tc.path, tc.auth)
|
||||
|
||||
for key, value := range want {
|
||||
if got := rec.Header().Get(key); got != value {
|
||||
t.Errorf("%d response: %s = %q, want %q", rec.Code, key, got, value)
|
||||
}
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%d response: Access-Control-Allow-Origin = %q", rec.Code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// handleChats lists the bot's chats, ordered by id. The bot talks to
|
||||
// people only one to one, so its chats are its contacts, and a chat's
|
||||
// id is its contact's.
|
||||
func (h *handlers) handleChats() http.HandlerFunc {
|
||||
type chat struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ContactDeleted bool `json:"contact_deleted"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Chats []chat `json:"chats"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
contacts, err := h.client.Contacts(r.Context(), h.userID)
|
||||
if err != nil {
|
||||
h.log.Error("listing the chats", "error", err)
|
||||
h.respondError(w, http.StatusInternalServerError,
|
||||
"the chats could not be read")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
chats := make([]chat, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
chats = append(chats, chat{
|
||||
ID: c.ContactID,
|
||||
DisplayName: c.Profile.DisplayName,
|
||||
ContactDeleted: c.Deleted(),
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortFunc(chats, func(a, b chat) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
h.respond(w, http.StatusOK, response{Chats: chats})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
// TestChats: the chats are the bot's contacts, ordered by id, deleted
|
||||
// ones marked, and the chat client is asked for the bot's user with a
|
||||
// deadline.
|
||||
func TestChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{contacts: []simplex.Contact{
|
||||
{ContactID: 3, Profile: simplex.Profile{DisplayName: "bob"}, Status: "deleted"},
|
||||
{ContactID: 2, Profile: simplex.Profile{DisplayName: "alice"}, Status: "active"},
|
||||
}}
|
||||
|
||||
rec := request(t, newAPI(credential, client), http.MethodGet, chatsPath, bearer)
|
||||
|
||||
want := `{"chats":[{"id":2,"display_name":"alice","contact_deleted":false},` +
|
||||
`{"id":3,"display_name":"bob","contact_deleted":true}]}` + "\n"
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", got)
|
||||
}
|
||||
|
||||
if client.userID != 1 || !client.hadDeadline {
|
||||
t.Errorf("the chat client was asked for user %d, deadline %v; "+
|
||||
"want user 1 with a deadline", client.userID, client.hadDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoChats: no contacts is an empty list, not null.
|
||||
func TestNoChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := request(t, newAPI(credential, &fakeClient{}),
|
||||
http.MethodGet, chatsPath, bearer)
|
||||
|
||||
want := `{"chats":[]}` + "\n"
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("response = %d %q, want 200 %q", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatsFailure: when the chat client fails, the response says so
|
||||
// in a chosen sentence, never in the error's own text.
|
||||
func TestChatsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := request(t, newAPI(credential, &fakeClient{err: errChat}),
|
||||
http.MethodGet, chatsPath, bearer)
|
||||
|
||||
want := `{"error":"the chats could not be read"}` + "\n"
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
|
||||
t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user