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:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Agent Guidance
|
title: Agent Guidance
|
||||||
last_modified: 2026-09-28
|
last_modified: 2026-09-29
|
||||||
---
|
---
|
||||||
|
|
||||||
This file is the single source of guidance for any automated agent
|
This file is the single source of guidance for any automated agent
|
||||||
@@ -119,6 +119,7 @@ Do not weaken them.
|
|||||||
|
|
||||||
```
|
```
|
||||||
cmd/simplexcalc/ main(), a single call into internal/cli
|
cmd/simplexcalc/ main(), a single call into internal/cli
|
||||||
|
internal/api/ the HTTP API: its credential, headers and endpoints
|
||||||
internal/bot/ startup, address setup, and the reply to a message
|
internal/bot/ startup, address setup, and the reply to a message
|
||||||
internal/calc/ the arithmetic: go/parser and go/constant
|
internal/calc/ the arithmetic: go/parser and go/constant
|
||||||
internal/cli/ cobra command tree: run and version
|
internal/cli/ cobra command tree: run and version
|
||||||
|
|||||||
@@ -119,4 +119,8 @@ USER simplexcalc
|
|||||||
|
|
||||||
ENV DATA_DIR=/var/lib/simplexcalc
|
ENV DATA_DIR=/var/lib/simplexcalc
|
||||||
|
|
||||||
|
# The API, on its default PORT. The chat client's WebSocket on 5225 is
|
||||||
|
# not exposed: it has no authentication.
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["/app/simplexcalc", "run"]
|
CMD ["/app/simplexcalc", "run"]
|
||||||
|
|||||||
@@ -11,18 +11,28 @@ else gets a short explanation instead of a result.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Build the image and run the bot, with its SimpleX profile on a named
|
Build the image, write a random API credential onto a named volume, and
|
||||||
volume:
|
run the bot with its SimpleX profile on the same volume:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone git@git.eeqj.de:clawbot/simplexcalc.git
|
git clone git@git.eeqj.de:clawbot/simplexcalc.git
|
||||||
cd simplexcalc
|
cd simplexcalc
|
||||||
make docker
|
make docker
|
||||||
|
docker run --rm -v simplexcalc-data:/var/lib/simplexcalc simplexcalc \
|
||||||
|
sh -c 'umask 077 && od -An -N32 -tx1 /dev/urandom | tr -d " \n" \
|
||||||
|
>/var/lib/simplexcalc/api-token'
|
||||||
docker run -d --name simplexcalc --restart unless-stopped \
|
docker run -d --name simplexcalc --restart unless-stopped \
|
||||||
-v simplexcalc-data:/var/lib/simplexcalc simplexcalc
|
-v simplexcalc-data:/var/lib/simplexcalc \
|
||||||
|
-p 127.0.0.1:8080:8080 -e API_TOKEN_FILE=/var/lib/simplexcalc/api-token \
|
||||||
|
simplexcalc
|
||||||
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
|
docker logs simplexcalc 2>&1 | grep '"msg":"ready"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The credential is 32 random bytes written as 64 hexadecimal characters
|
||||||
|
to `/var/lib/simplexcalc/api-token`, readable only by the bot's user.
|
||||||
|
`-p 127.0.0.1:8080:8080` makes the API reachable from this host only;
|
||||||
|
see [API](#api).
|
||||||
|
|
||||||
The `ready` log line carries the bot's contact address: `address` is the
|
The `ready` log line carries the bot's contact address: `address` is the
|
||||||
short link to share, and `full_address` is the same address in the long
|
short link to share, and `full_address` is the same address in the long
|
||||||
form that older SimpleX clients need. Open the link in any SimpleX Chat
|
form that older SimpleX clients need. Open the link in any SimpleX Chat
|
||||||
@@ -67,6 +77,60 @@ variables that are absent.
|
|||||||
`/var/lib/simplexcalc`.
|
`/var/lib/simplexcalc`.
|
||||||
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
|
- `DEBUG` — `true` or `false`, default `false`. `true` logs every event
|
||||||
the chat client sends.
|
the chat client sends.
|
||||||
|
- `PORT` — the TCP port the API listens on, on all interfaces: a whole
|
||||||
|
number from 1 to 65535, default `8080`.
|
||||||
|
- `API_TOKEN_FILE` — path of a file holding the API credential, at least
|
||||||
|
32 characters not counting the whitespace around them. The file is
|
||||||
|
read once, at startup; one that cannot be read, or holds a shorter
|
||||||
|
credential, aborts startup. Absent, the API still listens but refuses
|
||||||
|
every request, and startup logs a warning saying so.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
An HTTP API beside the chat client lets another program read the bot's
|
||||||
|
chats. It speaks JSON on `PORT`.
|
||||||
|
|
||||||
|
**Authentication.** Every request carries the credential from
|
||||||
|
`API_TOKEN_FILE`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer {credential}
|
||||||
|
```
|
||||||
|
|
||||||
|
A request without it, or with a wrong one, gets `401` with
|
||||||
|
`WWW-Authenticate: Bearer` and `{"error":"unauthorized"}`. No path is
|
||||||
|
exempt. Without `API_TOKEN_FILE`, every request gets that answer. The
|
||||||
|
file is read at startup, so a new credential takes a restart.
|
||||||
|
|
||||||
|
**Errors** are JSON with a short explanation, such as
|
||||||
|
`{"error":"not found"}`; what went wrong inside goes to the bot's log.
|
||||||
|
|
||||||
|
### `GET /api/v1/chats`
|
||||||
|
|
||||||
|
The bot's chats, ordered by `id`. The bot talks to people only one to
|
||||||
|
one, so each chat is one of its contacts.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKEN=$(docker exec simplexcalc cat /var/lib/simplexcalc/api-token)
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/v1/chats
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chats": [
|
||||||
|
{ "id": 3, "display_name": "tester", "contact_deleted": false },
|
||||||
|
{ "id": 4, "display_name": "tester", "contact_deleted": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` — the chat's number, which is its contact's number in the chat
|
||||||
|
client.
|
||||||
|
- `display_name` — the name the contact gave themselves. Nothing makes
|
||||||
|
it unique.
|
||||||
|
- `contact_deleted` — `true` once the contact has deleted their chat
|
||||||
|
with the bot. The chat stays in the list, but nothing more reaches
|
||||||
|
them.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
@@ -142,8 +206,8 @@ container.
|
|||||||
- **One process tree, one container.** `simplexcalc run` starts the
|
- **One process tree, one container.** `simplexcalc run` starts the
|
||||||
SimpleX Chat command-line client, `simplex-chat`, as a child process,
|
SimpleX Chat command-line client, `simplex-chat`, as a child process,
|
||||||
with its database under `$DATA_DIR/simplex` and its WebSocket API on
|
with its database under `$DATA_DIR/simplex` and its WebSocket API on
|
||||||
`127.0.0.1:5225`. The API has no authentication, which is why it is
|
`127.0.0.1:5225`. That WebSocket has no authentication, which is why
|
||||||
never exposed outside the container.
|
it is never exposed outside the container.
|
||||||
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
|
- **The protocol** (`internal/simplex`) is JSON over that WebSocket: a
|
||||||
command carries a correlation id, its response carries the same id,
|
command carries a correlation id, its response carries the same id,
|
||||||
and anything without one is an event. Only the fields the bot reads
|
and anything without one is an event. Only the fields the bot reads
|
||||||
@@ -154,6 +218,16 @@ container.
|
|||||||
accept every contact request and to greet each new contact. The first
|
accept every contact request and to greet each new contact. The first
|
||||||
start creates the profile itself, a bot profile named `calc`. The
|
start creates the profile itself, a bot profile named `calc`. The
|
||||||
address is logged in the `ready` line.
|
address is logged in the `ready` line.
|
||||||
|
- **The API** (`internal/api`): an HTTP server, routed with chi, that
|
||||||
|
starts once set-up is done; if it cannot listen, the bot exits with an
|
||||||
|
error, as when the chat client fails. Every request must carry the
|
||||||
|
credential, compared in constant time; every response carries headers
|
||||||
|
that forbid framing, content sniffing, caching and referrers; a
|
||||||
|
request body is capped at 64 KiB and a request's work at 10 seconds.
|
||||||
|
Handlers call the chat client on the request's own goroutine, never on
|
||||||
|
the one that delivers events, which also delivers the chat client's
|
||||||
|
answers. When the bot stops, requests in progress get 5 seconds to
|
||||||
|
finish.
|
||||||
- **Replies**: for each text message a contact sends in a direct chat,
|
- **Replies**: for each text message a contact sends in a direct chat,
|
||||||
the bot sends back the result, as a reply quoting the message. Group
|
the bot sends back the result, as a reply quoting the message. Group
|
||||||
messages, files and the bot's own messages are ignored.
|
messages, files and the bot's own messages are ignored.
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ with no deprecation warning.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`,
|
||||||
|
the bearer credential from `API_TOKEN_FILE`, security headers and
|
||||||
|
limits, and `GET /api/v1/chats`
|
||||||
- 2026-09-28 Moved the command tree and the `run` and `version` commands
|
- 2026-09-28 Moved the command tree and the `run` and `version` commands
|
||||||
from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go`
|
from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go`
|
||||||
is now a single call to `cli.Main`
|
is now a single call to `cli.Main`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module sneak.berlin/go/simplexcalc
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
|||||||
@@ -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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/api"
|
||||||
"sneak.berlin/go/simplexcalc/internal/calc"
|
"sneak.berlin/go/simplexcalc/internal/calc"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,17 +45,22 @@ const (
|
|||||||
// retryInterval paces the connection attempts.
|
// retryInterval paces the connection attempts.
|
||||||
retryInterval = 250 * time.Millisecond
|
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
|
dataDirMode = 0o700
|
||||||
)
|
)
|
||||||
|
|
||||||
var errExited = errors.New("simplex-chat exited")
|
var errExited = errors.New("simplex-chat exited")
|
||||||
|
|
||||||
// Run starts the chat client with its database in dataDir, connects to
|
// Run starts the chat client with its database in cfg.DataDir, connects
|
||||||
// it, sets up the bot's address, and answers messages until ctx is
|
// to it, sets up the bot's address, then answers messages and serves the
|
||||||
// cancelled — which is a clean stop and returns nil — or until the chat
|
// API until ctx is cancelled — which is a clean stop and returns nil —
|
||||||
// client or the connection to it fails, which returns the error.
|
// or until the chat client, the connection to it or the API's listener
|
||||||
func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
// fails, which returns the error.
|
||||||
err := os.MkdirAll(dataDir, dataDirMode)
|
func Run(ctx context.Context, log *slog.Logger, cfg *config.Config) error {
|
||||||
|
err := os.MkdirAll(cfg.DataDir, dataDirMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating data directory: %w", err)
|
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.
|
// Run return only once it has exited, whatever path Run takes.
|
||||||
cliCtx, stopCLI := context.WithCancel(ctx)
|
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)
|
DisplayName, chatPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stopCLI()
|
stopCLI()
|
||||||
@@ -81,11 +89,31 @@ func Run(ctx context.Context, log *slog.Logger, dataDir string) error {
|
|||||||
|
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
err = setUp(ctx, log, client)
|
user, err := setUp(ctx, log, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
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())
|
return fmt.Errorf("%w: %w", simplex.ErrClosed, client.Err())
|
||||||
case <-cli.Done():
|
case <-cli.Done():
|
||||||
return fmt.Errorf("%w: %w", errExited, cli.Err())
|
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
|
// 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
|
// 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
|
// each new contact. The settings are written on every start, so an
|
||||||
// address whose settings were changed by hand is put right.
|
// address whose settings were changed by hand is put right. It returns
|
||||||
func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error {
|
// 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)
|
ctx, cancel := context.WithTimeout(ctx, setupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
user, err := client.ActiveUser(ctx)
|
user, err := client.ActiveUser(ctx)
|
||||||
if err != nil {
|
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)
|
link, ok, err := client.Address(ctx, user.UserID)
|
||||||
if err != nil {
|
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 {
|
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)
|
link, err = client.CreateAddress(ctx, user.UserID)
|
||||||
if err != nil {
|
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},
|
AutoReply: &simplex.MsgContent{Type: "text", Text: Welcome},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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",
|
log.Info("ready",
|
||||||
@@ -163,7 +211,7 @@ func setUp(ctx context.Context, log *slog.Logger, client *simplex.Client) error
|
|||||||
"full_address", link.FullLink,
|
"full_address", link.FullLink,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle answers each text message a contact sends.
|
// 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)
|
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
err = bot.Run(ctx, log, cfg.DataDir)
|
err = bot.Run(ctx, log, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("stopped", "error", err)
|
log.Error("stopped", "error", err)
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
@@ -34,12 +36,23 @@ import (
|
|||||||
// Environment variable names. Bare names, no prefix: this matches the
|
// Environment variable names. Bare names, no prefix: this matches the
|
||||||
// other services and keeps a compose file readable.
|
// other services and keeps a compose file readable.
|
||||||
const (
|
const (
|
||||||
EnvDataDir = "DATA_DIR"
|
EnvDataDir = "DATA_DIR"
|
||||||
EnvDebug = "DEBUG"
|
EnvDebug = "DEBUG"
|
||||||
|
EnvPort = "PORT"
|
||||||
|
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultDataDir applies when DATA_DIR is absent.
|
// Defaults, for the variables that are absent.
|
||||||
const DefaultDataDir = "./data"
|
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,
|
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||||
// so callers can distinguish "the operator got it wrong" from "the
|
// 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.
|
// address and its contacts. Losing it loses the address.
|
||||||
DataDir string
|
DataDir string
|
||||||
Debug bool
|
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
|
// loader parses one environment into a Config, accumulating every
|
||||||
@@ -109,6 +130,53 @@ func (l *loader) boolean(key string, def bool) bool {
|
|||||||
return b
|
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
|
// New parses and validates the environment. An error here aborts
|
||||||
// startup before the chat client is launched, so there is no partially
|
// startup before the chat client is launched, so there is no partially
|
||||||
// configured running state to reason about.
|
// configured running state to reason about.
|
||||||
@@ -125,8 +193,10 @@ func load(v *viper.Viper) (*Config, error) {
|
|||||||
l := &loader{v: v}
|
l := &loader{v: v}
|
||||||
|
|
||||||
c := &Config{
|
c := &Config{
|
||||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||||
Debug: l.boolean(EnvDebug, false),
|
Debug: l.boolean(EnvDebug, false),
|
||||||
|
Port: l.port(EnvPort, DefaultPort),
|
||||||
|
APIToken: l.tokenFile(EnvAPITokenFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(l.errs) > 0 {
|
if len(l.errs) > 0 {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package config_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -26,8 +29,11 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, kv := range map[string]map[string]string{
|
for name, kv := range map[string]map[string]string{
|
||||||
"unset": nil,
|
"unset": nil,
|
||||||
"whitespace only": {config.EnvDataDir: " ", config.EnvDebug: " "},
|
"whitespace only": {
|
||||||
|
config.EnvDataDir: " ", config.EnvDebug: " ",
|
||||||
|
config.EnvPort: " ", config.EnvAPITokenFile: "\t",
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -44,6 +50,14 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|||||||
if c.Debug {
|
if c.Debug {
|
||||||
t.Error("Debug must default off")
|
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")
|
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)
|
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
|
// 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
|
// 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.
|
// accept it; a failure is logged when the client's answer arrives.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -43,6 +44,19 @@ const (
|
|||||||
|
|
||||||
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
contactConnected = `{"type":"contactConnected","user":{"userId":1},
|
||||||
"contact":{"contactId":3,"localDisplayName":"alice"}}`
|
"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
|
// 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
|
// TestRefusedCommand: a command the chat client refuses is an error
|
||||||
// that names the reason.
|
// that names the reason.
|
||||||
func TestRefusedCommand(t *testing.T) {
|
func TestRefusedCommand(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
TypeUserContactLink = "userContactLink"
|
TypeUserContactLink = "userContactLink"
|
||||||
TypeUserContactLinkCreated = "userContactLinkCreated"
|
TypeUserContactLinkCreated = "userContactLinkCreated"
|
||||||
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
TypeUserContactLinkUpdated = "userContactLinkUpdated"
|
||||||
|
TypeContactsList = "contactsList"
|
||||||
TypeNewChatItems = "newChatItems"
|
TypeNewChatItems = "newChatItems"
|
||||||
TypeContactConnected = "contactConnected"
|
TypeContactConnected = "contactConnected"
|
||||||
TypeChatCmdError = "chatCmdError"
|
TypeChatCmdError = "chatCmdError"
|
||||||
@@ -46,10 +47,14 @@ func (e Event) Decode(v any) error {
|
|||||||
type (
|
type (
|
||||||
// User is the chat client's local user profile: the bot itself.
|
// User is the chat client's local user profile: the bot itself.
|
||||||
User struct {
|
User struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
Profile struct {
|
Profile Profile `json:"profile"`
|
||||||
DisplayName string `json:"displayName"`
|
}
|
||||||
} `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;
|
// 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 is a person connected to the bot.
|
||||||
Contact struct {
|
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
|
// 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.
|
// Message is a text message a contact sent to the bot.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ContactID int64
|
ContactID int64
|
||||||
@@ -190,6 +204,10 @@ func cmdSetAddressSettings(userID int64, s AddressSettings) (string, error) {
|
|||||||
return "/_address_settings " + strconv.FormatInt(userID, 10) + " " + string(b), nil
|
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) {
|
func cmdSendText(contactID, quotedItemID int64, text string) (string, error) {
|
||||||
b, err := json.Marshal([]composedMessage{{
|
b, err := json.Marshal([]composedMessage{{
|
||||||
QuotedItemID: quotedItemID,
|
QuotedItemID: quotedItemID,
|
||||||
|
|||||||
Reference in New Issue
Block a user