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:
@@ -16,8 +16,10 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
@@ -34,12 +36,23 @@ import (
|
||||
// Environment variable names. Bare names, no prefix: this matches the
|
||||
// other services and keeps a compose file readable.
|
||||
const (
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDebug = "DEBUG"
|
||||
EnvDataDir = "DATA_DIR"
|
||||
EnvDebug = "DEBUG"
|
||||
EnvPort = "PORT"
|
||||
EnvAPITokenFile = "API_TOKEN_FILE" //nolint:gosec // G101: a name, not a credential
|
||||
)
|
||||
|
||||
// DefaultDataDir applies when DATA_DIR is absent.
|
||||
const DefaultDataDir = "./data"
|
||||
// Defaults, for the variables that are absent.
|
||||
const (
|
||||
DefaultDataDir = "./data"
|
||||
DefaultPort = 8080
|
||||
)
|
||||
|
||||
// MinAPITokenLength is the fewest characters the API credential may
|
||||
// have, not counting whitespace around it.
|
||||
const MinAPITokenLength = 32
|
||||
|
||||
const maxPort = 65535
|
||||
|
||||
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||
// so callers can distinguish "the operator got it wrong" from "the
|
||||
@@ -55,6 +68,14 @@ type Config struct {
|
||||
// address and its contacts. Losing it loses the address.
|
||||
DataDir string
|
||||
Debug bool
|
||||
|
||||
// Port is the API's TCP port.
|
||||
Port int
|
||||
|
||||
// APIToken is the credential every API request must carry, read
|
||||
// from the file named by API_TOKEN_FILE. Empty when that is absent,
|
||||
// and then the API refuses every request. Never log it.
|
||||
APIToken string
|
||||
}
|
||||
|
||||
// loader parses one environment into a Config, accumulating every
|
||||
@@ -109,6 +130,53 @@ func (l *loader) boolean(key string, def bool) bool {
|
||||
return b
|
||||
}
|
||||
|
||||
// port accepts a whole number from 1 to 65535 and refuses everything
|
||||
// else.
|
||||
func (l *loader) port(key string, def int) int {
|
||||
s, ok := l.raw(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > maxPort {
|
||||
l.fail(key, s, "not a port (use a whole number from 1 to 65535)")
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// tokenFile returns the credential held in the file named by key, with
|
||||
// the whitespace around it trimmed, or "" when key is absent. A file
|
||||
// that cannot be read, or holds fewer than MinAPITokenLength
|
||||
// characters, is a failure; the message names the file, never what it
|
||||
// holds.
|
||||
func (l *loader) tokenFile(key string) string {
|
||||
path, ok := l.raw(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path) //nolint:gosec // G304: the operator names the file.
|
||||
if err != nil {
|
||||
l.fail(key, path, "unreadable: "+err.Error())
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(string(b))
|
||||
if utf8.RuneCountInString(token) < MinAPITokenLength {
|
||||
l.fail(key, path, fmt.Sprintf("a file holding fewer than %d characters",
|
||||
MinAPITokenLength))
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// New parses and validates the environment. An error here aborts
|
||||
// startup before the chat client is launched, so there is no partially
|
||||
// configured running state to reason about.
|
||||
@@ -125,8 +193,10 @@ func load(v *viper.Viper) (*Config, error) {
|
||||
l := &loader{v: v}
|
||||
|
||||
c := &Config{
|
||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||
Debug: l.boolean(EnvDebug, false),
|
||||
DataDir: l.str(EnvDataDir, DefaultDataDir),
|
||||
Debug: l.boolean(EnvDebug, false),
|
||||
Port: l.port(EnvPort, DefaultPort),
|
||||
APIToken: l.tokenFile(EnvAPITokenFile),
|
||||
}
|
||||
|
||||
if len(l.errs) > 0 {
|
||||
|
||||
Reference in New Issue
Block a user