HTTP API: server, credential and the list of chats #12
@@ -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"
|
||||||
|
|
||||||
@@ -36,10 +38,21 @@ import (
|
|||||||
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.
|
||||||
@@ -127,6 +195,8 @@ func load(v *viper.Viper) (*Config, error) {
|
|||||||
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"
|
||||||
@@ -27,7 +30,10 @@ func TestAbsentValuesTakeDefaults(t *testing.T) {
|
|||||||
|
|
||||||
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"
|
||||||
@@ -47,9 +48,13 @@ 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 Profile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is how the bot or a contact presents itself. Nothing
|
||||||
|
// makes a display name unique.
|
||||||
Profile struct {
|
Profile struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
} `json:"profile"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnLink is a SimpleX link. The short form is what people share;
|
// ConnLink is a SimpleX link. The short form is what people share;
|
||||||
@@ -62,6 +67,8 @@ 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