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)
|
||||
}
|
||||
}
|
||||
+63
-15
@@ -8,12 +8,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/api"
|
||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
@@ -42,17 +45,22 @@ const (
|
||||
// retryInterval paces the connection attempts.
|
||||
retryInterval = 250 * time.Millisecond
|
||||
|
||||
// apiStopTimeout is how long API requests in progress get to finish
|
||||
// when the bot stops, before their connections are closed.
|
||||
apiStopTimeout = 5 * time.Second
|
||||
|
||||
dataDirMode = 0o700
|
||||
)
|
||||
|
||||
var errExited = errors.New("simplex-chat exited")
|
||||
|
||||
// Run starts the chat client with its database in dataDir, connects to
|
||||
// it, sets up the bot's address, and answers messages until ctx is
|
||||
// cancelled — which is a clean stop and returns nil — or until the chat
|
||||
// client or the connection to it fails, which returns the error.
|
||||
func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
||||
err := os.MkdirAll(dataDir, dataDirMode)
|
||||
// Run starts the chat client with its database in cfg.DataDir, connects
|
||||
// to it, sets up the bot's address, then answers messages and serves the
|
||||
// API until ctx is cancelled — which is a clean stop and returns nil —
|
||||
// or until the chat client, the connection to it or the API's listener
|
||||
// fails, which returns the error.
|
||||
func Run(ctx context.Context, log *slog.Logger, cfg *config.Config) error {
|
||||
err := os.MkdirAll(cfg.DataDir, dataDirMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
@@ -61,7 +69,7 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
||||
// Run return only once it has exited, whatever path Run takes.
|
||||
cliCtx, stopCLI := context.WithCancel(ctx)
|
||||
|
||||
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(dataDir, "simplex"),
|
||||
cli, err := simplex.StartCLI(cliCtx, log, filepath.Join(cfg.DataDir, "simplex"),
|
||||
DisplayName, chatPort)
|
||||
if err != nil {
|
||||
stopCLI()
|
||||
@@ -81,11 +89,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
err = setUp(ctx, log, client)
|
||||
user, err := setUp(ctx, log, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := api.New(api.Params{
|
||||
Log: log,
|
||||
Client: client,
|
||||
UserID: user.UserID,
|
||||
Port: cfg.Port,
|
||||
Token: cfg.APIToken,
|
||||
})
|
||||
|
||||
served := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
served <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// Deferred last, so it runs first: requests in progress finish while
|
||||
// the chat client is still there to answer them.
|
||||
defer stopAPI(ctx, log, srv)
|
||||
|
||||
log.Info("starting the API", "port", cfg.Port)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
@@ -93,6 +121,23 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
||||
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
|
||||
case <-cli.Done():
|
||||
return fmt.Errorf("%w: %w", errExited, cli.Err())
|
||||
case err := <-served:
|
||||
return fmt.Errorf("serving the API: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stopAPI stops the API server, giving requests in progress up to
|
||||
// apiStopTimeout to finish before closing their connections.
|
||||
func stopAPI(ctx context.Context, log *slog.Logger, srv *http.Server) {
|
||||
// ctx is usually cancelled by now: that is why the bot is stopping.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), apiStopTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := srv.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Warn("stopping the API", "error", err)
|
||||
|
||||
_ = srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,19 +170,22 @@ func connect(
|
||||
// setUp gives the bot a long-term contact address, creating it on the
|
||||
// first start, and sets it to accept every contact request and to greet
|
||||
// each new contact. The settings are written on every start, so an
|
||||
// address whose settings were changed by hand is put right.
|
||||
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error {
|
||||
// address whose settings were changed by hand is put right. It returns
|
||||
// the bot's user profile.
|
||||
func setUp(
|
||||
ctx context.Context, log *slog.Logger, client *simplex.Client,
|
||||
) (simplex.User, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
||||
defer cancel()
|
||||
|
||||
user, err := client.ActiveUser(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the bot's profile: %w", err)
|
||||
return user, fmt.Errorf("reading the bot's profile: %w", err)
|
||||
}
|
||||
|
||||
link, ok, err := client.Address(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the bot's address: %w", err)
|
||||
return user, fmt.Errorf("reading the bot's address: %w", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
@@ -145,7 +193,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
||||
|
||||
link, err = client.CreateAddress(ctx, user.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating the bot's address: %w", err)
|
||||
return user, fmt.Errorf("creating the bot's address: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +202,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
||||
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting the bot's address to accept everyone: %w", err)
|
||||
return user, fmt.Errorf("setting the bot's address to accept everyone: %w", err)
|
||||
}
|
||||
|
||||
log.Info("ready",
|
||||
@@ -163,7 +211,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
||||
"full_address", link.FullLink,
|
||||
)
|
||||
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// handle answers each text message a contact sends.
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ func run(ctx context.Context, version string) error {
|
||||
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
err = bot.Run(ctx, log, cfg.DataDir)
|
||||
err = bot.Run(ctx, log, cfg)
|
||||
if err != nil {
|
||||
log.Error("stopped", "error", err)
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
@@ -34,12 +36,23 @@ import (
|
||||
// Environment variable names. Bare names, no prefix: this matches the
|
||||
// other services and keeps a compose file readable.
|
||||
const (
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDebug = "DEBUG"
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDebug = "DEBUG"
|
||||
EnvPort = "PORT"
|
||||
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
|
||||
)
|
||||
|
||||
// DefaultDataDir applies when DATA_DIR is absent.
|
||||
const DefaultDataDir = "./data"
|
||||
// Defaults, for the variables that are absent.
|
||||
const (
|
||||
DefaultDataDir = "./data"
|
||||
DefaultPort = 8080
|
||||
)
|
||||
|
||||
// MinAPITokenLength is the fewest characters the API credential may
|
||||
// have, not counting whitespace around it.
|
||||
const MinAPITokenLength = 32
|
||||
|
||||
const maxPort = 65535
|
||||
|
||||
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||
// so callers can distinguish "the operator got it wrong" from "the
|
||||
@@ -55,6 +68,14 @@ type Config struct {
|
||||
// address and its contacts. Losing it loses the address.
|
||||
DataDir string
|
||||
Debug bool
|
||||
|
||||
// Port is the API's TCP port.
|
||||
Port int
|
||||
|
||||
// APIToken is the credential every API request must carry, read
|
||||
// from the file named by API_TOKEN_FILE. Empty when that is absent,
|
||||
// and then the API refuses every request. Never log it.
|
||||
APIToken string
|
||||
}
|
||||
|
||||
// loader parses one environment into a Config, accumulating every
|
||||
@@ -109,6 +130,53 @@ func (l *loader) boolean(key string, def bool) bool {
|
||||
return b
|
||||
}
|
||||
|
||||
// port accepts a whole number from 1 to 65535 and refuses everything
|
||||
// else.
|
||||
func (l *loader) port(key string, def int) int {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > maxPort {
|
||||
l.fail(key, s, "not a port (use a whole number from 1 to 65535)")
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// tokenFile returns the credential held in the file named by key, with
|
||||
// the whitespace around it trimmed, or "" when key is absent. A file
|
||||
// that cannot be read, or holds fewer than MinAPITokenLength
|
||||
// characters, is a failure; the message names the file, never what it
|
||||
// holds.
|
||||
func (l *loader) tokenFile(key string) string {
|
||||
path, ok := l.raw(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file.
|
||||
if err != nil {
|
||||
l.fail(key, path, "unreadable: "+err.Error())
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(string(b))
|
||||
if utf8.RuneCountInString(token) < MinAPITokenLength {
|
||||
l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters",
|
||||
MinAPITokenLength))
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// New parses and validates the environment. An error here aborts
|
||||
// startup before the chat client is launched, so there is no partially
|
||||
// configured running state to reason about.
|
||||
@@ -125,8 +193,10 @@ func load(v *viper.Viper) (*Config, error) {
|
||||
l := &loader{v: v}
|
||||
|
||||
c := &Config{
|
||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||
Debug: l.boolean(EnvDebug, false),
|
||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||
Debug: l.boolean(EnvDebug, false),
|
||||
Port: l.port(EnvPort, DefaultPort),
|
||||
APIToken: l.tokenFile(EnvAPITokenFile),
|
||||
}
|
||||
|
||||
if len(l.errs) > 0 {
|
||||
|
||||
@@ -2,6 +2,9 @@ package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, kv := range map[string]map[string]string{
|
||||
"unset": nil,
|
||||
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "},
|
||||
"unset": nil,
|
||||
"whitespace only": {
|
||||
config.EnvDataDir: " ", config.EnvDebug: " ",
|
||||
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
||||
if c.Debug {
|
||||
t.Error("Debug must default off")
|
||||
}
|
||||
|
||||
if c.Port != config.DefaultPort {
|
||||
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
|
||||
}
|
||||
|
||||
if c.APIToken != "" {
|
||||
t.Error("APIToken must default to none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -94,3 +108,79 @@ func TestValidValuesAreUsed(t *testing.T) {
|
||||
t.Error("Debug = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPort: PORT is a whole number from 1 to 65535, and anything else
|
||||
// aborts.
|
||||
func TestPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for raw, want := range map[string]int{"1": 1, "8081": 8081, "65535": 65535} {
|
||||
c, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
||||
if err != nil {
|
||||
t.Errorf("PORT=%q was rejected: %v", raw, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if c.Port != want {
|
||||
t.Errorf("PORT=%q: Port = %d, want %d", raw, c.Port, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{"0", "65536", "-1", "80.5", "8080x", "http"} {
|
||||
_, err := config.Load(env(map[string]string{config.EnvPort: raw}))
|
||||
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||
t.Errorf("PORT=%q: error = %v, want ErrInvalidConfig", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPITokenFile: the credential is the file's content without the
|
||||
// whitespace around it. A file that cannot be read, or holds too short
|
||||
// a credential, aborts, and the error never shows what the file holds.
|
||||
func TestAPITokenFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
token := strings.Repeat("k", config.MinAPITokenLength)
|
||||
short := strings.Repeat("s", config.MinAPITokenLength-1)
|
||||
|
||||
for name, content := range map[string]string{
|
||||
"good": " " + token + "\n",
|
||||
"short": "\n" + short + " \n",
|
||||
} {
|
||||
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
load := func(name string) (*config.Config, error) {
|
||||
return config.Load(env(map[string]string{
|
||||
config.EnvAPITokenFile: filepath.Join(dir, name),
|
||||
}))
|
||||
}
|
||||
|
||||
c, err := load("good")
|
||||
if err != nil {
|
||||
t.Fatalf("a good file was rejected: %v", err)
|
||||
}
|
||||
|
||||
if c.APIToken != token {
|
||||
t.Errorf("APIToken = %q, want %q", c.APIToken, token)
|
||||
}
|
||||
|
||||
_, err = load("missing")
|
||||
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||
t.Errorf("a missing file: error = %v, want ErrInvalidConfig", err)
|
||||
}
|
||||
|
||||
_, err = load("short")
|
||||
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||
t.Fatalf("a short credential: error = %v, want ErrInvalidConfig", err)
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), short) {
|
||||
t.Errorf("the error shows the file's content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,18 @@ func (c *Client) SetAddressSettings(
|
||||
return c.command(ctx, cmd, TypeUserContactLinkUpdated, nil)
|
||||
}
|
||||
|
||||
// Contacts returns the user's contacts: everyone it has a direct chat
|
||||
// with.
|
||||
func (c *Client) Contacts(ctx context.Context, userID int64) ([]Contact, error) {
|
||||
var r struct {
|
||||
Contacts []Contact `json:"contacts"`
|
||||
}
|
||||
|
||||
err := c.command(ctx, cmdListContacts(userID), TypeContactsList, &r)
|
||||
|
||||
return r.Contacts, err
|
||||
}
|
||||
|
||||
// SendText sends a text message to a contact, as a reply to the message
|
||||
// quotedItemID (0 for none). It does not wait for the chat client to
|
||||
// accept it; a failure is logged when the client's answer arrives.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -43,6 +44,19 @@ const (
|
||||
|
||||
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
||||
"contact":{"contactId":3,"localDisplayName":"alice"}}`
|
||||
|
||||
// Two contacts with the same display name, which the chat client
|
||||
// tells apart by the local name it gives the second. The second has
|
||||
// deleted its chat with the bot, and is still listed.
|
||||
contactsList = `{"type":"contactsList","user":{"userId":1},"contacts":[
|
||||
{"contactId":3,"localDisplayName":"tester","profile":{"profileId":3,
|
||||
"displayName":"tester","fullName":"","localAlias":""},
|
||||
"activeConn":{"connId":2,"connStatus":{"type":"ready"}},
|
||||
"contactUsed":true,"contactStatus":"active","chatDeleted":false},
|
||||
{"contactId":4,"localDisplayName":"tester_1","profile":{"profileId":4,
|
||||
"displayName":"tester","fullName":"","localAlias":""},
|
||||
"activeConn":{"connId":3,"connStatus":{"type":"deleted"}},
|
||||
"contactUsed":true,"contactStatus":"deleted","chatDeleted":false}]}`
|
||||
)
|
||||
|
||||
// fakeChat stands in for the chat client's API. It answers each command
|
||||
@@ -220,6 +234,38 @@ func TestAddressSetup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestContacts: the contacts of the given user come back with their
|
||||
// ids, display names and whether they are deleted.
|
||||
func TestContacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, url := newFakeChat(t, map[string]string{"/_contacts": contactsList})
|
||||
c, ctx := dial(t, url, nil)
|
||||
|
||||
contacts, err := c.Contacts(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Contacts: %v", err)
|
||||
}
|
||||
|
||||
if got := f.next(t); got != "/_contacts 1" {
|
||||
t.Errorf("command = %s, want /_contacts 1", got)
|
||||
}
|
||||
|
||||
tester := simplex.Profile{DisplayName: "tester"}
|
||||
|
||||
want := []simplex.Contact{
|
||||
{ContactID: 3, Profile: tester, Status: "active"},
|
||||
{ContactID: 4, Profile: tester, Status: "deleted"},
|
||||
}
|
||||
if !slices.Equal(contacts, want) {
|
||||
t.Fatalf("Contacts = %+v\nwant %+v", contacts, want)
|
||||
}
|
||||
|
||||
if contacts[0].Deleted() || !contacts[1].Deleted() {
|
||||
t.Error("Deleted must be false for contact 3 and true for contact 4")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedCommand: a command the chat client refuses is an error
|
||||
// that names the reason.
|
||||
func TestRefusedCommand(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
TypeUserContactLink = "userContactLink"
|
||||
TypeUserContactLinkCreated = "userContactLinkCreated"
|
||||
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
||||
TypeContactsList = "contactsList"
|
||||
TypeNewChatItems = "newChatItems"
|
||||
TypeContactConnected = "contactConnected"
|
||||
TypeChatCmdError = "chatCmdError"
|
||||
@@ -46,10 +47,14 @@ func (e Event) Decode(v any) error {
|
||||
type (
|
||||
// User is the chat client's local user profile: the bot itself.
|
||||
User struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Profile struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"profile"`
|
||||
UserID int64 `json:"userId"`
|
||||
Profile Profile `json:"profile"`
|
||||
}
|
||||
|
||||
// Profile is how the bot or a contact presents itself. Nothing
|
||||
// makes a display name unique.
|
||||
Profile struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// ConnLink is a SimpleX link. The short form is what people share;
|
||||
@@ -61,7 +66,9 @@ type (
|
||||
|
||||
// Contact is a person connected to the bot.
|
||||
Contact struct {
|
||||
ContactID int64 `json:"contactId"`
|
||||
ContactID int64 `json:"contactId"`
|
||||
Profile Profile `json:"profile"`
|
||||
Status string `json:"contactStatus"`
|
||||
}
|
||||
|
||||
// NewChatItems is the record of a newChatItems event: messages
|
||||
@@ -142,6 +149,13 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// Deleted reports whether the contact is gone, as it is once the person
|
||||
// deletes their chat with the bot. The chat client still lists such a
|
||||
// contact, with its chat, but nothing more reaches them.
|
||||
func (c Contact) Deleted() bool {
|
||||
return c.Status != "active"
|
||||
}
|
||||
|
||||
// Message is a text message a contact sent to the bot.
|
||||
type Message struct {
|
||||
ContactID int64
|
||||
@@ -190,6 +204,10 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
|
||||
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
||||
}
|
||||
|
||||
func cmdListContacts(userID int64) string {
|
||||
return "/_contacts " + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
||||
b, err := json.Marshal([]composedMessage{{
|
||||
QuotedItemID: quotedItemID,
|
||||
|
||||
Reference in New Issue
Block a user