Compare commits
21 Commits
3d08399e91
...
feature/mv
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d08a8476f | |||
| f0c4a5bb47 | |||
| cbc93473fc | |||
|
|
a57a73e94e | ||
|
|
4b4a337a88 | ||
|
|
69e1042e6e | ||
|
|
6043e9b879 | ||
|
|
b7ec171ea6 | ||
|
|
704f5ecbbf | ||
|
|
a7792168a1 | ||
|
|
d6408b2853 | ||
|
|
d71d09c021 | ||
|
|
eff44e5d32 | ||
|
|
fbeede563d | ||
|
|
84162e82f1 | ||
|
|
6c1d652308 | ||
|
|
5d31c17a9d | ||
|
|
097c24f498 | ||
|
|
368ef4dfc9 | ||
|
|
e342472712 | ||
|
|
5a701e573a |
@@ -7,24 +7,7 @@ run:
|
|||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
disable:
|
disable:
|
||||||
- exhaustruct
|
- wsl # Deprecated in v2, replaced by wsl_v5
|
||||||
- depguard
|
|
||||||
- godot
|
|
||||||
- wsl
|
|
||||||
- wsl_v5
|
|
||||||
- wrapcheck
|
|
||||||
- varnamelen
|
|
||||||
- noinlineerr
|
|
||||||
- dupl
|
|
||||||
- paralleltest
|
|
||||||
- nlreturn
|
|
||||||
- tagliatelle
|
|
||||||
- goconst
|
|
||||||
- funlen
|
|
||||||
- maintidx
|
|
||||||
- cyclop
|
|
||||||
- gocognit
|
|
||||||
- lll
|
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -35,7 +18,19 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
gosec:
|
||||||
|
excludes:
|
||||||
|
- G704
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
all:
|
||||||
|
deny:
|
||||||
|
- pkg: "io/ioutil"
|
||||||
|
desc: "Deprecated; use io and os packages."
|
||||||
|
- pkg: "math/rand$"
|
||||||
|
desc: "Use crypto/rand for security-sensitive code."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
25
Dockerfile
25
Dockerfile
@@ -1,23 +1,26 @@
|
|||||||
# Build stage
|
# golang:1.24-alpine, 2026-02-26
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache make gcc musl-dev
|
RUN apk add --no-cache git build-base make
|
||||||
|
|
||||||
|
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
|
||||||
|
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests
|
# Run all checks — build fails if branch is not green
|
||||||
ENV DBURL="file::memory:?cache=shared"
|
RUN make check
|
||||||
RUN go test ./...
|
|
||||||
|
|
||||||
# Build binaries
|
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chatd ./cmd/chatd/
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
||||||
|
|
||||||
# Final stage — server only
|
# alpine:3.21, 2026-02-26
|
||||||
FROM alpine:3.21
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN apk add --no-cache ca-certificates \
|
||||||
&& addgroup -S chat && adduser -S chat -G chat
|
&& addgroup -S chat && adduser -S chat -G chat
|
||||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||||
|
|||||||
55
Makefile
55
Makefile
@@ -1,20 +1,49 @@
|
|||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||||
LDFLAGS := -ldflags "-X main.Version=$(VERSION)"
|
|
||||||
|
|
||||||
.PHONY: build test clean docker lint
|
BINARY := chatd
|
||||||
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
BUILDARCH := $(shell go env GOARCH)
|
||||||
|
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||||
|
|
||||||
|
all: check build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build $(LDFLAGS) -o chatd ./cmd/chatd/
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
||||||
go build $(LDFLAGS) -o chat-cli ./cmd/chat-cli/
|
|
||||||
|
|
||||||
test:
|
|
||||||
DBURL="file::memory:?cache=shared" go test ./...
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f chatd chat-cli
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
GOFLAGS=-buildvcs=false golangci-lint run ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
gofmt -s -w .
|
||||||
|
goimports -w .
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -timeout 30s -v -race -cover ./...
|
||||||
|
|
||||||
|
# check runs all validation without making changes
|
||||||
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
|
check: test lint fmt-check
|
||||||
|
@echo "==> Building..."
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
||||||
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./bin/$(BINARY)
|
||||||
|
|
||||||
|
debug: build
|
||||||
|
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/ chatd data.db
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t chat:$(VERSION) .
|
docker build -t chat .
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
@printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit
|
||||||
|
@printf 'make check\n' >> .git/hooks/pre-commit
|
||||||
|
@chmod +x .git/hooks/pre-commit
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package chatapi provides a client for the chat server API.
|
||||||
package chatapi
|
package chatapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,8 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
httpTimeout = 30 * time.Second
|
httpTimeout = 30 * time.Second
|
||||||
pollExtraTime = 5
|
pollExtraTime = 5
|
||||||
httpErrThreshold = 400
|
httpErrThreshold = 400
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,17 +32,19 @@ type Client struct {
|
|||||||
|
|
||||||
// NewClient creates a new API client.
|
// NewClient creates a new API client.
|
||||||
func NewClient(baseURL string) *Client {
|
func NewClient(baseURL string) *Client {
|
||||||
return &Client{
|
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
HTTPClient: &http.Client{Timeout: httpTimeout},
|
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||||
|
Timeout: httpTimeout,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
func (c *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
data, err := c.do(
|
data, err := client.do(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick},
|
&SessionRequest{Nick: nick},
|
||||||
@@ -57,14 +60,14 @@ func (c *Client) CreateSession(
|
|||||||
return nil, fmt.Errorf("decode session: %w", err)
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Token = resp.Token
|
client.Token = resp.Token
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetState returns the current user state.
|
// GetState returns the current user state.
|
||||||
func (c *Client) GetState() (*StateResponse, error) {
|
func (client *Client) GetState() (*StateResponse, error) {
|
||||||
data, err := c.do(
|
data, err := client.do(
|
||||||
http.MethodGet, "/api/v1/state", nil,
|
http.MethodGet, "/api/v1/state", nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,8 +85,8 @@ func (c *Client) GetState() (*StateResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends a message (any IRC command).
|
// SendMessage sends a message (any IRC command).
|
||||||
func (c *Client) SendMessage(msg *Message) error {
|
func (client *Client) SendMessage(msg *Message) error {
|
||||||
_, err := c.do(
|
_, err := client.do(
|
||||||
http.MethodPost, "/api/v1/messages", msg,
|
http.MethodPost, "/api/v1/messages", msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,11 +94,11 @@ func (c *Client) SendMessage(msg *Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PollMessages long-polls for new messages.
|
// PollMessages long-polls for new messages.
|
||||||
func (c *Client) PollMessages(
|
func (client *Client) PollMessages(
|
||||||
afterID int64,
|
afterID int64,
|
||||||
timeout int,
|
timeout int,
|
||||||
) (*PollResult, error) {
|
) (*PollResult, error) {
|
||||||
client := &http.Client{
|
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine
|
||||||
Timeout: time.Duration(
|
Timeout: time.Duration(
|
||||||
timeout+pollExtraTime,
|
timeout+pollExtraTime,
|
||||||
) * time.Second,
|
) * time.Second,
|
||||||
@@ -113,28 +116,30 @@ func (c *Client) PollMessages(
|
|||||||
|
|
||||||
path := "/api/v1/messages?" + params.Encode()
|
path := "/api/v1/messages?" + params.Encode()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
c.BaseURL+path,
|
client.BaseURL+path,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
request.Header.Set(
|
||||||
|
"Authorization", "Bearer "+client.Token,
|
||||||
|
)
|
||||||
|
|
||||||
resp, err := client.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
|
resp, err := pollClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("poll request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("read poll body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= httpErrThreshold {
|
if resp.StatusCode >= httpErrThreshold {
|
||||||
@@ -160,22 +165,28 @@ func (c *Client) PollMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// JoinChannel joins a channel.
|
// JoinChannel joins a channel.
|
||||||
func (c *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return c.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{Command: "JOIN", To: channel},
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
|
Command: "JOIN", To: channel,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartChannel leaves a channel.
|
// PartChannel leaves a channel.
|
||||||
func (c *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return c.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{Command: "PART", To: channel},
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
|
Command: "PART", To: channel,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChannels returns all channels on the server.
|
// ListChannels returns all channels on the server.
|
||||||
func (c *Client) ListChannels() ([]Channel, error) {
|
func (client *Client) ListChannels() (
|
||||||
data, err := c.do(
|
[]Channel, error,
|
||||||
|
) {
|
||||||
|
data, err := client.do(
|
||||||
http.MethodGet, "/api/v1/channels", nil,
|
http.MethodGet, "/api/v1/channels", nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,19 +197,21 @@ func (c *Client) ListChannels() ([]Channel, error) {
|
|||||||
|
|
||||||
err = json.Unmarshal(data, &channels)
|
err = json.Unmarshal(data, &channels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"decode channels: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return channels, nil
|
return channels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMembers returns members of a channel.
|
// GetMembers returns members of a channel.
|
||||||
func (c *Client) GetMembers(
|
func (client *Client) GetMembers(
|
||||||
channel string,
|
channel string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
name := strings.TrimPrefix(channel, "#")
|
name := strings.TrimPrefix(channel, "#")
|
||||||
|
|
||||||
data, err := c.do(
|
data, err := client.do(
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/api/v1/channels/"+url.PathEscape(name)+
|
"/api/v1/channels/"+url.PathEscape(name)+
|
||||||
"/members",
|
"/members",
|
||||||
@@ -221,8 +234,10 @@ func (c *Client) GetMembers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetServerInfo returns server info.
|
// GetServerInfo returns server info.
|
||||||
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
func (client *Client) GetServerInfo() (
|
||||||
data, err := c.do(
|
*ServerInfo, error,
|
||||||
|
) {
|
||||||
|
data, err := client.do(
|
||||||
http.MethodGet, "/api/v1/server", nil,
|
http.MethodGet, "/api/v1/server", nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -233,13 +248,15 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
|||||||
|
|
||||||
err = json.Unmarshal(data, &info)
|
err = json.Unmarshal(data, &info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"decode server info: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) do(
|
func (client *Client) do(
|
||||||
method, path string,
|
method, path string,
|
||||||
body any,
|
body any,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
@@ -254,25 +271,27 @@ func (c *Client) do(
|
|||||||
bodyReader = bytes.NewReader(data)
|
bodyReader = bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
request, err := http.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
method,
|
method,
|
||||||
c.BaseURL+path,
|
client.BaseURL+path,
|
||||||
bodyReader,
|
bodyReader,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request: %w", err)
|
return nil, fmt.Errorf("request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
request.Header.Set(
|
||||||
|
"Content-Type", "application/json",
|
||||||
|
)
|
||||||
|
|
||||||
if c.Token != "" {
|
if client.Token != "" {
|
||||||
req.Header.Set(
|
request.Header.Set(
|
||||||
"Authorization", "Bearer "+c.Token,
|
"Authorization", "Bearer "+client.Token,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package chatapi provides API types and client for chat-cli.
|
|
||||||
package chatapi
|
package chatapi
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
@@ -24,9 +23,9 @@ type StateResponse struct {
|
|||||||
|
|
||||||
// Message represents a chat message envelope.
|
// Message represents a chat message envelope.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Params []string `json:"params,omitempty"`
|
Params []string `json:"params,omitempty"`
|
||||||
Body any `json:"body,omitempty"`
|
Body any `json:"body,omitempty"`
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
@@ -36,19 +35,19 @@ type Message struct {
|
|||||||
|
|
||||||
// BodyLines returns the body as a string slice.
|
// BodyLines returns the body as a string slice.
|
||||||
func (m *Message) BodyLines() []string {
|
func (m *Message) BodyLines() []string {
|
||||||
switch v := m.Body.(type) {
|
switch bodyVal := m.Body.(type) {
|
||||||
case []any:
|
case []any:
|
||||||
lines := make([]string, 0, len(v))
|
lines := make([]string, 0, len(bodyVal))
|
||||||
|
|
||||||
for _, item := range v {
|
for _, item := range bodyVal {
|
||||||
if s, ok := item.(string); ok {
|
if str, ok := item.(string); ok {
|
||||||
lines = append(lines, s)
|
lines = append(lines, str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
case []string:
|
case []string:
|
||||||
return v
|
return bodyVal
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
splitParts = 2
|
splitParts = 2
|
||||||
pollTimeout = 15
|
pollTimeout = 15
|
||||||
pollRetry = 2 * time.Second
|
pollRetry = 2 * time.Second
|
||||||
timeFormat = "15:04"
|
timeFormat = "15:04"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App holds the application state.
|
// App holds the application state.
|
||||||
@@ -32,7 +32,7 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &App{
|
app := &App{ //nolint:exhaustruct
|
||||||
ui: NewUI(),
|
ui: NewUI(),
|
||||||
nick: "guest",
|
nick: "guest",
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func (a *App) handleInput(text string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
@@ -98,7 +98,7 @@ func (a *App) handleInput(text string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().Format(timeFormat)
|
timestamp := time.Now().Format(timeFormat)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
@@ -106,7 +106,7 @@ func (a *App) handleInput(text string) {
|
|||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
ts, nick, text,
|
timestamp, nick, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "NICK",
|
Command: "NICK",
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
@@ -362,7 +362,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
@@ -375,11 +375,11 @@ func (a *App) cmdMsg(args string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Now().Format(timeFormat)
|
timestamp := time.Now().Format(timeFormat)
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
ts, nick, text,
|
timestamp, nick, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "TOPIC",
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
@@ -433,7 +433,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "TOPIC",
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
@@ -519,18 +519,18 @@ func (a *App) cmdWindow(args string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var n int
|
var bufIndex int
|
||||||
|
|
||||||
_, _ = fmt.Sscanf(args, "%d", &n)
|
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||||
|
|
||||||
a.ui.SwitchBuffer(n)
|
a.ui.SwitchBuffer(bufIndex)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
nick := a.nick
|
nick := a.nick
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if n >= 0 && n < a.ui.BufferCount() {
|
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||||
buf := a.ui.buffers[n]
|
buf := a.ui.buffers[bufIndex]
|
||||||
if buf.Name != "(status)" {
|
if buf.Name != "(status)" {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.target = buf.Name
|
a.target = buf.Name
|
||||||
@@ -550,7 +550,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: "QUIT"},
|
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +625,7 @@ func (a *App) pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleServerMessage(msg *api.Message) {
|
func (a *App) handleServerMessage(msg *api.Message) {
|
||||||
ts := a.formatTS(msg)
|
timestamp := a.formatTS(msg)
|
||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
myNick := a.nick
|
myNick := a.nick
|
||||||
@@ -633,21 +633,21 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case "PRIVMSG":
|
case "PRIVMSG":
|
||||||
a.handlePrivmsgEvent(msg, ts, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
a.handleJoinEvent(msg, ts)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case "PART":
|
case "PART":
|
||||||
a.handlePartEvent(msg, ts)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
a.handleQuitEvent(msg, ts)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case "NICK":
|
case "NICK":
|
||||||
a.handleNickEvent(msg, ts, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case "NOTICE":
|
case "NOTICE":
|
||||||
a.handleNoticeEvent(msg, ts)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
a.handleTopicEvent(msg, ts)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, ts)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,7 +660,7 @@ func (a *App) formatTS(msg *api.Message) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePrivmsgEvent(
|
func (a *App) handlePrivmsgEvent(
|
||||||
msg *api.Message, ts, myNick string,
|
msg *api.Message, timestamp, myNick string,
|
||||||
) {
|
) {
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
text := strings.Join(lines, " ")
|
text := strings.Join(lines, " ")
|
||||||
@@ -676,12 +676,12 @@ func (a *App) handlePrivmsgEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
a.ui.AddLine(target, fmt.Sprintf(
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
ts, msg.From, text,
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleJoinEvent(
|
func (a *App) handleJoinEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
if msg.To == "" {
|
if msg.To == "" {
|
||||||
return
|
return
|
||||||
@@ -689,12 +689,12 @@ func (a *App) handleJoinEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has joined %s",
|
"[gray]%s [yellow]*** %s has joined %s",
|
||||||
ts, msg.From, msg.To,
|
timestamp, msg.From, msg.To,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePartEvent(
|
func (a *App) handlePartEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
if msg.To == "" {
|
if msg.To == "" {
|
||||||
return
|
return
|
||||||
@@ -706,18 +706,18 @@ func (a *App) handlePartEvent(
|
|||||||
if reason != "" {
|
if reason != "" {
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||||
ts, msg.From, msg.To, reason,
|
timestamp, msg.From, msg.To, reason,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has left %s",
|
"[gray]%s [yellow]*** %s has left %s",
|
||||||
ts, msg.From, msg.To,
|
timestamp, msg.From, msg.To,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleQuitEvent(
|
func (a *App) handleQuitEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
reason := strings.Join(lines, " ")
|
reason := strings.Join(lines, " ")
|
||||||
@@ -725,18 +725,18 @@ func (a *App) handleQuitEvent(
|
|||||||
if reason != "" {
|
if reason != "" {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||||
ts, msg.From, reason,
|
timestamp, msg.From, reason,
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s has quit",
|
"[gray]%s [yellow]*** %s has quit",
|
||||||
ts, msg.From,
|
timestamp, msg.From,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleNickEvent(
|
func (a *App) handleNickEvent(
|
||||||
msg *api.Message, ts, myNick string,
|
msg *api.Message, timestamp, myNick string,
|
||||||
) {
|
) {
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
|
|
||||||
@@ -757,24 +757,24 @@ func (a *App) handleNickEvent(
|
|||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [yellow]*** %s is now known as %s",
|
"[gray]%s [yellow]*** %s is now known as %s",
|
||||||
ts, msg.From, newNick,
|
timestamp, msg.From, newNick,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleNoticeEvent(
|
func (a *App) handleNoticeEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
text := strings.Join(lines, " ")
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [magenta]--%s-- %s",
|
"[gray]%s [magenta]--%s-- %s",
|
||||||
ts, msg.From, text,
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleTopicEvent(
|
func (a *App) handleTopicEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
if msg.To == "" {
|
if msg.To == "" {
|
||||||
return
|
return
|
||||||
@@ -785,12 +785,12 @@ func (a *App) handleTopicEvent(
|
|||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||||
"[gray]%s [cyan]*** %s set topic: %s",
|
"[gray]%s [cyan]*** %s set topic: %s",
|
||||||
ts, msg.From, text,
|
timestamp, msg.From, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleDefaultEvent(
|
func (a *App) handleDefaultEvent(
|
||||||
msg *api.Message, ts string,
|
msg *api.Message, timestamp string,
|
||||||
) {
|
) {
|
||||||
lines := msg.BodyLines()
|
lines := msg.BodyLines()
|
||||||
text := strings.Join(lines, " ")
|
text := strings.Join(lines, " ")
|
||||||
@@ -798,7 +798,7 @@ func (a *App) handleDefaultEvent(
|
|||||||
if text != "" {
|
if text != "" {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
"[gray]%s [white][%s] %s",
|
"[gray]%s [white][%s] %s",
|
||||||
ts, msg.Command, text,
|
timestamp, msg.Command, text,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ type UI struct {
|
|||||||
|
|
||||||
// NewUI creates the tview-based IRC-like UI.
|
// NewUI creates the tview-based IRC-like UI.
|
||||||
func NewUI() *UI {
|
func NewUI() *UI {
|
||||||
ui := &UI{
|
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic
|
||||||
app: tview.NewApplication(),
|
app: tview.NewApplication(),
|
||||||
buffers: []*Buffer{
|
buffers: []*Buffer{
|
||||||
{Name: "(status)", Lines: nil},
|
{Name: "(status)", Lines: nil, Unread: 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,12 @@ func NewUI() *UI {
|
|||||||
|
|
||||||
// Run starts the UI event loop (blocks).
|
// Run starts the UI event loop (blocks).
|
||||||
func (ui *UI) Run() error {
|
func (ui *UI) Run() error {
|
||||||
return ui.app.Run()
|
err := ui.app.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run ui: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the UI.
|
// Stop stops the UI.
|
||||||
@@ -80,6 +85,7 @@ func (ui *UI) AddLine(bufferName, line string) {
|
|||||||
cur := ui.buffers[ui.currentBuffer]
|
cur := ui.buffers[ui.currentBuffer]
|
||||||
if cur != buf {
|
if cur != buf {
|
||||||
buf.Unread++
|
buf.Unread++
|
||||||
|
|
||||||
ui.refreshStatusBar()
|
ui.refreshStatusBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,15 +105,15 @@ func (ui *UI) AddStatus(line string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwitchBuffer switches to the buffer at index n.
|
// SwitchBuffer switches to the buffer at index n.
|
||||||
func (ui *UI) SwitchBuffer(n int) {
|
func (ui *UI) SwitchBuffer(bufIndex int) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
if n < 0 || n >= len(ui.buffers) {
|
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.currentBuffer = n
|
ui.currentBuffer = bufIndex
|
||||||
|
|
||||||
buf := ui.buffers[n]
|
buf := ui.buffers[bufIndex]
|
||||||
buf.Unread = 0
|
buf.Unread = 0
|
||||||
|
|
||||||
ui.messages.Clear()
|
ui.messages.Clear()
|
||||||
@@ -281,7 +287,7 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := &Buffer{Name: name}
|
buf := &Buffer{Name: name, Lines: nil, Unread: 0}
|
||||||
ui.buffers = append(ui.buffers, buf)
|
ui.buffers = append(ui.buffers, buf)
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
|
|||||||
@@ -8,25 +8,28 @@ import (
|
|||||||
// Broker notifies waiting clients when new messages are available.
|
// Broker notifies waiting clients when new messages are available.
|
||||||
type Broker struct {
|
type Broker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
listeners map[int64][]chan struct{} // userID -> list of waiting channels
|
listeners map[int64][]chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Broker.
|
// New creates a new Broker.
|
||||||
func New() *Broker {
|
func New() *Broker {
|
||||||
return &Broker{
|
return &Broker{ //nolint:exhaustruct // mu has zero-value default
|
||||||
listeners: make(map[int64][]chan struct{}),
|
listeners: make(map[int64][]chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait returns a channel that will be closed when a message is available for the user.
|
// Wait returns a channel that will be closed when a message
|
||||||
|
// is available for the user.
|
||||||
func (b *Broker) Wait(userID int64) chan struct{} {
|
func (b *Broker) Wait(userID int64) chan struct{} {
|
||||||
ch := make(chan struct{}, 1)
|
waitCh := make(chan struct{}, 1)
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.listeners[userID] = append(b.listeners[userID], ch)
|
b.listeners[userID] = append(
|
||||||
|
b.listeners[userID], waitCh,
|
||||||
|
)
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
|
||||||
return ch
|
return waitCh
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify wakes up all waiting clients for a user.
|
// Notify wakes up all waiting clients for a user.
|
||||||
@@ -36,24 +39,29 @@ func (b *Broker) Notify(userID int64) {
|
|||||||
delete(b.listeners, userID)
|
delete(b.listeners, userID)
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
|
||||||
for _, ch := range waiters {
|
for _, waiter := range waiters {
|
||||||
select {
|
select {
|
||||||
case ch <- struct{}{}:
|
case waiter <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove removes a specific wait channel (for cleanup on timeout).
|
// Remove removes a specific wait channel (for cleanup on timeout).
|
||||||
func (b *Broker) Remove(userID int64, ch chan struct{}) {
|
func (b *Broker) Remove(
|
||||||
|
userID int64,
|
||||||
|
waitCh chan struct{},
|
||||||
|
) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
waiters := b.listeners[userID]
|
waiters := b.listeners[userID]
|
||||||
|
|
||||||
for i, w := range waiters {
|
for i, waiter := range waiters {
|
||||||
if w == ch {
|
if waiter == waitCh {
|
||||||
b.listeners[userID] = append(waiters[:i], waiters[i+1:]...)
|
b.listeners[userID] = append(
|
||||||
|
waiters[:i], waiters[i+1:]...,
|
||||||
|
)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
if b == nil {
|
if brk == nil {
|
||||||
t.Fatal("expected non-nil broker")
|
t.Fatal("expected non-nil broker")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,16 +20,16 @@ func TestNewBroker(t *testing.T) {
|
|||||||
func TestWaitAndNotify(t *testing.T) {
|
func TestWaitAndNotify(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
ch := b.Wait(1)
|
waitCh := brk.Wait(1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
b.Notify(1)
|
brk.Notify(1)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-waitCh:
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("timeout")
|
t.Fatal("timeout")
|
||||||
}
|
}
|
||||||
@@ -38,21 +38,22 @@ func TestWaitAndNotify(t *testing.T) {
|
|||||||
func TestNotifyWithoutWaiters(t *testing.T) {
|
func TestNotifyWithoutWaiters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
b.Notify(42) // should not panic
|
brk.Notify(42) // should not panic.
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemove(t *testing.T) {
|
func TestRemove(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
ch := b.Wait(1)
|
waitCh := brk.Wait(1)
|
||||||
b.Remove(1, ch)
|
|
||||||
|
|
||||||
b.Notify(1)
|
brk.Remove(1, waitCh)
|
||||||
|
|
||||||
|
brk.Notify(1)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-waitCh:
|
||||||
t.Fatal("should not receive after remove")
|
t.Fatal("should not receive after remove")
|
||||||
case <-time.After(50 * time.Millisecond):
|
case <-time.After(50 * time.Millisecond):
|
||||||
}
|
}
|
||||||
@@ -61,20 +62,20 @@ func TestRemove(t *testing.T) {
|
|||||||
func TestMultipleWaiters(t *testing.T) {
|
func TestMultipleWaiters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
ch1 := b.Wait(1)
|
waitCh1 := brk.Wait(1)
|
||||||
ch2 := b.Wait(1)
|
waitCh2 := brk.Wait(1)
|
||||||
|
|
||||||
b.Notify(1)
|
brk.Notify(1)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch1:
|
case <-waitCh1:
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("ch1 timeout")
|
t.Fatal("ch1 timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch2:
|
case <-waitCh2:
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("ch2 timeout")
|
t.Fatal("ch2 timeout")
|
||||||
}
|
}
|
||||||
@@ -83,36 +84,38 @@ func TestMultipleWaiters(t *testing.T) {
|
|||||||
func TestConcurrentWaitNotify(t *testing.T) {
|
func TestConcurrentWaitNotify(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var waitGroup sync.WaitGroup
|
||||||
|
|
||||||
const concurrency = 100
|
const concurrency = 100
|
||||||
|
|
||||||
for i := range concurrency {
|
for idx := range concurrency {
|
||||||
wg.Add(1)
|
waitGroup.Add(1)
|
||||||
|
|
||||||
go func(uid int64) {
|
go func(uid int64) {
|
||||||
defer wg.Done()
|
defer waitGroup.Done()
|
||||||
|
|
||||||
ch := b.Wait(uid)
|
waitCh := brk.Wait(uid)
|
||||||
b.Notify(uid)
|
|
||||||
|
brk.Notify(uid)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-waitCh:
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Error("timeout")
|
t.Error("timeout")
|
||||||
}
|
}
|
||||||
}(int64(i % 10))
|
}(int64(idx % 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
waitGroup.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveNonexistent(t *testing.T) {
|
func TestRemoveNonexistent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := broker.New()
|
brk := broker.New()
|
||||||
ch := make(chan struct{}, 1)
|
waitCh := make(chan struct{}, 1)
|
||||||
b.Remove(999, ch) // should not panic
|
|
||||||
|
brk.Remove(999, waitCh) // should not panic.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config by reading from files and environment variables.
|
// New creates a new Config by reading from files and environment variables.
|
||||||
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
func New(
|
||||||
|
_ fx.Lifecycle, params Params,
|
||||||
|
) (*Config, error) {
|
||||||
log := params.Logger.Get()
|
log := params.Logger.Get()
|
||||||
name := params.Globals.Appname
|
name := params.Globals.Appname
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Config{
|
cfg := &Config{
|
||||||
DBURL: viper.GetString("DBURL"),
|
DBURL: viper.GetString("DBURL"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
@@ -92,10 +94,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Debug {
|
if cfg.Debug {
|
||||||
params.Logger.EnableDebugLogging()
|
params.Logger.EnableDebugLogging()
|
||||||
s.log = params.Logger.Get()
|
cfg.log = params.Logger.Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,84 +37,93 @@ type Params struct {
|
|||||||
|
|
||||||
// Database manages the SQLite connection and migrations.
|
// Database manages the SQLite connection and migrations.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sql.DB
|
conn *sql.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Database and registers lifecycle hooks.
|
// New creates a new Database and registers lifecycle hooks.
|
||||||
func New(
|
func New(
|
||||||
lc fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Database, error) {
|
) (*Database, error) {
|
||||||
s := new(Database)
|
database := &Database{ //nolint:exhaustruct // conn set in OnStart
|
||||||
s.params = ¶ms
|
params: ¶ms,
|
||||||
s.log = params.Logger.Get()
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
|
||||||
s.log.Info("Database instantiated")
|
database.log.Info("Database instantiated")
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
s.log.Info("Database OnStart Hook")
|
database.log.Info("Database OnStart Hook")
|
||||||
|
|
||||||
return s.connect(ctx)
|
return database.connect(ctx)
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
s.log.Info("Database OnStop Hook")
|
database.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if s.db != nil {
|
if database.conn != nil {
|
||||||
return s.db.Close()
|
closeErr := database.conn.Close()
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"close db: %w", closeErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return database, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the underlying sql.DB connection.
|
// GetDB returns the underlying sql.DB connection.
|
||||||
func (s *Database) GetDB() *sql.DB {
|
func (database *Database) GetDB() *sql.DB {
|
||||||
return s.db
|
return database.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) connect(ctx context.Context) error {
|
func (database *Database) connect(ctx context.Context) error {
|
||||||
dbURL := s.params.Config.DBURL
|
dbURL := database.params.Config.DBURL
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:./data.db?_journal_mode=WAL"
|
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("connecting to database", "url", dbURL)
|
database.log.Info(
|
||||||
|
"connecting to database", "url", dbURL,
|
||||||
|
)
|
||||||
|
|
||||||
d, err := sql.Open("sqlite", dbURL)
|
conn, err := sql.Open("sqlite", dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error(
|
return fmt.Errorf("open database: %w", err)
|
||||||
"failed to open database", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = d.PingContext(ctx)
|
err = conn.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error(
|
return fmt.Errorf("ping database: %w", err)
|
||||||
"failed to ping database", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.db = d
|
conn.SetMaxOpenConns(1)
|
||||||
s.log.Info("database connected")
|
|
||||||
|
|
||||||
_, err = s.db.ExecContext(
|
database.conn = conn
|
||||||
|
database.log.Info("database connected")
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(
|
||||||
ctx, "PRAGMA foreign_keys = ON",
|
ctx, "PRAGMA foreign_keys = ON",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("enable foreign keys: %w", err)
|
return fmt.Errorf("enable foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.runMigrations(ctx)
|
_, err = database.conn.ExecContext(
|
||||||
|
ctx, "PRAGMA busy_timeout = 5000",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set busy timeout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.runMigrations(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
type migration struct {
|
type migration struct {
|
||||||
@@ -123,10 +132,10 @@ type migration struct {
|
|||||||
sql string
|
sql string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) runMigrations(
|
func (database *Database) runMigrations(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
||||||
@@ -136,37 +145,37 @@ func (s *Database) runMigrations(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations, err := s.loadMigrations()
|
migrations, err := database.loadMigrations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range migrations {
|
for _, mig := range migrations {
|
||||||
err = s.applyMigration(ctx, m)
|
err = database.applyMigration(ctx, mig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("database migrations complete")
|
database.log.Info("database migrations complete")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) applyMigration(
|
func (database *Database) applyMigration(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
m migration,
|
mig migration,
|
||||||
) error {
|
) error {
|
||||||
var exists int
|
var exists int
|
||||||
|
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := database.conn.QueryRowContext(ctx,
|
||||||
`SELECT COUNT(*) FROM schema_migrations
|
`SELECT COUNT(*) FROM schema_migrations
|
||||||
WHERE version = ?`,
|
WHERE version = ?`,
|
||||||
m.version,
|
mig.version,
|
||||||
).Scan(&exists)
|
).Scan(&exists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"check migration %d: %w", m.version, err,
|
"check migration %d: %w", mig.version, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,55 +183,63 @@ func (s *Database) applyMigration(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info(
|
database.log.Info(
|
||||||
"applying migration",
|
"applying migration",
|
||||||
"version", m.version,
|
"version", mig.version,
|
||||||
"name", m.name,
|
"name", mig.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
return s.execMigration(ctx, m)
|
return database.execMigration(ctx, mig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) execMigration(
|
func (database *Database) execMigration(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
m migration,
|
mig migration,
|
||||||
) error {
|
) error {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"begin tx for migration %d: %w",
|
"begin tx for migration %d: %w",
|
||||||
m.version, err,
|
mig.version, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, m.sql)
|
_, err = transaction.ExecContext(ctx, mig.sql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"apply migration %d (%s): %w",
|
"apply migration %d (%s): %w",
|
||||||
m.version, m.name, err,
|
mig.version, mig.name, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx,
|
_, err = transaction.ExecContext(ctx,
|
||||||
`INSERT INTO schema_migrations (version)
|
`INSERT INTO schema_migrations (version)
|
||||||
VALUES (?)`,
|
VALUES (?)`,
|
||||||
m.version,
|
mig.version,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"record migration %d: %w",
|
"record migration %d: %w",
|
||||||
m.version, err,
|
mig.version, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
err = transaction.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"commit migration %d: %w",
|
||||||
|
mig.version, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) loadMigrations() (
|
func (database *Database) loadMigrations() (
|
||||||
[]migration,
|
[]migration,
|
||||||
error,
|
error,
|
||||||
) {
|
) {
|
||||||
@@ -233,7 +250,7 @@ func (s *Database) loadMigrations() (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var migrations []migration
|
migrations := make([]migration, 0, len(entries))
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() ||
|
if entry.IsDir() ||
|
||||||
|
|||||||
@@ -13,35 +13,48 @@ var testDBCounter atomic.Int64
|
|||||||
|
|
||||||
// NewTestDatabase creates an in-memory database for testing.
|
// NewTestDatabase creates an in-memory database for testing.
|
||||||
func NewTestDatabase() (*Database, error) {
|
func NewTestDatabase() (*Database, error) {
|
||||||
n := testDBCounter.Add(1)
|
counter := testDBCounter.Add(1)
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"file:testdb%d?mode=memory"+
|
"file:testdb%d?mode=memory"+
|
||||||
"&cache=shared&_pragma=foreign_keys(1)",
|
"&cache=shared&_pragma=foreign_keys(1)",
|
||||||
n,
|
counter,
|
||||||
)
|
)
|
||||||
|
|
||||||
d, err := sql.Open("sqlite", dsn)
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("open test db: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
database := &Database{db: d, log: slog.Default()}
|
database := &Database{ //nolint:exhaustruct // test helper, params not needed
|
||||||
|
conn: conn,
|
||||||
|
log: slog.Default(),
|
||||||
|
}
|
||||||
|
|
||||||
err = database.runMigrations(context.Background())
|
err = database.runMigrations(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
closeErr := d.Close()
|
closeErr := conn.Close()
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
return nil, closeErr
|
return nil, fmt.Errorf(
|
||||||
|
"close after migration failure: %w",
|
||||||
|
closeErr,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"run test migrations: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return database, nil
|
return database, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the underlying database connection.
|
// Close closes the underlying database connection.
|
||||||
func (s *Database) Close() error {
|
func (database *Database) Close() error {
|
||||||
return s.db.Close()
|
err := database.conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("close database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,15 @@ const (
|
|||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateToken() string {
|
func generateToken() (string, error) {
|
||||||
b := make([]byte, tokenBytes)
|
buf := make([]byte, tokenBytes)
|
||||||
_, _ = rand.Read(b)
|
|
||||||
|
|
||||||
return hex.EncodeToString(b)
|
_, err := rand.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IRCMessage is the IRC envelope for all messages.
|
// IRCMessage is the IRC envelope for all messages.
|
||||||
@@ -52,14 +56,18 @@ type MemberInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser registers a new user with the given nick.
|
// CreateUser registers a new user with the given nick.
|
||||||
func (s *Database) CreateUser(
|
func (database *Database) CreateUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick string,
|
nick string,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
token := generateToken()
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO users
|
`INSERT INTO users
|
||||||
(nick, token, created_at, last_seen)
|
(nick, token, created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?)`,
|
||||||
@@ -68,90 +76,88 @@ func (s *Database) CreateUser(
|
|||||||
return 0, "", fmt.Errorf("create user: %w", err)
|
return 0, "", fmt.Errorf("create user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := res.LastInsertId()
|
userID, _ := res.LastInsertId()
|
||||||
|
|
||||||
return id, token, nil
|
return userID, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByToken returns user id and nick for a token.
|
// GetUserByToken returns user id and nick for a token.
|
||||||
func (s *Database) GetUserByToken(
|
func (database *Database) GetUserByToken(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
token string,
|
token string,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
var id int64
|
var userID int64
|
||||||
|
|
||||||
var nick string
|
var nick string
|
||||||
|
|
||||||
err := s.db.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
"SELECT id, nick FROM users WHERE token = ?",
|
"SELECT id, nick FROM users WHERE token = ?",
|
||||||
token,
|
token,
|
||||||
).Scan(&id, &nick)
|
).Scan(&userID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
return 0, "", fmt.Errorf("get user by token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = s.db.ExecContext(
|
_, _ = database.conn.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
"UPDATE users SET last_seen = ? WHERE id = ?",
|
"UPDATE users SET last_seen = ? WHERE id = ?",
|
||||||
time.Now(), id,
|
time.Now(), userID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return id, nick, nil
|
return userID, nick, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByNick returns user id for a given nick.
|
// GetUserByNick returns user id for a given nick.
|
||||||
func (s *Database) GetUserByNick(
|
func (database *Database) GetUserByNick(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick string,
|
nick string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
var id int64
|
var userID int64
|
||||||
|
|
||||||
err := s.db.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
"SELECT id FROM users WHERE nick = ?",
|
"SELECT id FROM users WHERE nick = ?",
|
||||||
nick,
|
nick,
|
||||||
).Scan(&id)
|
).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get user by nick: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return id, err
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelByName returns the channel ID for a name.
|
// GetChannelByName returns the channel ID for a name.
|
||||||
func (s *Database) GetChannelByName(
|
func (database *Database) GetChannelByName(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
name string,
|
name string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
var id int64
|
var channelID int64
|
||||||
|
|
||||||
err := s.db.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
"SELECT id FROM channels WHERE name = ?",
|
"SELECT id FROM channels WHERE name = ?",
|
||||||
name,
|
name,
|
||||||
).Scan(&id)
|
).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get channel by name: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return id, err
|
return channelID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateChannel returns channel id, creating if needed.
|
// GetOrCreateChannel returns channel id, creating if needed.
|
||||||
func (s *Database) GetOrCreateChannel(
|
// Uses INSERT OR IGNORE to avoid TOCTOU races.
|
||||||
|
func (database *Database) GetOrCreateChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
name string,
|
name string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
var id int64
|
|
||||||
|
|
||||||
err := s.db.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT id FROM channels WHERE name = ?",
|
|
||||||
name,
|
|
||||||
).Scan(&id)
|
|
||||||
if err == nil {
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
res, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO channels
|
`INSERT OR IGNORE INTO channels
|
||||||
(name, created_at, updated_at)
|
(name, created_at, updated_at)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?)`,
|
||||||
name, now, now)
|
name, now, now)
|
||||||
@@ -159,51 +165,71 @@ func (s *Database) GetOrCreateChannel(
|
|||||||
return 0, fmt.Errorf("create channel: %w", err)
|
return 0, fmt.Errorf("create channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ = res.LastInsertId()
|
var channelID int64
|
||||||
|
|
||||||
return id, nil
|
err = database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT id FROM channels WHERE name = ?",
|
||||||
|
name,
|
||||||
|
).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return channelID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinChannel adds a user to a channel.
|
// JoinChannel adds a user to a channel.
|
||||||
func (s *Database) JoinChannel(
|
func (database *Database) JoinChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelID, userID int64,
|
channelID, userID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO channel_members
|
`INSERT OR IGNORE INTO channel_members
|
||||||
(channel_id, user_id, joined_at)
|
(channel_id, user_id, joined_at)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?)`,
|
||||||
channelID, userID, time.Now())
|
channelID, userID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("join channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartChannel removes a user from a channel.
|
// PartChannel removes a user from a channel.
|
||||||
func (s *Database) PartChannel(
|
func (database *Database) PartChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelID, userID int64,
|
channelID, userID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`DELETE FROM channel_members
|
`DELETE FROM channel_members
|
||||||
WHERE channel_id = ? AND user_id = ?`,
|
WHERE channel_id = ? AND user_id = ?`,
|
||||||
channelID, userID)
|
channelID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("part channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteChannelIfEmpty removes a channel with no members.
|
// DeleteChannelIfEmpty removes a channel with no members.
|
||||||
func (s *Database) DeleteChannelIfEmpty(
|
func (database *Database) DeleteChannelIfEmpty(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelID int64,
|
channelID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`DELETE FROM channels WHERE id = ?
|
`DELETE FROM channels WHERE id = ?
|
||||||
AND NOT EXISTS
|
AND NOT EXISTS
|
||||||
(SELECT 1 FROM channel_members
|
(SELECT 1 FROM channel_members
|
||||||
WHERE channel_id = ?)`,
|
WHERE channel_id = ?)`,
|
||||||
channelID, channelID)
|
channelID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"delete channel if empty: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanChannels scans rows into a ChannelInfo slice.
|
// scanChannels scans rows into a ChannelInfo slice.
|
||||||
@@ -215,19 +241,21 @@ func scanChannels(
|
|||||||
var out []ChannelInfo
|
var out []ChannelInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ch ChannelInfo
|
var chanInfo ChannelInfo
|
||||||
|
|
||||||
err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic)
|
err := rows.Scan(
|
||||||
|
&chanInfo.ID, &chanInfo.Name, &chanInfo.Topic,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("scan channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, ch)
|
out = append(out, chanInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rows.Err()
|
err := rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("rows error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out == nil {
|
if out == nil {
|
||||||
@@ -238,11 +266,11 @@ func scanChannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListChannels returns channels the user has joined.
|
// ListChannels returns channels the user has joined.
|
||||||
func (s *Database) ListChannels(
|
func (database *Database) ListChannels(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
) ([]ChannelInfo, error) {
|
) ([]ChannelInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT c.id, c.name, c.topic
|
`SELECT c.id, c.name, c.topic
|
||||||
FROM channels c
|
FROM channels c
|
||||||
INNER JOIN channel_members cm
|
INNER JOIN channel_members cm
|
||||||
@@ -250,32 +278,34 @@ func (s *Database) ListChannels(
|
|||||||
WHERE cm.user_id = ?
|
WHERE cm.user_id = ?
|
||||||
ORDER BY c.name`, userID)
|
ORDER BY c.name`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("list channels: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanChannels(rows)
|
return scanChannels(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAllChannels returns every channel.
|
// ListAllChannels returns every channel.
|
||||||
func (s *Database) ListAllChannels(
|
func (database *Database) ListAllChannels(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) ([]ChannelInfo, error) {
|
) ([]ChannelInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, name, topic
|
`SELECT id, name, topic
|
||||||
FROM channels ORDER BY name`)
|
FROM channels ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"list all channels: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanChannels(rows)
|
return scanChannels(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelMembers returns all members of a channel.
|
// ChannelMembers returns all members of a channel.
|
||||||
func (s *Database) ChannelMembers(
|
func (database *Database) ChannelMembers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelID int64,
|
channelID int64,
|
||||||
) ([]MemberInfo, error) {
|
) ([]MemberInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT u.id, u.nick, u.last_seen
|
`SELECT u.id, u.nick, u.last_seen
|
||||||
FROM users u
|
FROM users u
|
||||||
INNER JOIN channel_members cm
|
INNER JOIN channel_members cm
|
||||||
@@ -283,7 +313,9 @@ func (s *Database) ChannelMembers(
|
|||||||
WHERE cm.channel_id = ?
|
WHERE cm.channel_id = ?
|
||||||
ORDER BY u.nick`, channelID)
|
ORDER BY u.nick`, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"query channel members: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
@@ -291,19 +323,23 @@ func (s *Database) ChannelMembers(
|
|||||||
var members []MemberInfo
|
var members []MemberInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m MemberInfo
|
var member MemberInfo
|
||||||
|
|
||||||
err = rows.Scan(&m.ID, &m.Nick, &m.LastSeen)
|
err = rows.Scan(
|
||||||
|
&member.ID, &member.Nick, &member.LastSeen,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"scan member: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
members = append(members, m)
|
members = append(members, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("rows error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if members == nil {
|
if members == nil {
|
||||||
@@ -313,6 +349,27 @@ func (s *Database) ChannelMembers(
|
|||||||
return members, nil
|
return members, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsChannelMember checks if a user belongs to a channel.
|
||||||
|
func (database *Database) IsChannelMember(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID, userID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM channel_members
|
||||||
|
WHERE channel_id = ? AND user_id = ?`,
|
||||||
|
channelID, userID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check membership: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// scanInt64s scans rows into an int64 slice.
|
// scanInt64s scans rows into an int64 slice.
|
||||||
func scanInt64s(rows *sql.Rows) ([]int64, error) {
|
func scanInt64s(rows *sql.Rows) ([]int64, error) {
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
@@ -320,58 +377,64 @@ func scanInt64s(rows *sql.Rows) ([]int64, error) {
|
|||||||
var ids []int64
|
var ids []int64
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var val int64
|
||||||
|
|
||||||
err := rows.Scan(&id)
|
err := rows.Scan(&val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"scan int64: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ids = append(ids, id)
|
ids = append(ids, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rows.Err()
|
err := rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("rows error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelMemberIDs returns user IDs in a channel.
|
// GetChannelMemberIDs returns user IDs in a channel.
|
||||||
func (s *Database) GetChannelMemberIDs(
|
func (database *Database) GetChannelMemberIDs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelID int64,
|
channelID int64,
|
||||||
) ([]int64, error) {
|
) ([]int64, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT user_id FROM channel_members
|
`SELECT user_id FROM channel_members
|
||||||
WHERE channel_id = ?`, channelID)
|
WHERE channel_id = ?`, channelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"get channel member ids: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanInt64s(rows)
|
return scanInt64s(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserChannelIDs returns channel IDs the user is in.
|
// GetUserChannelIDs returns channel IDs the user is in.
|
||||||
func (s *Database) GetUserChannelIDs(
|
func (database *Database) GetUserChannelIDs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
) ([]int64, error) {
|
) ([]int64, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT channel_id FROM channel_members
|
`SELECT channel_id FROM channel_members
|
||||||
WHERE user_id = ?`, userID)
|
WHERE user_id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"get user channel ids: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanInt64s(rows)
|
return scanInt64s(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertMessage stores a message and returns its DB ID.
|
// InsertMessage stores a message and returns its DB ID.
|
||||||
func (s *Database) InsertMessage(
|
func (database *Database) InsertMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command, from, to string,
|
command, from, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
@@ -386,38 +449,43 @@ func (s *Database) InsertMessage(
|
|||||||
meta = json.RawMessage("{}")
|
meta = json.RawMessage("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(uuid, command, msg_from, msg_to,
|
(uuid, command, msg_from, msg_to,
|
||||||
body, meta, created_at)
|
body, meta, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msgUUID, command, from, to,
|
msgUUID, command, from, target,
|
||||||
string(body), string(meta), now)
|
string(body), string(meta), now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
return 0, "", fmt.Errorf(
|
||||||
|
"insert message: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := res.LastInsertId()
|
dbID, _ := res.LastInsertId()
|
||||||
|
|
||||||
return id, msgUUID, nil
|
return dbID, msgUUID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnqueueMessage adds a message to a user's queue.
|
// EnqueueMessage adds a message to a user's queue.
|
||||||
func (s *Database) EnqueueMessage(
|
func (database *Database) EnqueueMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, messageID int64,
|
userID, messageID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO client_queues
|
`INSERT OR IGNORE INTO client_queues
|
||||||
(user_id, message_id, created_at)
|
(user_id, message_id, created_at)
|
||||||
VALUES (?, ?, ?)`,
|
VALUES (?, ?, ?)`,
|
||||||
userID, messageID, time.Now())
|
userID, messageID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("enqueue message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollMessages returns queued messages for a user.
|
// PollMessages returns queued messages for a user.
|
||||||
func (s *Database) PollMessages(
|
func (database *Database) PollMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, afterQueueID int64,
|
userID, afterQueueID int64,
|
||||||
limit int,
|
limit int,
|
||||||
@@ -426,7 +494,7 @@ func (s *Database) PollMessages(
|
|||||||
limit = defaultPollLimit
|
limit = defaultPollLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT cq.id, m.uuid, m.command,
|
`SELECT cq.id, m.uuid, m.command,
|
||||||
m.msg_from, m.msg_to,
|
m.msg_from, m.msg_to,
|
||||||
m.body, m.meta, m.created_at
|
m.body, m.meta, m.created_at
|
||||||
@@ -437,7 +505,9 @@ func (s *Database) PollMessages(
|
|||||||
ORDER BY cq.id ASC LIMIT ?`,
|
ORDER BY cq.id ASC LIMIT ?`,
|
||||||
userID, afterQueueID, limit)
|
userID, afterQueueID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, afterQueueID, err
|
return nil, afterQueueID, fmt.Errorf(
|
||||||
|
"poll messages: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, lastQID, scanErr := scanMessages(
|
msgs, lastQID, scanErr := scanMessages(
|
||||||
@@ -451,7 +521,7 @@ func (s *Database) PollMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetHistory returns message history for a target.
|
// GetHistory returns message history for a target.
|
||||||
func (s *Database) GetHistory(
|
func (database *Database) GetHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
target string,
|
target string,
|
||||||
beforeID int64,
|
beforeID int64,
|
||||||
@@ -461,7 +531,7 @@ func (s *Database) GetHistory(
|
|||||||
limit = defaultHistLimit
|
limit = defaultHistLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.queryHistory(
|
rows, err := database.queryHistory(
|
||||||
ctx, target, beforeID, limit,
|
ctx, target, beforeID, limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -482,14 +552,14 @@ func (s *Database) GetHistory(
|
|||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) queryHistory(
|
func (database *Database) queryHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
target string,
|
target string,
|
||||||
beforeID int64,
|
beforeID int64,
|
||||||
limit int,
|
limit int,
|
||||||
) (*sql.Rows, error) {
|
) (*sql.Rows, error) {
|
||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
return s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
@@ -497,9 +567,16 @@ func (s *Database) queryHistory(
|
|||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
ORDER BY id DESC LIMIT ?`,
|
ORDER BY id DESC LIMIT ?`,
|
||||||
target, beforeID, limit)
|
target, beforeID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"query history: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
@@ -507,6 +584,11 @@ func (s *Database) queryHistory(
|
|||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
ORDER BY id DESC LIMIT ?`,
|
ORDER BY id DESC LIMIT ?`,
|
||||||
target, limit)
|
target, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMessages(
|
func scanMessages(
|
||||||
@@ -521,33 +603,37 @@ func scanMessages(
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
m IRCMessage
|
msg IRCMessage
|
||||||
qID int64
|
qID int64
|
||||||
body, meta string
|
body, meta string
|
||||||
ts time.Time
|
createdAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&qID, &m.ID, &m.Command,
|
&qID, &msg.ID, &msg.Command,
|
||||||
&m.From, &m.To,
|
&msg.From, &msg.To,
|
||||||
&body, &meta, &ts,
|
&body, &meta, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, err
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
|
"scan message: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.Body = json.RawMessage(body)
|
msg.Body = json.RawMessage(body)
|
||||||
m.Meta = json.RawMessage(meta)
|
msg.Meta = json.RawMessage(meta)
|
||||||
m.TS = ts.Format(time.RFC3339Nano)
|
msg.TS = createdAt.Format(time.RFC3339Nano)
|
||||||
m.DBID = qID
|
msg.DBID = qID
|
||||||
lastQID = qID
|
lastQID = qID
|
||||||
|
|
||||||
msgs = append(msgs, m)
|
msgs = append(msgs, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rows.Err()
|
err := rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, err
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
|
"rows error: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
@@ -564,59 +650,70 @@ func reverseMessages(msgs []IRCMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ChangeNick updates a user's nickname.
|
// ChangeNick updates a user's nickname.
|
||||||
func (s *Database) ChangeNick(
|
func (database *Database) ChangeNick(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
newNick string,
|
newNick string,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
"UPDATE users SET nick = ? WHERE id = ?",
|
"UPDATE users SET nick = ? WHERE id = ?",
|
||||||
newNick, userID)
|
newNick, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("change nick: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTopic sets the topic for a channel.
|
// SetTopic sets the topic for a channel.
|
||||||
func (s *Database) SetTopic(
|
func (database *Database) SetTopic(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
channelName, topic string,
|
channelName, topic string,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := database.conn.ExecContext(ctx,
|
||||||
`UPDATE channels SET topic = ?,
|
`UPDATE channels SET topic = ?,
|
||||||
updated_at = ? WHERE name = ?`,
|
updated_at = ? WHERE name = ?`,
|
||||||
topic, time.Now(), channelName)
|
topic, time.Now(), channelName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set topic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes a user and all their data.
|
// DeleteUser removes a user and all their data.
|
||||||
func (s *Database) DeleteUser(
|
func (database *Database) DeleteUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := s.db.ExecContext(
|
_, err := database.conn.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
"DELETE FROM users WHERE id = ?",
|
"DELETE FROM users WHERE id = ?",
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllChannelMembershipsForUser returns channels
|
// GetAllChannelMembershipsForUser returns channels
|
||||||
// a user belongs to.
|
// a user belongs to.
|
||||||
func (s *Database) GetAllChannelMembershipsForUser(
|
func (database *Database) GetAllChannelMembershipsForUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
) ([]ChannelInfo, error) {
|
) ([]ChannelInfo, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT c.id, c.name, c.topic
|
`SELECT c.id, c.name, c.topic
|
||||||
FROM channels c
|
FROM channels c
|
||||||
INNER JOIN channel_members cm
|
INNER JOIN channel_members cm
|
||||||
ON cm.channel_id = c.id
|
ON cm.channel_id = c.id
|
||||||
WHERE cm.user_id = ?`, userID)
|
WHERE cm.user_id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"get memberships: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanChannels(rows)
|
return scanChannels(rows)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package db_test
|
package db_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -13,26 +12,26 @@ import (
|
|||||||
func setupTestDB(t *testing.T) *db.Database {
|
func setupTestDB(t *testing.T) *db.Database {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
d, err := db.NewTestDatabase()
|
database, err := db.NewTestDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
closeErr := d.Close()
|
closeErr := database.Close()
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
t.Logf("close db: %v", closeErr)
|
t.Logf("close db: %v", closeErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return d
|
return database
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateUser(t *testing.T) {
|
func TestCreateUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
id, token, err := database.CreateUser(ctx, "alice")
|
id, token, err := database.CreateUser(ctx, "alice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,7 +52,7 @@ func TestGetUserByToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, token, err := database.CreateUser(ctx, "bob")
|
_, token, err := database.CreateUser(ctx, "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +78,7 @@ func TestGetUserByNick(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, _, err := database.CreateUser(ctx, "charlie")
|
_, _, err := database.CreateUser(ctx, "charlie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,7 +100,7 @@ func TestChannelOperations(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#test")
|
chID, err := database.GetOrCreateChannel(ctx, "#test")
|
||||||
if err != nil || chID == 0 {
|
if err != nil || chID == 0 {
|
||||||
@@ -128,7 +127,7 @@ func TestJoinAndPart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
uid, _, err := database.CreateUser(ctx, "user1")
|
uid, _, err := database.CreateUser(ctx, "user1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -170,7 +169,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(
|
chID, err := database.GetOrCreateChannel(
|
||||||
ctx, "#empty",
|
ctx, "#empty",
|
||||||
@@ -212,7 +211,7 @@ func createUserWithChannels(
|
|||||||
) (int64, int64, int64) {
|
) (int64, int64, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
uid, _, err := database.CreateUser(ctx, nick)
|
uid, _, err := database.CreateUser(ctx, nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -255,7 +254,7 @@ func TestListChannels(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
channels, err := database.ListChannels(
|
channels, err := database.ListChannels(
|
||||||
context.Background(), uid,
|
t.Context(), uid,
|
||||||
)
|
)
|
||||||
if err != nil || len(channels) != 2 {
|
if err != nil || len(channels) != 2 {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
@@ -269,7 +268,7 @@ func TestListAllChannels(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, err := database.GetOrCreateChannel(ctx, "#x")
|
_, err := database.GetOrCreateChannel(ctx, "#x")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -294,7 +293,7 @@ func TestChangeNick(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
uid, token, err := database.CreateUser(ctx, "old")
|
uid, token, err := database.CreateUser(ctx, "old")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -320,7 +319,7 @@ func TestSetTopic(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, err := database.GetOrCreateChannel(
|
_, err := database.GetOrCreateChannel(
|
||||||
ctx, "#topictest",
|
ctx, "#topictest",
|
||||||
@@ -350,11 +349,31 @@ func TestSetTopic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInsertAndPollMessages(t *testing.T) {
|
func TestInsertMessage(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
|
dbID, msgUUID, err := database.InsertMessage(
|
||||||
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbID == 0 || msgUUID == "" {
|
||||||
|
t.Fatal("expected valid id and uuid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
uid, _, err := database.CreateUser(ctx, "poller")
|
uid, _, err := database.CreateUser(ctx, "poller")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -363,11 +382,11 @@ func TestInsertAndPollMessages(t *testing.T) {
|
|||||||
|
|
||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, msgUUID, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil || dbID == 0 || msgUUID == "" {
|
if err != nil {
|
||||||
t.Fatal("insert failed")
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.EnqueueMessage(ctx, uid, dbID)
|
err = database.EnqueueMessage(ctx, uid, dbID)
|
||||||
@@ -415,7 +434,7 @@ func TestGetHistory(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
const msgCount = 10
|
const msgCount = 10
|
||||||
|
|
||||||
@@ -452,7 +471,7 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
uid, _, err := database.CreateUser(ctx, "deleteme")
|
uid, _, err := database.CreateUser(ctx, "deleteme")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -491,7 +510,7 @@ func TestChannelMembers(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
uid1, _, err := database.CreateUser(ctx, "m1")
|
uid1, _, err := database.CreateUser(ctx, "m1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -539,7 +558,7 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) {
|
|||||||
|
|
||||||
channels, err :=
|
channels, err :=
|
||||||
database.GetAllChannelMembershipsForUser(
|
database.GetAllChannelMembershipsForUser(
|
||||||
context.Background(), uid,
|
t.Context(), uid,
|
||||||
)
|
)
|
||||||
if err != nil || len(channels) != 2 {
|
if err != nil || len(channels) != 2 {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,40 +40,59 @@ type Handlers struct {
|
|||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(
|
func New(
|
||||||
lc fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
s := new(Handlers)
|
hdlr := &Handlers{
|
||||||
s.params = ¶ms
|
params: ¶ms,
|
||||||
s.log = params.Logger.Get()
|
log: params.Logger.Get(),
|
||||||
s.hc = params.Healthcheck
|
hc: params.Healthcheck,
|
||||||
s.broker = broker.New()
|
broker: broker.New(),
|
||||||
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return hdlr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (hdlr *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
data any,
|
data any,
|
||||||
status int,
|
status int,
|
||||||
) {
|
) {
|
||||||
w.Header().Set(
|
writer.Header().Set(
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
"application/json; charset=utf-8",
|
"application/json; charset=utf-8",
|
||||||
)
|
)
|
||||||
w.WriteHeader(status)
|
writer.WriteHeader(status)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(w).Encode(data)
|
err := json.NewEncoder(writer).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("json encode error", "error", err)
|
hdlr.log.Error(
|
||||||
|
"json encode error", "error", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) respondError(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
msg string,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
|
hdlr.respondJSON(
|
||||||
|
writer, request,
|
||||||
|
map[string]string{"error": msg},
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
const httpStatusOK = 200
|
const httpStatusOK = 200
|
||||||
|
|
||||||
// HandleHealthCheck returns an HTTP handler for the health check endpoint.
|
// HandleHealthCheck returns an HTTP handler for the health check endpoint.
|
||||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(
|
||||||
resp := s.hc.Healthcheck()
|
writer http.ResponseWriter,
|
||||||
s.respondJSON(w, req, resp, httpStatusOK)
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
resp := hdlr.hc.Healthcheck()
|
||||||
|
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,17 @@ type Healthcheck struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Healthcheck instance.
|
// New creates a new Healthcheck instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
func New(
|
||||||
s := new(Healthcheck)
|
lifecycle fx.Lifecycle, params Params,
|
||||||
s.params = ¶ms
|
) (*Healthcheck, error) {
|
||||||
s.log = params.Logger.Get()
|
hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart
|
||||||
|
params: ¶ms,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
s.StartupTime = time.Now()
|
hcheck.StartupTime = time.Now()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -49,7 +52,7 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return hcheck, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response is the JSON response returned by the health endpoint.
|
// Response is the JSON response returned by the health endpoint.
|
||||||
@@ -64,19 +67,18 @@ type Response struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (s *Healthcheck) Healthcheck() *Response {
|
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||||
resp := &Response{
|
return &Response{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||||
UptimeHuman: s.uptime().String(),
|
UptimeHuman: hcheck.uptime().String(),
|
||||||
Appname: s.params.Globals.Appname,
|
Appname: hcheck.params.Globals.Appname,
|
||||||
Version: s.params.Globals.Version,
|
Version: hcheck.params.Globals.Version,
|
||||||
|
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Healthcheck) uptime() time.Duration {
|
func (hcheck *Healthcheck) uptime() time.Duration {
|
||||||
return time.Since(s.StartupTime)
|
return time.Since(hcheck.StartupTime)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,51 +23,56 @@ type Logger struct {
|
|||||||
params Params
|
params Params
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Logger with appropriate handler based on terminal detection.
|
// New creates a new Logger with appropriate handler
|
||||||
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
// based on terminal detection.
|
||||||
l := new(Logger)
|
func New(
|
||||||
l.level = new(slog.LevelVar)
|
_ fx.Lifecycle, params Params,
|
||||||
l.level.Set(slog.LevelInfo)
|
) (*Logger, error) {
|
||||||
|
logger := new(Logger)
|
||||||
|
logger.level = new(slog.LevelVar)
|
||||||
|
logger.level.Set(slog.LevelInfo)
|
||||||
|
|
||||||
tty := false
|
tty := false
|
||||||
|
|
||||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||||
tty = true
|
tty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var handler slog.Handler
|
opts := &slog.HandlerOptions{ //nolint:exhaustruct // ReplaceAttr optional
|
||||||
if tty {
|
Level: logger.level,
|
||||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
AddSource: true,
|
||||||
Level: l.level,
|
|
||||||
AddSource: true,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
|
||||||
Level: l.level,
|
|
||||||
AddSource: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.log = slog.New(handler)
|
var handler slog.Handler
|
||||||
l.params = params
|
if tty {
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||||
|
} else {
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||||
|
}
|
||||||
|
|
||||||
return l, nil
|
logger.log = slog.New(handler)
|
||||||
|
logger.params = params
|
||||||
|
|
||||||
|
return logger, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableDebugLogging switches the log level to debug.
|
// EnableDebugLogging switches the log level to debug.
|
||||||
func (l *Logger) EnableDebugLogging() {
|
func (logger *Logger) EnableDebugLogging() {
|
||||||
l.level.Set(slog.LevelDebug)
|
logger.level.Set(slog.LevelDebug)
|
||||||
l.log.Debug("debug logging enabled", "debug", true)
|
logger.log.Debug(
|
||||||
|
"debug logging enabled", "debug", true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the underlying slog.Logger.
|
// Get returns the underlying slog.Logger.
|
||||||
func (l *Logger) Get() *slog.Logger {
|
func (logger *Logger) Get() *slog.Logger {
|
||||||
return l.log
|
return logger.log
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify logs the application name and version at startup.
|
// Identify logs the application name and version at startup.
|
||||||
func (l *Logger) Identify() {
|
func (logger *Logger) Identify() {
|
||||||
l.log.Info("starting",
|
logger.log.Info("starting",
|
||||||
"appname", l.params.Globals.Appname,
|
"appname", logger.params.Globals.Appname,
|
||||||
"version", l.params.Globals.Version,
|
"version", logger.params.Globals.Version,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
"github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -38,25 +38,28 @@ type Middleware struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Middleware instance.
|
// New creates a new Middleware instance.
|
||||||
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
func New(
|
||||||
s := new(Middleware)
|
_ fx.Lifecycle, params Params,
|
||||||
s.params = ¶ms
|
) (*Middleware, error) {
|
||||||
s.log = params.Logger.Get()
|
mware := &Middleware{
|
||||||
|
params: ¶ms,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
|
||||||
return s, nil
|
return mware, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipFromHostPort(hp string) string {
|
func ipFromHostPort(hostPort string) string {
|
||||||
h, _, err := net.SplitHostPort(hp)
|
host, _, err := net.SplitHostPort(hostPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(h) > 0 && h[0] == '[' {
|
if len(host) > 0 && host[0] == '[' {
|
||||||
return h[1 : len(h)-1]
|
return host[1 : len(host)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return h
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
@@ -65,9 +68,15 @@ type loggingResponseWriter struct {
|
|||||||
statusCode int
|
statusCode int
|
||||||
}
|
}
|
||||||
|
|
||||||
// newLoggingResponseWriter wraps a ResponseWriter to capture the status code.
|
// newLoggingResponseWriter wraps a ResponseWriter
|
||||||
func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
// to capture the status code.
|
||||||
return &loggingResponseWriter{w, http.StatusOK}
|
func newLoggingResponseWriter(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
) *loggingResponseWriter {
|
||||||
|
return &loggingResponseWriter{
|
||||||
|
ResponseWriter: writer,
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||||
@@ -76,43 +85,57 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Logging returns middleware that logs each HTTP request.
|
// Logging returns middleware that logs each HTTP request.
|
||||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(
|
||||||
start := time.Now()
|
func(
|
||||||
lrw := newLoggingResponseWriter(w)
|
writer http.ResponseWriter,
|
||||||
ctx := r.Context()
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
start := time.Now()
|
||||||
|
lrw := newLoggingResponseWriter(writer)
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
reqID, _ := ctx.Value(
|
||||||
|
chimw.RequestIDKey,
|
||||||
|
).(string)
|
||||||
|
|
||||||
s.log.InfoContext(ctx, "request",
|
mware.log.InfoContext(
|
||||||
"request_start", start,
|
ctx, "request",
|
||||||
"method", r.Method,
|
"request_start", start,
|
||||||
"url", r.URL.String(),
|
"method", request.Method,
|
||||||
"useragent", r.UserAgent(),
|
"url", request.URL.String(),
|
||||||
"request_id", reqID,
|
"useragent", request.UserAgent(),
|
||||||
"referer", r.Referer(),
|
"request_id", reqID,
|
||||||
"proto", r.Proto,
|
"referer", request.Referer(),
|
||||||
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
"proto", request.Proto,
|
||||||
"status", lrw.statusCode,
|
"remoteIP",
|
||||||
"latency_ms", latency.Milliseconds(),
|
ipFromHostPort(request.RemoteAddr),
|
||||||
)
|
"status", lrw.statusCode,
|
||||||
}()
|
"latency_ms",
|
||||||
|
latency.Milliseconds(),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(lrw, r)
|
next.ServeHTTP(lrw, request)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||||
|
},
|
||||||
|
AllowedHeaders: []string{
|
||||||
|
"Accept", "Authorization",
|
||||||
|
"Content-Type", "X-CSRF-Token",
|
||||||
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
@@ -120,28 +143,34 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns middleware that performs authentication.
|
// Auth returns middleware that performs authentication.
|
||||||
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(
|
||||||
s.log.Info("AUTH: before request")
|
func(
|
||||||
next.ServeHTTP(w, r)
|
writer http.ResponseWriter,
|
||||||
})
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
mware.log.Info("AUTH: before request")
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
mdlw := ghmm.New(ghmm.Config{
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
Recorder: metrics.NewRecorder(
|
||||||
|
metrics.Config{}, //nolint:exhaustruct // defaults
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return std.Handler("", mdlw, next)
|
return std.Handler("", metricsMiddleware, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricsAuth returns middleware that protects metrics with basic auth.
|
// MetricsAuth returns middleware that protects metrics with basic auth.
|
||||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
return basicauth.New(
|
return basicauth.New(
|
||||||
"metrics",
|
"metrics",
|
||||||
map[string][]string{
|
map[string][]string{
|
||||||
|
|||||||
@@ -17,67 +17,94 @@ import (
|
|||||||
const routeTimeout = 60 * time.Second
|
const routeTimeout = 60 * time.Second
|
||||||
|
|
||||||
// SetupRoutes configures the HTTP routes and middleware.
|
// SetupRoutes configures the HTTP routes and middleware.
|
||||||
func (s *Server) SetupRoutes() {
|
func (srv *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
srv.router = chi.NewRouter()
|
||||||
|
|
||||||
s.router.Use(middleware.Recoverer)
|
srv.router.Use(middleware.Recoverer)
|
||||||
s.router.Use(middleware.RequestID)
|
srv.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.Logging())
|
srv.router.Use(srv.mw.Logging())
|
||||||
|
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
s.router.Use(s.mw.Metrics())
|
srv.router.Use(srv.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(s.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(
|
||||||
Repanic: true,
|
sentryhttp.Options{ //nolint:exhaustruct // optional fields
|
||||||
})
|
Repanic: true,
|
||||||
s.router.Use(sentryHandler.Handle)
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
srv.router.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check.
|
||||||
s.router.Get(
|
srv.router.Get(
|
||||||
"/.well-known/healthcheck.json",
|
"/.well-known/healthcheck.json",
|
||||||
s.h.HandleHealthCheck(),
|
srv.handlers.HandleHealthCheck(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Protected metrics endpoint
|
// Protected metrics endpoint.
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
s.router.Group(func(r chi.Router) {
|
srv.router.Group(func(router chi.Router) {
|
||||||
r.Use(s.mw.MetricsAuth())
|
router.Use(srv.mw.MetricsAuth())
|
||||||
r.Get("/metrics",
|
router.Get("/metrics",
|
||||||
http.HandlerFunc(
|
http.HandlerFunc(
|
||||||
promhttp.Handler().ServeHTTP,
|
promhttp.Handler().ServeHTTP,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// API v1
|
// API v1.
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
srv.router.Route(
|
||||||
r.Get("/server", s.h.HandleServerInfo())
|
"/api/v1",
|
||||||
r.Post("/session", s.h.HandleCreateSession())
|
func(router chi.Router) {
|
||||||
r.Get("/state", s.h.HandleState())
|
router.Get(
|
||||||
r.Get("/messages", s.h.HandleGetMessages())
|
"/server",
|
||||||
r.Post("/messages", s.h.HandleSendCommand())
|
srv.handlers.HandleServerInfo(),
|
||||||
r.Get("/history", s.h.HandleGetHistory())
|
)
|
||||||
r.Get("/channels", s.h.HandleListAllChannels())
|
router.Post(
|
||||||
r.Get(
|
"/session",
|
||||||
"/channels/{channel}/members",
|
srv.handlers.HandleCreateSession(),
|
||||||
s.h.HandleChannelMembers(),
|
)
|
||||||
)
|
router.Get(
|
||||||
})
|
"/state",
|
||||||
|
srv.handlers.HandleState(),
|
||||||
|
)
|
||||||
|
router.Get(
|
||||||
|
"/messages",
|
||||||
|
srv.handlers.HandleGetMessages(),
|
||||||
|
)
|
||||||
|
router.Post(
|
||||||
|
"/messages",
|
||||||
|
srv.handlers.HandleSendCommand(),
|
||||||
|
)
|
||||||
|
router.Get(
|
||||||
|
"/history",
|
||||||
|
srv.handlers.HandleGetHistory(),
|
||||||
|
)
|
||||||
|
router.Get(
|
||||||
|
"/channels",
|
||||||
|
srv.handlers.HandleListAllChannels(),
|
||||||
|
)
|
||||||
|
router.Get(
|
||||||
|
"/channels/{channel}/members",
|
||||||
|
srv.handlers.HandleChannelMembers(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Serve embedded SPA
|
// Serve embedded SPA.
|
||||||
s.setupSPA()
|
srv.setupSPA()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupSPA() {
|
func (srv *Server) setupSPA() {
|
||||||
distFS, err := fs.Sub(web.Dist, "dist")
|
distFS, err := fs.Sub(web.Dist, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error(
|
srv.log.Error(
|
||||||
"failed to get web dist filesystem",
|
"failed to get web dist filesystem",
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
@@ -87,38 +114,40 @@ func (s *Server) setupSPA() {
|
|||||||
|
|
||||||
fileServer := http.FileServer(http.FS(distFS))
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
|
|
||||||
s.router.Get("/*", func(
|
srv.router.Get("/*", func(
|
||||||
w http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
r *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
readFS, ok := distFS.(fs.ReadFileFS)
|
readFS, ok := distFS.(fs.ReadFileFS)
|
||||||
if !ok {
|
if !ok {
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
f, readErr := readFS.ReadFile(r.URL.Path[1:])
|
fileData, readErr := readFS.ReadFile(
|
||||||
if readErr != nil || len(f) == 0 {
|
request.URL.Path[1:],
|
||||||
|
)
|
||||||
|
if readErr != nil || len(fileData) == 0 {
|
||||||
indexHTML, indexErr := readFS.ReadFile(
|
indexHTML, indexErr := readFS.ReadFile(
|
||||||
"index.html",
|
"index.html",
|
||||||
)
|
)
|
||||||
if indexErr != nil {
|
if indexErr != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set(
|
writer.Header().Set(
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
)
|
)
|
||||||
w.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(indexHTML)
|
_, _ = writer.Write(indexHTML)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(writer, request)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ type Params struct {
|
|||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main HTTP server. It manages routing, middleware, and lifecycle.
|
// Server is the main HTTP server.
|
||||||
|
// It manages routing, middleware, and lifecycle.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
@@ -53,21 +54,24 @@ type Server struct {
|
|||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
params Params
|
params Params
|
||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
h *handlers.Handlers
|
handlers *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Server and registers its lifecycle hooks.
|
// New creates a new Server and registers its lifecycle hooks.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
func New(
|
||||||
s := new(Server)
|
lifecycle fx.Lifecycle, params Params,
|
||||||
s.params = params
|
) (*Server, error) {
|
||||||
s.mw = params.Middleware
|
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle
|
||||||
s.h = params.Handlers
|
params: params,
|
||||||
s.log = params.Logger.Get()
|
mw: params.Middleware,
|
||||||
|
handlers: params.Handlers,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
s.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
go s.Run() //nolint:contextcheck
|
go srv.Run() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -76,122 +80,140 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
// Run starts the server configuration, Sentry, and begins serving.
|
||||||
func (s *Server) Run() {
|
func (srv *Server) Run() {
|
||||||
s.configure()
|
srv.configure()
|
||||||
s.enableSentry()
|
srv.enableSentry()
|
||||||
s.serve()
|
srv.serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP delegates to the chi router.
|
// ServeHTTP delegates to the chi router.
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (srv *Server) ServeHTTP(
|
||||||
s.router.ServeHTTP(w, r)
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
srv.router.ServeHTTP(writer, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaintenanceMode reports whether the server is in maintenance mode.
|
// MaintenanceMode reports whether the server is in maintenance mode.
|
||||||
func (s *Server) MaintenanceMode() bool {
|
func (srv *Server) MaintenanceMode() bool {
|
||||||
return s.params.Config.MaintenanceMode
|
return srv.params.Config.MaintenanceMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) enableSentry() {
|
func (srv *Server) enableSentry() {
|
||||||
s.sentryEnabled = false
|
srv.sentryEnabled = false
|
||||||
|
|
||||||
if s.params.Config.SentryDSN == "" {
|
if srv.params.Config.SentryDSN == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{
|
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields
|
||||||
Dsn: s.params.Config.SentryDSN,
|
Dsn: srv.params.Config.SentryDSN,
|
||||||
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
Release: fmt.Sprintf(
|
||||||
|
"%s-%s",
|
||||||
|
srv.params.Globals.Appname,
|
||||||
|
srv.params.Globals.Version,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("sentry init failure", "error", err)
|
srv.log.Error("sentry init failure", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("sentry error reporting activated")
|
srv.log.Info("sentry error reporting activated")
|
||||||
s.sentryEnabled = true
|
srv.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serve() int {
|
func (srv *Server) serve() int {
|
||||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
srv.ctx, srv.cancelFunc = context.WithCancel(
|
||||||
|
context.Background(),
|
||||||
|
)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
c := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
sig := <-c
|
|
||||||
s.log.Info("signal received", "signal", sig)
|
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
sig := <-sigCh
|
||||||
s.cancelFunc()
|
|
||||||
|
srv.log.Info("signal received", "signal", sig)
|
||||||
|
|
||||||
|
if srv.cancelFunc != nil {
|
||||||
|
srv.cancelFunc()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go s.serveUntilShutdown()
|
go srv.serveUntilShutdown()
|
||||||
|
|
||||||
<-s.ctx.Done()
|
<-srv.ctx.Done()
|
||||||
|
|
||||||
s.cleanShutdown()
|
srv.cleanShutdown()
|
||||||
|
|
||||||
return s.exitCode
|
return srv.exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) cleanupForExit() {
|
func (srv *Server) cleanupForExit() {
|
||||||
s.log.Info("cleaning up")
|
srv.log.Info("cleaning up")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) cleanShutdown() {
|
func (srv *Server) cleanShutdown() {
|
||||||
s.exitCode = 0
|
srv.exitCode = 0
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
context.Background(), shutdownTimeout,
|
context.Background(), shutdownTimeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.httpServer.Shutdown(ctxShutdown)
|
err := srv.httpServer.Shutdown(ctxShutdown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("server clean shutdown failed", "error", err)
|
srv.log.Error(
|
||||||
|
"server clean shutdown failed", "error", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if shutdownCancel != nil {
|
if shutdownCancel != nil {
|
||||||
shutdownCancel()
|
shutdownCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cleanupForExit()
|
srv.cleanupForExit()
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
sentry.Flush(sentryFlushTime)
|
sentry.Flush(sentryFlushTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) configure() {
|
func (srv *Server) configure() {
|
||||||
// server configuration placeholder
|
// Server configuration placeholder.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serveUntilShutdown() {
|
func (srv *Server) serveUntilShutdown() {
|
||||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
listenAddr := fmt.Sprintf(
|
||||||
s.httpServer = &http.Server{
|
":%d", srv.params.Config.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
ReadTimeout: httpReadTimeout,
|
ReadTimeout: httpReadTimeout,
|
||||||
WriteTimeout: httpWriteTimeout,
|
WriteTimeout: httpWriteTimeout,
|
||||||
MaxHeaderBytes: maxHeaderBytes,
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
Handler: s,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.SetupRoutes()
|
srv.SetupRoutes()
|
||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
srv.log.Info(
|
||||||
|
"http begin listen", "listenaddr", listenAddr,
|
||||||
|
)
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := srv.httpServer.ListenAndServe()
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("listen error", "error", err)
|
srv.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if srv.cancelFunc != nil {
|
||||||
s.cancelFunc()
|
srv.cancelFunc()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user