22 Commits

Author SHA1 Message Date
user
0b132cfbf4 fix: replace conflicting migration 003 with no-op
Migration 003 tried to recreate tables (users, channel_members, messages)
with INTEGER IDs, conflicting with 002_schema.sql which already defines
them with TEXT UUIDs. This caused all tests to fail.
2026-02-20 03:31:02 -08:00
user
a9586eb95f fix: resolve funcorder and noinlineerr issues 2026-02-20 03:30:01 -08:00
user
2f6d1f284c fix: resolve cyclop, funlen issues by extracting helper methods 2026-02-20 03:29:18 -08:00
user
037202280b fix: resolve nestif issues by extracting helper methods 2026-02-20 03:26:46 -08:00
user
e0da78f17c fix: resolve wsl_v5, lll, noinlineerr, and gosec lint issues 2026-02-20 03:26:06 -08:00
user
db3b0bfee1 fix: resolve ireturn lint issues 2026-02-20 03:22:32 -08:00
user
a3c26c415e fix: resolve funcorder lint issues 2026-02-20 03:22:03 -08:00
user
2c89b23bea fix: resolve forcetypeassert lint issues 2026-02-20 03:21:14 -08:00
user
0be5e80b85 fix: resolve rowserrcheck lint issues 2026-02-20 03:21:02 -08:00
user
f6ca154315 fix: resolve mnd lint issues with named constants 2026-02-20 03:20:26 -08:00
user
c6c5aaf48e fix: resolve noctx lint issues 2026-02-20 03:19:19 -08:00
user
4fe5227cbf fix: resolve err113 lint issues with sentinel errors 2026-02-20 03:18:56 -08:00
user
dd5e9e61ab fix: resolve errcheck lint issues 2026-02-20 03:17:50 -08:00
user
3d968a1102 fix: resolve noinlineerr lint issues 2026-02-20 03:17:09 -08:00
user
f125a3f591 fix: resolve revive lint issues 2026-02-20 03:16:09 -08:00
user
5fad27ff4c fix: resolve lll lint issues 2026-02-20 03:15:37 -08:00
user
6ca3ad0e99 fix: resolve tagliatelle lint issues 2026-02-20 03:15:25 -08:00
user
d8c63640f5 fix: resolve wastedassign and gosmopolitan lint issues 2026-02-20 03:15:10 -08:00
user
3adc5479b7 fix: resolve wsl_v5 lint issues 2026-02-20 03:14:46 -08:00
clawbot
ed96c6ccde fix: format cmd/chat-cli/api/client.go with gofmt 2026-02-20 03:02:32 -08:00
clawbot
c1040ff69d fix: resolve nlreturn, modernize, perfsprint, wsl_v5, and partial err113 lint issues 2026-02-20 02:59:15 -08:00
clawbot
c65c9bbe5a fix: resolve typecheck errors by removing duplicate db methods and updating handlers to use models-based API 2026-02-20 02:57:16 -08:00
40 changed files with 2727 additions and 6460 deletions

View File

@@ -1,9 +1,8 @@
.git
*.md
!README.md
bin/
chatd
chat-cli
data.db
data.db-wal
data.db-shm
.env
.git
*.test
*.out
debug.log

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -1,9 +0,0 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

30
.gitignore vendored
View File

@@ -1,28 +1,7 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build artifacts
/chatd
/bin/
data.db
.env
*.exe
*.dll
*.so
@@ -30,9 +9,6 @@ node_modules/
*.test
*.out
vendor/
# Project
data.db
debug.log
/chat-cli
web/node_modules/
chat-cli

View File

@@ -7,35 +7,26 @@ run:
linters:
default: all
disable:
- exhaustruct
- depguard
- godot
- wsl
- wsl_v5
- wrapcheck
- varnamelen
- noinlineerr
- dupl
- paralleltest
- nlreturn
- tagliatelle
- goconst
- funlen
- maintidx
- cyclop
- gocognit
- lll
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,29 +1,22 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make gcc musl-dev
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run tests
ENV DBURL="file::memory:?cache=shared"
RUN go test ./...
ARG VERSION=dev
RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd
# Build binaries
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# Final stage — server only
FROM alpine:3.21
RUN apk add --no-cache ca-certificates \
&& addgroup -S chat && adduser -S chat -G chat
RUN apk add --no-cache ca-certificates
COPY --from=builder /chatd /usr/local/bin/chatd
USER chat
WORKDIR /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["chatd"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 sneak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,20 +1,43 @@
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.Version=$(VERSION)"
.PHONY: all build lint fmt test check clean run debug
.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:
go build $(LDFLAGS) -o chatd ./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
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
lint:
GOFLAGS=-buildvcs=false golangci-lint run ./...
golangci-lint run --config .golangci.yml ./...
docker:
docker build -t chat:$(VERSION) .
fmt:
gofmt -s -w .
goimports -w .
test:
go test -v -race -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build — fails if anything is wrong
check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@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

2308
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,182 +0,0 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

View File

@@ -1,4 +1,5 @@
package chatapi
// Package api provides the HTTP client for the chat server API.
package api
import (
"bytes"
@@ -10,17 +11,24 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
httpTimeout = 30 * time.Second
pollExtraTime = 5
httpErrThreshold = 400
// httpClientTimeout is the default HTTP client timeout in seconds.
httpClientTimeout = 30
// httpStatusErrorThreshold is the minimum status code considered an error.
httpStatusErrorThreshold = 400
// pollTimeoutBuffer is extra seconds added to HTTP timeout beyond the poll timeout.
pollTimeoutBuffer = 5
)
var errHTTP = errors.New("HTTP error")
var (
// ErrHTTPStatus is returned when the server responds with an error status code.
ErrHTTPStatus = errors.New("HTTP error")
// ErrUnexpectedFormat is returned when the response format is unexpected.
ErrUnexpectedFormat = errors.New("unexpected format")
)
// Client wraps HTTP calls to the chat server API.
type Client struct {
@@ -32,20 +40,16 @@ type Client struct {
// NewClient creates a new API client.
func NewClient(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{Timeout: httpTimeout},
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: httpClientTimeout * time.Second,
},
}
}
// CreateSession creates a new session on the server.
func (c *Client) CreateSession(
nick string,
) (*SessionResponse, error) {
data, err := c.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick},
)
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
if err != nil {
return nil, err
}
@@ -64,9 +68,7 @@ func (c *Client) CreateSession(
// GetState returns the current user state.
func (c *Client) GetState() (*StateResponse, error) {
data, err := c.do(
http.MethodGet, "/api/v1/state", nil,
)
data, err := c.do("GET", "/api/v1/state", nil)
if err != nil {
return nil, err
}
@@ -83,49 +85,36 @@ func (c *Client) GetState() (*StateResponse, error) {
// SendMessage sends a message (any IRC command).
func (c *Client) SendMessage(msg *Message) error {
_, err := c.do(
http.MethodPost, "/api/v1/messages", msg,
)
_, err := c.do("POST", "/api/v1/messages", msg)
return err
}
// PollMessages long-polls for new messages.
func (c *Client) PollMessages(
afterID int64,
timeout int,
) (*PollResult, error) {
client := &http.Client{
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
}
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
// Use a longer HTTP timeout than the server long-poll timeout.
client := &http.Client{Timeout: time.Duration(timeout+pollTimeoutBuffer) * time.Second}
params := url.Values{}
if afterID > 0 {
params.Set(
"after",
strconv.FormatInt(afterID, 10),
)
if afterID != "" {
params.Set("after", afterID)
}
params.Set("timeout", strconv.Itoa(timeout))
path := "/api/v1/messages?" + params.Encode()
path := "/api/v1/messages"
if len(params) > 0 {
path += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
c.BaseURL+path,
nil,
)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := client.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
resp, err := client.Do(req) //nolint:gosec // G704: BaseURL is set by user at connect time, not tainted input
if err != nil {
return nil, err
}
@@ -137,47 +126,42 @@ func (c *Client) PollMessages(
return nil, err
}
if resp.StatusCode >= httpErrThreshold {
return nil, fmt.Errorf(
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
if resp.StatusCode >= httpStatusErrorThreshold {
return nil, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data))
}
var wrapped MessagesResponse
// The server may return an array directly or wrapped.
var msgs []Message
err = json.Unmarshal(data, &wrapped)
err = json.Unmarshal(data, &msgs)
if err != nil {
return nil, fmt.Errorf(
"decode messages: %w", err,
)
// Try wrapped format.
var wrapped MessagesResponse
err2 := json.Unmarshal(data, &wrapped)
if err2 != nil {
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
}
msgs = wrapped.Messages
}
return &PollResult{
Messages: wrapped.Messages,
LastID: wrapped.LastID,
}, nil
return msgs, nil
}
// JoinChannel joins a channel.
// JoinChannel joins a channel via the unified command endpoint.
func (c *Client) JoinChannel(channel string) error {
return c.SendMessage(
&Message{Command: "JOIN", To: channel},
)
return c.SendMessage(&Message{Command: "JOIN", To: channel})
}
// PartChannel leaves a channel.
// PartChannel leaves a channel via the unified command endpoint.
func (c *Client) PartChannel(channel string) error {
return c.SendMessage(
&Message{Command: "PART", To: channel},
)
return c.SendMessage(&Message{Command: "PART", To: channel})
}
// ListChannels returns all channels on the server.
func (c *Client) ListChannels() ([]Channel, error) {
data, err := c.do(
http.MethodGet, "/api/v1/channels", nil,
)
data, err := c.do("GET", "/api/v1/channels", nil)
if err != nil {
return nil, err
}
@@ -193,17 +177,8 @@ func (c *Client) ListChannels() ([]Channel, error) {
}
// GetMembers returns members of a channel.
func (c *Client) GetMembers(
channel string,
) ([]string, error) {
name := strings.TrimPrefix(channel, "#")
data, err := c.do(
http.MethodGet,
"/api/v1/channels/"+url.PathEscape(name)+
"/members",
nil,
)
func (c *Client) GetMembers(channel string) ([]string, error) {
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
if err != nil {
return nil, err
}
@@ -212,9 +187,15 @@ func (c *Client) GetMembers(
err = json.Unmarshal(data, &members)
if err != nil {
return nil, fmt.Errorf(
"unexpected members format: %w", err,
)
// Try object format.
var obj map[string]any
err2 := json.Unmarshal(data, &obj)
if err2 != nil {
return nil, err
}
// Extract member names from whatever format.
return nil, fmt.Errorf("%w: members: %s", ErrUnexpectedFormat, string(data))
}
return members, nil
@@ -222,9 +203,7 @@ func (c *Client) GetMembers(
// GetServerInfo returns server info.
func (c *Client) GetServerInfo() (*ServerInfo, error) {
data, err := c.do(
http.MethodGet, "/api/v1/server", nil,
)
data, err := c.do("GET", "/api/v1/server", nil)
if err != nil {
return nil, err
}
@@ -239,10 +218,7 @@ func (c *Client) GetServerInfo() (*ServerInfo, error) {
return &info, nil
}
func (c *Client) do(
method, path string,
body any,
) ([]byte, error) {
func (c *Client) do(method, path string, body any) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
@@ -254,12 +230,7 @@ func (c *Client) do(
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(
context.Background(),
method,
c.BaseURL+path,
bodyReader,
)
req, err := http.NewRequestWithContext(context.Background(), method, c.BaseURL+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
@@ -267,12 +238,10 @@ func (c *Client) do(
req.Header.Set("Content-Type", "application/json")
if c.Token != "" {
req.Header.Set(
"Authorization", "Bearer "+c.Token,
)
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL constructed from trusted base URL
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
@@ -284,11 +253,8 @@ func (c *Client) do(
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= httpErrThreshold {
return data, fmt.Errorf(
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
if resp.StatusCode >= httpStatusErrorThreshold {
return data, fmt.Errorf("%w: %d: %s", ErrHTTPStatus, resp.StatusCode, string(data))
}
return data, nil

View File

@@ -1,5 +1,4 @@
// Package chatapi provides API types and client for chat-cli.
package chatapi
package api //nolint:revive // package name "api" is conventional for API client packages
import "time"
@@ -8,25 +7,27 @@ type SessionRequest struct {
Nick string `json:"nick"`
}
// SessionResponse is the response from session creation.
// SessionResponse is the response from POST /api/v1/session.
type SessionResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
SessionID string `json:"sessionId"`
ClientID string `json:"clientId"`
Nick string `json:"nick"`
Token string `json:"token"`
}
// StateResponse is the response from GET /api/v1/state.
type StateResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []string `json:"channels"`
SessionID string `json:"sessionId"`
ClientID string `json:"clientId"`
Nick string `json:"nick"`
Channels []string `json:"channels"`
}
// Message represents a chat message envelope.
type Message struct {
Command string `json:"command"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Command string `json:"command"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Params []string `json:"params,omitempty"`
Body any `json:"body,omitempty"`
ID string `json:"id,omitempty"`
@@ -34,12 +35,11 @@ type Message struct {
Meta any `json:"meta,omitempty"`
}
// BodyLines returns the body as a string slice.
// BodyLines returns the body as a slice of strings (for text messages).
func (m *Message) BodyLines() []string {
switch v := m.Body.(type) {
case []any:
lines := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
lines = append(lines, s)
@@ -72,13 +72,6 @@ type ServerInfo struct {
// MessagesResponse wraps polling results.
type MessagesResponse struct {
Messages []Message `json:"messages"`
LastID int64 `json:"lastId"`
}
// PollResult wraps the poll response including the cursor.
type PollResult struct {
Messages []Message
LastID int64
}
// ParseTS parses the message timestamp.

View File

@@ -1,4 +1,4 @@
// Package main is the entry point for the chat-cli client.
// Package main implements the chat-cli terminal client.
package main
import (
@@ -8,14 +8,16 @@ import (
"sync"
"time"
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
"git.eeqj.de/sneak/chat/cmd/chat-cli/api"
)
const (
splitParts = 2
pollTimeout = 15
pollRetry = 2 * time.Second
timeFormat = "15:04"
// splitParts is the number of parts to split a command into (command + args).
splitParts = 2
// pollTimeout is the long-poll timeout in seconds.
pollTimeout = 15
// pollRetryDelay is the delay before retrying a failed poll.
pollRetryDelay = 2 * time.Second
)
// App holds the application state.
@@ -25,9 +27,9 @@ type App struct {
mu sync.Mutex
nick string
target string
target string // current target (#channel or nick for DM)
connected bool
lastQID int64
lastMsgID string
stopPoll chan struct{}
}
@@ -40,14 +42,8 @@ func main() {
app.ui.OnInput(app.handleInput)
app.ui.SetStatus(app.nick, "", "disconnected")
app.ui.AddStatus(
"Welcome to chat-cli — an IRC-style client",
)
app.ui.AddStatus(
"Type [yellow]/connect <server-url>" +
"[white] to begin, " +
"or [yellow]/help[white] for commands",
)
app.ui.AddStatus("Welcome to chat-cli — an IRC-style client")
app.ui.AddStatus("Type [yellow]/connect <server-url>[white] to begin, or [yellow]/help[white] for commands")
err := app.ui.Run()
if err != nil {
@@ -63,24 +59,20 @@ func (a *App) handleInput(text string) {
return
}
// Plain text → PRIVMSG to current target.
a.mu.Lock()
target := a.target
connected := a.connected
a.mu.Unlock()
if !connected {
a.ui.AddStatus(
"[red]Not connected. Use /connect <url>",
)
a.ui.AddStatus("[red]Not connected. Use /connect <url>")
return
}
if target == "" {
a.ui.AddStatus(
"[red]No target. " +
"Use /join #channel or /query nick",
)
a.ui.AddStatus("[red]No target. Use /join #channel or /query nick")
return
}
@@ -91,23 +83,36 @@ func (a *App) handleInput(text string) {
Body: []string{text},
})
if err != nil {
a.ui.AddStatus(
"[red]Send error: " + err.Error(),
)
a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err))
return
}
ts := time.Now().Format(timeFormat)
// Echo locally.
ts := time.Now().Format("15:04")
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
}
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, nick, text,
))
func (a *App) commandHandlers() map[string]func(string) {
return map[string]func(string){
"/connect": a.cmdConnect,
"/nick": a.cmdNick,
"/join": a.cmdJoin,
"/part": a.cmdPart,
"/msg": a.cmdMsg,
"/query": a.cmdQuery,
"/topic": a.cmdTopic,
"/names": func(_ string) { a.cmdNames() },
"/list": func(_ string) { a.cmdList() },
"/window": a.cmdWindow,
"/w": a.cmdWindow,
"/quit": func(_ string) { a.cmdQuit() },
"/help": func(_ string) { a.cmdHelp() },
}
}
func (a *App) handleCommand(text string) {
@@ -119,54 +124,23 @@ func (a *App) handleCommand(text string) {
args = parts[1]
}
a.dispatchCommand(cmd, args)
}
func (a *App) dispatchCommand(cmd, args string) {
switch cmd {
case "/connect":
a.cmdConnect(args)
case "/nick":
a.cmdNick(args)
case "/join":
a.cmdJoin(args)
case "/part":
a.cmdPart(args)
case "/msg":
a.cmdMsg(args)
case "/query":
a.cmdQuery(args)
case "/topic":
a.cmdTopic(args)
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/window", "/w":
a.cmdWindow(args)
case "/quit":
a.cmdQuit()
case "/help":
a.cmdHelp()
default:
a.ui.AddStatus(
"[red]Unknown command: " + cmd,
)
if handler, ok := a.commandHandlers()[cmd]; ok {
handler(args)
} else {
a.ui.AddStatus("[red]Unknown command: " + cmd)
}
}
func (a *App) cmdConnect(serverURL string) {
if serverURL == "" {
a.ui.AddStatus(
"[red]Usage: /connect <server-url>",
)
a.ui.AddStatus("[red]Usage: /connect <server-url>")
return
}
serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + serverURL + "...")
a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL))
a.mu.Lock()
nick := a.nick
@@ -176,9 +150,7 @@ func (a *App) cmdConnect(serverURL string) {
resp, err := client.CreateSession(nick)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Connection failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err))
return
}
@@ -187,25 +159,20 @@ func (a *App) cmdConnect(serverURL string) {
a.client = client
a.nick = resp.Nick
a.connected = true
a.lastQID = 0
a.lastMsgID = ""
a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf(
"[green]Connected! Nick: %s, Session: %d",
resp.Nick, resp.ID,
))
a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID))
a.ui.SetStatus(resp.Nick, "", "connected")
// Start polling.
a.stopPoll = make(chan struct{})
go a.pollLoop()
}
func (a *App) cmdNick(nick string) {
if nick == "" {
a.ui.AddStatus(
"[red]Usage: /nick <name>",
)
a.ui.AddStatus("[red]Usage: /nick <name>")
return
}
@@ -218,11 +185,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Lock()
a.nick = nick
a.mu.Unlock()
a.ui.AddStatus(
"Nick set to " + nick +
" (will be used on connect)",
)
a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick))
return
}
@@ -232,9 +195,7 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick},
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Nick change failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err))
return
}
@@ -243,16 +204,13 @@ func (a *App) cmdNick(nick string) {
a.nick = nick
target := a.target
a.mu.Unlock()
a.ui.SetStatus(nick, target, "connected")
a.ui.AddStatus("Nick changed to " + nick)
}
func (a *App) cmdJoin(channel string) {
if channel == "" {
a.ui.AddStatus(
"[red]Usage: /join #channel",
)
a.ui.AddStatus("[red]Usage: /join #channel")
return
}
@@ -273,9 +231,7 @@ func (a *App) cmdJoin(channel string) {
err := a.client.JoinChannel(channel)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Join failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err))
return
}
@@ -286,9 +242,7 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock()
a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.AddLine(channel, "[yellow]*** Joined "+channel)
a.ui.SetStatus(nick, channel, "connected")
}
@@ -301,8 +255,7 @@ func (a *App) cmdPart(channel string) {
connected := a.connected
a.mu.Unlock()
if channel == "" ||
!strings.HasPrefix(channel, "#") {
if channel == "" || !strings.HasPrefix(channel, "#") {
a.ui.AddStatus("[red]No channel to part")
return
@@ -316,16 +269,12 @@ func (a *App) cmdPart(channel string) {
err := a.client.PartChannel(channel)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Part failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err))
return
}
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.ui.AddLine(channel, "[yellow]*** Left "+channel)
a.mu.Lock()
if a.target == channel {
@@ -342,9 +291,7 @@ func (a *App) cmdPart(channel string) {
func (a *App) cmdMsg(args string) {
parts := strings.SplitN(args, " ", splitParts)
if len(parts) < splitParts {
a.ui.AddStatus(
"[red]Usage: /msg <nick> <text>",
)
a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
return
}
@@ -368,26 +315,18 @@ func (a *App) cmdMsg(args string) {
Body: []string{text},
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err))
return
}
ts := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, nick, text,
))
ts := time.Now().Format("15:04")
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
}
func (a *App) cmdQuery(nick string) {
if nick == "" {
a.ui.AddStatus(
"[red]Usage: /query <nick>",
)
a.ui.AddStatus("[red]Usage: /query <nick>")
return
}
@@ -420,14 +359,13 @@ func (a *App) cmdTopic(args string) {
}
if args == "" {
// Query topic.
err := a.client.SendMessage(&api.Message{
Command: "TOPIC",
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err))
}
return
@@ -439,9 +377,7 @@ func (a *App) cmdTopic(args string) {
Body: []string{args},
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic set failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err))
}
}
@@ -465,17 +401,12 @@ func (a *App) cmdNames() {
members, err := a.client.GetMembers(target)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Names failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err))
return
}
a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s",
target, strings.Join(members, " "),
))
a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " ")))
}
func (a *App) cmdList() {
@@ -491,9 +422,7 @@ func (a *App) cmdList() {
channels, err := a.client.ListChannels()
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]List failed: %v", err,
))
a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err))
return
}
@@ -501,10 +430,7 @@ func (a *App) cmdList() {
a.ui.AddStatus("[cyan]*** Channel list:")
for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf(
" %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
))
a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic))
}
a.ui.AddStatus("[cyan]*** End of channel list")
@@ -512,33 +438,27 @@ func (a *App) cmdList() {
func (a *App) cmdWindow(args string) {
if args == "" {
a.ui.AddStatus(
"[red]Usage: /window <number>",
)
a.ui.AddStatus("[red]Usage: /window <number>")
return
}
var n int
n := 0
_, _ = fmt.Sscanf(args, "%d", &n)
a.ui.SwitchBuffer(n)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
if n >= 0 && n < a.ui.BufferCount() {
// Update target based on buffer.
if n < a.ui.BufferCount() {
buf := a.ui.buffers[n]
if buf.Name != "(status)" {
a.mu.Lock()
a.target = buf.Name
a.mu.Unlock()
a.ui.SetStatus(
nick, buf.Name, "connected",
)
a.ui.SetStatus(nick, buf.Name, "connected")
} else {
a.ui.SetStatus(nick, "", "connected")
}
@@ -547,17 +467,13 @@ func (a *App) cmdWindow(args string) {
func (a *App) cmdQuit() {
a.mu.Lock()
if a.connected && a.client != nil {
_ = a.client.SendMessage(
&api.Message{Command: "QUIT"},
)
_ = a.client.SendMessage(&api.Message{Command: "QUIT"})
}
if a.stopPoll != nil {
close(a.stopPoll)
}
a.mu.Unlock()
a.ui.Stop()
}
@@ -574,12 +490,11 @@ func (a *App) cmdHelp() {
" /topic [text] — View/set topic",
" /names — List channel members",
" /list — List channels",
" /window <n> — Switch buffer",
" /window <n> — Switch buffer (Alt+0-9)",
" /quit — Disconnect and exit",
" /help — This help",
" Plain text sends to current target.",
}
for _, line := range help {
a.ui.AddStatus(line)
}
@@ -596,36 +511,45 @@ func (a *App) pollLoop() {
a.mu.Lock()
client := a.client
lastQID := a.lastQID
lastID := a.lastMsgID
a.mu.Unlock()
if client == nil {
return
}
result, err := client.PollMessages(
lastQID, pollTimeout,
)
msgs, err := client.PollMessages(lastID, pollTimeout)
if err != nil {
time.Sleep(pollRetry)
// Transient error — retry after delay.
time.Sleep(pollRetryDelay)
continue
}
if result.LastID > 0 {
a.mu.Lock()
a.lastQID = result.LastID
a.mu.Unlock()
}
for _, msg := range msgs {
a.handleServerMessage(&msg)
for i := range result.Messages {
a.handleServerMessage(&result.Messages[i])
if msg.ID != "" {
a.mu.Lock()
a.lastMsgID = msg.ID
a.mu.Unlock()
}
}
}
}
func (a *App) messageTimestamp(msg *api.Message) string {
if msg.TS != "" {
t := msg.ParseTS()
return t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally
}
return time.Now().Format("15:04")
}
func (a *App) handleServerMessage(msg *api.Message) {
ts := a.formatTS(msg)
ts := a.messageTimestamp(msg)
a.mu.Lock()
myNick := a.nick
@@ -633,35 +557,25 @@ func (a *App) handleServerMessage(msg *api.Message) {
switch msg.Command {
case "PRIVMSG":
a.handlePrivmsgEvent(msg, ts, myNick)
a.handleMsgPrivmsg(msg, ts, myNick)
case "JOIN":
a.handleJoinEvent(msg, ts)
a.handleMsgJoin(msg, ts)
case "PART":
a.handlePartEvent(msg, ts)
a.handleMsgPart(msg, ts)
case "QUIT":
a.handleQuitEvent(msg, ts)
a.handleMsgQuit(msg, ts)
case "NICK":
a.handleNickEvent(msg, ts, myNick)
a.handleMsgNick(msg, ts, myNick)
case "NOTICE":
a.handleNoticeEvent(msg, ts)
a.handleMsgNotice(msg, ts)
case "TOPIC":
a.handleTopicEvent(msg, ts)
a.handleMsgTopic(msg, ts)
default:
a.handleDefaultEvent(msg, ts)
a.handleMsgDefault(msg, ts)
}
}
func (a *App) formatTS(msg *api.Message) string {
if msg.TS != "" {
return msg.ParseTS().UTC().Format(timeFormat)
}
return time.Now().Format(timeFormat)
}
func (a *App) handlePrivmsgEvent(
msg *api.Message, ts, myNick string,
) {
func (a *App) handleMsgPrivmsg(msg *api.Message, ts, myNick string) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
@@ -674,70 +588,40 @@ func (a *App) handlePrivmsgEvent(
target = msg.From
}
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
ts, msg.From, text,
))
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text))
}
func (a *App) handleJoinEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
func (a *App) handleMsgJoin(msg *api.Message, ts string) {
if msg.To != "" {
a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, msg.To))
}
}
func (a *App) handleMsgPart(msg *api.Message, ts string) {
target := msg.To
reason := strings.Join(msg.BodyLines(), " ")
if target == "" {
return
}
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s",
ts, msg.From, msg.To,
))
}
func (a *App) handlePartEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
return
}
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
if reason != "" {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
ts, msg.From, msg.To, reason,
))
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
ts, msg.From, msg.To,
))
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target))
}
}
func (a *App) handleQuitEvent(
msg *api.Message, ts string,
) {
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
func (a *App) handleMsgQuit(msg *api.Message, ts string) {
reason := strings.Join(msg.BodyLines(), " ")
if reason != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)",
ts, msg.From, reason,
))
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason))
} else {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit",
ts, msg.From,
))
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From))
}
}
func (a *App) handleNickEvent(
msg *api.Message, ts, myNick string,
) {
func (a *App) handleMsgNick(msg *api.Message, ts, myNick string) {
lines := msg.BodyLines()
newNick := ""
@@ -748,57 +632,29 @@ func (a *App) handleNickEvent(
if msg.From == myNick && newNick != "" {
a.mu.Lock()
a.nick = newNick
target := a.target
a.mu.Unlock()
a.ui.SetStatus(newNick, target, "connected")
}
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s",
ts, msg.From, newNick,
))
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick))
}
func (a *App) handleNoticeEvent(
msg *api.Message, ts string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s",
ts, msg.From, text,
))
func (a *App) handleMsgNotice(msg *api.Message, ts string) {
text := strings.Join(msg.BodyLines(), " ")
a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text))
}
func (a *App) handleTopicEvent(
msg *api.Message, ts string,
) {
if msg.To == "" {
return
func (a *App) handleMsgTopic(msg *api.Message, ts string) {
text := strings.Join(msg.BodyLines(), " ")
if msg.To != "" {
a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text))
}
lines := msg.BodyLines()
text := strings.Join(lines, " ")
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s",
ts, msg.From, text,
))
}
func (a *App) handleDefaultEvent(
msg *api.Message, ts string,
) {
lines := msg.BodyLines()
text := strings.Join(lines, " ")
func (a *App) handleMsgDefault(msg *api.Message, ts string) {
text := strings.Join(msg.BodyLines(), " ")
if text != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white][%s] %s",
ts, msg.Command, text,
))
a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text))
}
}

View File

@@ -39,13 +39,27 @@ func NewUI() *UI {
},
}
ui.initMessages()
ui.initStatusBar()
ui.initInput()
ui.initKeyCapture()
// Message area.
ui.messages = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWordWrap(true).
SetChangedFunc(func() {
ui.app.Draw()
})
ui.messages.SetBorder(false)
ui.layout = tview.NewFlex().
SetDirection(tview.FlexRow).
// Status bar.
ui.statusBar = tview.NewTextView().
SetDynamicColors(true)
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
ui.setupInput()
ui.setupKeyCapture()
// Layout: messages on top, status bar, input at bottom.
ui.layout = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(ui.messages, 0, 1, false).
AddItem(ui.statusBar, 1, 0, false).
AddItem(ui.input, 1, 0, true)
@@ -72,30 +86,29 @@ func (ui *UI) OnInput(fn func(string)) {
}
// AddLine adds a line to the specified buffer.
func (ui *UI) AddLine(bufferName, line string) {
func (ui *UI) AddLine(bufferName string, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
cur := ui.buffers[ui.currentBuffer]
if cur != buf {
// Mark unread if not currently viewing this buffer.
if ui.buffers[ui.currentBuffer] != buf {
buf.Unread++
ui.refreshStatusBar()
ui.refreshStatus()
}
if cur == buf {
// If viewing this buffer, append to display.
if ui.buffers[ui.currentBuffer] == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer.
// AddStatus adds a line to the status buffer (buffer 0).
func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04")
ui.AddLine(
"(status)",
"[gray]"+ts+"[white] "+line,
)
ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line))
}
// SwitchBuffer switches to the buffer at index n.
@@ -106,7 +119,6 @@ func (ui *UI) SwitchBuffer(n int) {
}
ui.currentBuffer = n
buf := ui.buffers[n]
buf.Unread = 0
@@ -117,16 +129,14 @@ func (ui *UI) SwitchBuffer(n int) {
}
ui.messages.ScrollToEnd()
ui.refreshStatusBar()
ui.refreshStatus()
})
}
// SwitchToBuffer switches to named buffer, creating if
// needed.
// SwitchToBuffer switches to the named buffer, creating it if needed.
func (ui *UI) SwitchToBuffer(name string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(name)
for i, b := range ui.buffers {
if b == buf {
ui.currentBuffer = i
@@ -144,16 +154,14 @@ func (ui *UI) SwitchToBuffer(name string) {
}
ui.messages.ScrollToEnd()
ui.refreshStatusBar()
ui.refreshStatus()
})
}
// SetStatus updates the status bar text.
func (ui *UI) SetStatus(
nick, target, connStatus string,
) {
func (ui *UI) SetStatus(nick, target, connStatus string) {
ui.app.QueueUpdateDraw(func() {
ui.renderStatusBar(nick, target, connStatus)
ui.refreshStatusWith(nick, target, connStatus)
})
}
@@ -162,7 +170,7 @@ func (ui *UI) BufferCount() int {
return len(ui.buffers)
}
// BufferIndex returns the index of a named buffer.
// BufferIndex returns the index of a named buffer, or -1.
func (ui *UI) BufferIndex(name string) int {
for i, buf := range ui.buffers {
if buf.Name == name {
@@ -173,29 +181,10 @@ func (ui *UI) BufferIndex(name string) int {
return -1
}
func (ui *UI) initMessages() {
ui.messages = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWordWrap(true).
SetChangedFunc(func() {
ui.app.Draw()
})
ui.messages.SetBorder(false)
}
func (ui *UI) initStatusBar() {
ui.statusBar = tview.NewTextView().
SetDynamicColors(true)
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
}
func (ui *UI) initInput() {
func (ui *UI) setupInput() {
ui.input = tview.NewInputField().
SetFieldBackgroundColor(tcell.ColorBlack).
SetFieldTextColor(tcell.ColorWhite)
ui.input.SetDoneFunc(func(key tcell.Key) {
if key != tcell.KeyEnter {
return
@@ -214,13 +203,9 @@ func (ui *UI) initInput() {
})
}
func (ui *UI) initKeyCapture() {
ui.app.SetInputCapture(
func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers()&tcell.ModAlt == 0 {
return event
}
func (ui *UI) setupKeyCapture() {
ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers()&tcell.ModAlt != 0 {
r := event.Rune()
if r >= '0' && r <= '9' {
idx := int(r - '0')
@@ -228,50 +213,36 @@ func (ui *UI) initKeyCapture() {
return nil
}
}
return event
},
)
return event
})
}
func (ui *UI) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context.
func (ui *UI) refreshStatus() {
// Will be called from the main goroutine via QueueUpdateDraw parent.
// Rebuild status from app state — caller must provide context.
}
func (ui *UI) renderStatusBar(
nick, target, connStatus string,
) {
func (ui *UI) refreshStatusWith(nick, target, connStatus string) {
var unreadParts []string
for i, buf := range ui.buffers {
if buf.Unread > 0 {
unreadParts = append(unreadParts,
fmt.Sprintf(
"%d:%s(%d)",
i, buf.Name, buf.Unread,
),
)
unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread))
}
}
unread := ""
if len(unreadParts) > 0 {
unread = " [Act: " +
strings.Join(unreadParts, ",") + "]"
unread = " [Act: " + strings.Join(unreadParts, ",") + "]"
}
bufInfo := fmt.Sprintf(
"[%d:%s]",
ui.currentBuffer,
ui.buffers[ui.currentBuffer].Name,
)
bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name)
ui.statusBar.Clear()
_, _ = fmt.Fprintf(ui.statusBar,
" [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread,
)
_, _ = fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread)
}
func (ui *UI) getOrCreateBuffer(name string) *Buffer {

View File

@@ -1,65 +0,0 @@
// Package broker provides an in-memory pub/sub for long-poll notifications.
package broker
import (
"sync"
)
// Broker notifies waiting clients when new messages are available.
type Broker struct {
mu sync.Mutex
listeners map[int64][]chan struct{} // userID -> list of waiting channels
}
// New creates a new Broker.
func New() *Broker {
return &Broker{
listeners: make(map[int64][]chan struct{}),
}
}
// Wait returns a channel that will be closed when a message is available for the user.
func (b *Broker) Wait(userID int64) chan struct{} {
ch := make(chan struct{}, 1)
b.mu.Lock()
b.listeners[userID] = append(b.listeners[userID], ch)
b.mu.Unlock()
return ch
}
// Notify wakes up all waiting clients for a user.
func (b *Broker) Notify(userID int64) {
b.mu.Lock()
waiters := b.listeners[userID]
delete(b.listeners, userID)
b.mu.Unlock()
for _, ch := range waiters {
select {
case ch <- struct{}{}:
default:
}
}
}
// Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove(userID int64, ch chan struct{}) {
b.mu.Lock()
defer b.mu.Unlock()
waiters := b.listeners[userID]
for i, w := range waiters {
if w == ch {
b.listeners[userID] = append(waiters[:i], waiters[i+1:]...)
break
}
}
if len(b.listeners[userID]) == 0 {
delete(b.listeners, userID)
}
}

View File

@@ -1,118 +0,0 @@
package broker_test
import (
"sync"
"testing"
"time"
"git.eeqj.de/sneak/chat/internal/broker"
)
func TestNewBroker(t *testing.T) {
t.Parallel()
b := broker.New()
if b == nil {
t.Fatal("expected non-nil broker")
}
}
func TestWaitAndNotify(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
go func() {
time.Sleep(10 * time.Millisecond)
b.Notify(1)
}()
select {
case <-ch:
case <-time.After(2 * time.Second):
t.Fatal("timeout")
}
}
func TestNotifyWithoutWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
b.Notify(42) // should not panic
}
func TestRemove(t *testing.T) {
t.Parallel()
b := broker.New()
ch := b.Wait(1)
b.Remove(1, ch)
b.Notify(1)
select {
case <-ch:
t.Fatal("should not receive after remove")
case <-time.After(50 * time.Millisecond):
}
}
func TestMultipleWaiters(t *testing.T) {
t.Parallel()
b := broker.New()
ch1 := b.Wait(1)
ch2 := b.Wait(1)
b.Notify(1)
select {
case <-ch1:
case <-time.After(time.Second):
t.Fatal("ch1 timeout")
}
select {
case <-ch2:
case <-time.After(time.Second):
t.Fatal("ch2 timeout")
}
}
func TestConcurrentWaitNotify(t *testing.T) {
t.Parallel()
b := broker.New()
var wg sync.WaitGroup
const concurrency = 100
for i := range concurrency {
wg.Add(1)
go func(uid int64) {
defer wg.Done()
ch := b.Wait(uid)
b.Notify(uid)
select {
case <-ch:
case <-time.After(time.Second):
t.Error("timeout")
}
}(int64(i % 10))
}
wg.Wait()
}
func TestRemoveNonexistent(t *testing.T) {
t.Parallel()
b := broker.New()
ch := make(chan struct{}, 1)
b.Remove(999, ch) // should not panic
}

View File

@@ -11,16 +11,20 @@ import (
"sort"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/models"
"go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // .env
_ "modernc.org/sqlite" // driver
_ "github.com/joho/godotenv/autoload" // loads .env file
_ "modernc.org/sqlite" // SQLite driver
)
const minMigrationParts = 2
const (
minMigrationParts = 2
)
// SchemaFiles contains embedded SQL migration files.
//
@@ -35,18 +39,15 @@ type Params struct {
Config *config.Config
}
// Database manages the SQLite connection and migrations.
// Database manages the SQLite database connection and migrations.
type Database struct {
db *sql.DB
log *slog.Logger
params *Params
}
// New creates a new Database and registers lifecycle hooks.
func New(
lc fx.Lifecycle,
params Params,
) (*Database, error) {
// New creates a new Database instance and registers lifecycle hooks.
func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := new(Database)
s.params = &params
s.log = params.Logger.Get()
@@ -73,11 +74,461 @@ func New(
return s, nil
}
// NewTest creates a Database for testing, bypassing fx lifecycle.
// It connects to the given DSN and runs all migrations.
func NewTest(dsn string) (*Database, error) {
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Database{
db: d,
log: slog.Default(),
}
// Item 9: Enable foreign keys
_, err = d.ExecContext(context.Background(), "PRAGMA foreign_keys = ON")
if err != nil {
_ = d.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
ctx := context.Background()
err = s.runMigrations(ctx)
if err != nil {
_ = d.Close()
return nil, err
}
return s, nil
}
// GetDB returns the underlying sql.DB connection.
func (s *Database) GetDB() *sql.DB {
return s.db
}
// Hydrate injects the database reference into any model that
// embeds Base.
func (s *Database) Hydrate(m interface{ SetDB(d models.DB) }) {
m.SetDB(s)
}
// GetUserByID looks up a user by their ID.
func (s *Database) GetUserByID(
ctx context.Context,
id string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT id, nick, password_hash, created_at, updated_at, last_seen_at
FROM users WHERE id = ?`,
id,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// GetChannelByID looks up a channel by its ID.
func (s *Database) GetChannelByID(
ctx context.Context,
id string,
) (*models.Channel, error) {
c := &models.Channel{}
s.Hydrate(c)
err := s.db.QueryRowContext(ctx, `
SELECT id, name, topic, modes, created_at, updated_at
FROM channels WHERE id = ?`,
id,
).Scan(
&c.ID, &c.Name, &c.Topic, &c.Modes,
&c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
return c, nil
}
// GetUserByNick looks up a user by their nick.
func (s *Database) GetUserByNick(
ctx context.Context,
nick string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT id, nick, password_hash, created_at, updated_at, last_seen_at
FROM users WHERE nick = ?`,
nick,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// GetUserByToken looks up a user by their auth token.
func (s *Database) GetUserByToken(
ctx context.Context,
token string,
) (*models.User, error) {
u := &models.User{}
s.Hydrate(u)
err := s.db.QueryRowContext(ctx, `
SELECT u.id, u.nick, u.password_hash,
u.created_at, u.updated_at, u.last_seen_at
FROM users u
JOIN auth_tokens t ON t.user_id = u.id
WHERE t.token = ?`,
token,
).Scan(
&u.ID, &u.Nick, &u.PasswordHash,
&u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt,
)
if err != nil {
return nil, err
}
return u, nil
}
// DeleteAuthToken removes an auth token from the database.
func (s *Database) DeleteAuthToken(
ctx context.Context,
token string,
) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM auth_tokens WHERE token = ?`, token,
)
return err
}
// UpdateUserLastSeen updates the last_seen_at timestamp for a user.
func (s *Database) UpdateUserLastSeen(
ctx context.Context,
userID string,
) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
return err
}
// CreateUser inserts a new user into the database.
func (s *Database) CreateUser(
ctx context.Context,
id, nick, passwordHash string,
) (*models.User, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, nick, password_hash)
VALUES (?, ?, ?)`,
id, nick, passwordHash,
)
if err != nil {
return nil, err
}
u := &models.User{
ID: id, Nick: nick, PasswordHash: passwordHash,
CreatedAt: now, UpdatedAt: now,
}
s.Hydrate(u)
return u, nil
}
// CreateChannel inserts a new channel into the database.
func (s *Database) CreateChannel(
ctx context.Context,
id, name, topic, modes string,
) (*models.Channel, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO channels (id, name, topic, modes)
VALUES (?, ?, ?, ?)`,
id, name, topic, modes,
)
if err != nil {
return nil, err
}
c := &models.Channel{
ID: id, Name: name, Topic: topic, Modes: modes,
CreatedAt: now, UpdatedAt: now,
}
s.Hydrate(c)
return c, nil
}
// AddChannelMember adds a user to a channel with the given modes.
func (s *Database) AddChannelMember(
ctx context.Context,
channelID, userID, modes string,
) (*models.ChannelMember, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO channel_members
(channel_id, user_id, modes)
VALUES (?, ?, ?)`,
channelID, userID, modes,
)
if err != nil {
return nil, err
}
cm := &models.ChannelMember{
ChannelID: channelID,
UserID: userID,
Modes: modes,
JoinedAt: now,
}
s.Hydrate(cm)
return cm, nil
}
// CreateMessage inserts a new message into the database.
func (s *Database) CreateMessage(
ctx context.Context,
id, fromUserID, fromNick, target, msgType, body string,
) (*models.Message, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages
(id, from_user_id, from_nick, target, type, body)
VALUES (?, ?, ?, ?, ?, ?)`,
id, fromUserID, fromNick, target, msgType, body,
)
if err != nil {
return nil, err
}
m := &models.Message{
ID: id,
FromUserID: fromUserID,
FromNick: fromNick,
Target: target,
Type: msgType,
Body: body,
Timestamp: now,
CreatedAt: now,
}
s.Hydrate(m)
return m, nil
}
// QueueMessage adds a message to a user's delivery queue.
func (s *Database) QueueMessage(
ctx context.Context,
userID, messageID string,
) (*models.MessageQueueEntry, error) {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO message_queue (user_id, message_id)
VALUES (?, ?)`,
userID, messageID,
)
if err != nil {
return nil, err
}
entryID, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get last insert id: %w", err)
}
mq := &models.MessageQueueEntry{
ID: entryID,
UserID: userID,
MessageID: messageID,
QueuedAt: now,
}
s.Hydrate(mq)
return mq, nil
}
// DequeueMessages returns up to limit pending messages for a user,
// ordered by queue time (oldest first).
func (s *Database) DequeueMessages(
ctx context.Context,
userID string,
limit int,
) ([]*models.MessageQueueEntry, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, message_id, queued_at
FROM message_queue
WHERE user_id = ?
ORDER BY queued_at ASC
LIMIT ?`,
userID, limit,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
entries := []*models.MessageQueueEntry{}
for rows.Next() {
e := &models.MessageQueueEntry{}
s.Hydrate(e)
err = rows.Scan(&e.ID, &e.UserID, &e.MessageID, &e.QueuedAt)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// AckMessages removes the given queue entry IDs, marking them as delivered.
func (s *Database) AckMessages(
ctx context.Context,
entryIDs []int64,
) error {
if len(entryIDs) == 0 {
return nil
}
placeholders := make([]string, len(entryIDs))
args := make([]any, len(entryIDs))
for i, id := range entryIDs {
placeholders[i] = "?"
args[i] = id
}
//nolint:gosec // G201: placeholders are all "?" literals, not user input
query := fmt.Sprintf(
"DELETE FROM message_queue WHERE id IN (%s)",
strings.Join(placeholders, ","),
)
_, err := s.db.ExecContext(ctx, query, args...)
return err
}
// CreateAuthToken inserts a new auth token for a user.
func (s *Database) CreateAuthToken(
ctx context.Context,
token, userID string,
) (*models.AuthToken, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO auth_tokens (token, user_id)
VALUES (?, ?)`,
token, userID,
)
if err != nil {
return nil, err
}
at := &models.AuthToken{Token: token, UserID: userID, CreatedAt: now}
s.Hydrate(at)
return at, nil
}
// CreateSession inserts a new session for a user.
func (s *Database) CreateSession(
ctx context.Context,
id, userID string,
) (*models.Session, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id)
VALUES (?, ?)`,
id, userID,
)
if err != nil {
return nil, err
}
sess := &models.Session{
ID: id, UserID: userID,
CreatedAt: now, LastActiveAt: now,
}
s.Hydrate(sess)
return sess, nil
}
// CreateServerLink inserts a new server link.
func (s *Database) CreateServerLink(
ctx context.Context,
id, name, url, sharedKeyHash string,
isActive bool,
) (*models.ServerLink, error) {
now := time.Now()
active := 0
if isActive {
active = 1
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO server_links
(id, name, url, shared_key_hash, is_active)
VALUES (?, ?, ?, ?, ?)`,
id, name, url, sharedKeyHash, active,
)
if err != nil {
return nil, err
}
sl := &models.ServerLink{
ID: id,
Name: name,
URL: url,
SharedKeyHash: sharedKeyHash,
IsActive: isActive,
CreatedAt: now,
}
s.Hydrate(sl)
return sl, nil
}
func (s *Database) connect(ctx context.Context) error {
dbURL := s.params.Config.DBURL
if dbURL == "" {
@@ -88,18 +539,14 @@ func (s *Database) connect(ctx context.Context) error {
d, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error(
"failed to open database", "error", err,
)
s.log.Error("failed to open database", "error", err)
return err
}
err = d.PingContext(ctx)
if err != nil {
s.log.Error(
"failed to ping database", "error", err,
)
s.log.Error("failed to ping database", "error", err)
return err
}
@@ -107,9 +554,8 @@ func (s *Database) connect(ctx context.Context) error {
s.db = d
s.log.Info("database connected")
_, err = s.db.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
// Item 9: Enable foreign keys on every connection
_, err = s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
@@ -123,17 +569,10 @@ type migration struct {
sql string
}
func (s *Database) runMigrations(
ctx context.Context,
) error {
_, err := s.db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
func (s *Database) runMigrations(ctx context.Context) error {
err := s.bootstrapMigrationsTable(ctx)
if err != nil {
return fmt.Errorf(
"create schema_migrations: %w", err,
)
return err
}
migrations, err := s.loadMigrations()
@@ -141,11 +580,9 @@ func (s *Database) runMigrations(
return err
}
for _, m := range migrations {
err = s.applyMigration(ctx, m)
if err != nil {
return err
}
err = s.applyMigrations(ctx, migrations)
if err != nil {
return err
}
s.log.Info("database migrations complete")
@@ -153,84 +590,27 @@ func (s *Database) runMigrations(
return nil
}
func (s *Database) applyMigration(
func (s *Database) bootstrapMigrationsTable(
ctx context.Context,
m migration,
) error {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations
WHERE version = ?`,
m.version,
).Scan(&exists)
_, err := s.db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf(
"check migration %d: %w", m.version, err,
"create schema_migrations table: %w", err,
)
}
if exists > 0 {
return nil
}
s.log.Info(
"applying migration",
"version", m.version,
"name", m.name,
)
return s.execMigration(ctx, m)
return nil
}
func (s *Database) execMigration(
ctx context.Context,
m migration,
) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(
"begin tx for migration %d: %w",
m.version, err,
)
}
_, err = tx.ExecContext(ctx, m.sql)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf(
"apply migration %d (%s): %w",
m.version, m.name, err,
)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version)
VALUES (?)`,
m.version,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf(
"record migration %d: %w",
m.version, err,
)
}
return tx.Commit()
}
func (s *Database) loadMigrations() (
[]migration,
error,
) {
func (s *Database) loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(SchemaFiles, "schema")
if err != nil {
return nil, fmt.Errorf(
"read schema dir: %w", err,
)
return nil, fmt.Errorf("read schema dir: %w", err)
}
var migrations []migration
@@ -248,18 +628,17 @@ func (s *Database) loadMigrations() (
continue
}
version, parseErr := strconv.Atoi(parts[0])
if parseErr != nil {
version, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
content, readErr := SchemaFiles.ReadFile(
content, err := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)
if readErr != nil {
if err != nil {
return nil, fmt.Errorf(
"read migration %s: %w",
entry.Name(), readErr,
"read migration %s: %w", entry.Name(), err,
)
}
@@ -276,3 +655,60 @@ func (s *Database) loadMigrations() (
return migrations, nil
}
// Item 4: Wrap each migration in a transaction
func (s *Database) applyMigrations(
ctx context.Context,
migrations []migration,
) error {
for _, m := range migrations {
err := s.applyOneMigration(ctx, m)
if err != nil {
return err
}
}
return nil
}
func (s *Database) applyOneMigration(ctx context.Context, m migration) error {
var exists int
err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
m.version,
).Scan(&exists)
if err != nil {
return fmt.Errorf("check migration %d: %w", m.version, err)
}
if exists > 0 {
return nil
}
s.log.Info("applying migration", "version", m.version, "name", m.name)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx for migration %d: %w", m.version, err)
}
_, err = tx.ExecContext(ctx, m.sql)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err)
}
_, err = tx.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
m.version,
)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %d: %w", m.version, err)
}
return tx.Commit()
}

494
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,494 @@
package db_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/chat/internal/db"
)
const (
nickAlice = "alice"
nickBob = "bob"
nickCharlie = "charlie"
)
// setupTestDB creates a fresh database in a temp directory with
// all migrations applied.
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
dir := t.TempDir()
dsn := fmt.Sprintf(
"file:%s?_journal_mode=WAL",
filepath.Join(dir, "test.db"),
)
d, err := db.NewTest(dsn)
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() { _ = d.GetDB().Close() })
return d
}
func TestCreateUser(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, err := d.CreateUser(ctx, "u1", nickAlice, "hash1")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("got user %+v", u)
}
}
func TestCreateAuthToken(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, err := d.CreateUser(ctx, "u1", nickAlice, "h")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tok, err := d.CreateAuthToken(ctx, "tok1", "u1")
if err != nil {
t.Fatalf("CreateAuthToken: %v", err)
}
if tok.Token != "tok1" || tok.UserID != "u1" {
t.Errorf("unexpected token: %+v", tok)
}
u, err := tok.User(ctx)
if err != nil {
t.Fatalf("AuthToken.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("AuthToken.User got %+v", u)
}
}
func TestCreateChannel(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, err := d.CreateChannel(
ctx, "c1", "#general", "welcome", "+n",
)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if ch.ID != "c1" || ch.Name != "#general" {
t.Errorf("unexpected channel: %+v", ch)
}
}
func TestAddChannelMember(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, err := d.AddChannelMember(ctx, "c1", "u1", "+o")
if err != nil {
t.Fatalf("AddChannelMember: %v", err)
}
if cm.ChannelID != "c1" || cm.Modes != "+o" {
t.Errorf("unexpected member: %+v", cm)
}
}
func TestCreateMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
msg, err := d.CreateMessage(
ctx, "m1", "u1", nickAlice,
"#general", "message", "hello",
)
if err != nil {
t.Fatalf("CreateMessage: %v", err)
}
if msg.ID != "m1" || msg.Body != "hello" {
t.Errorf("unexpected message: %+v", msg)
}
}
func TestQueueMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hi",
)
mq, err := d.QueueMessage(ctx, "u2", "m1")
if err != nil {
t.Fatalf("QueueMessage: %v", err)
}
if mq.UserID != "u2" || mq.MessageID != "m1" {
t.Errorf("unexpected queue entry: %+v", mq)
}
}
func TestCreateSession(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
sess, err := d.CreateSession(ctx, "s1", "u1")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if sess.ID != "s1" || sess.UserID != "u1" {
t.Errorf("unexpected session: %+v", sess)
}
u, err := sess.User(ctx)
if err != nil {
t.Fatalf("Session.User: %v", err)
}
if u.ID != "u1" {
t.Errorf("Session.User got %v, want u1", u.ID)
}
}
func TestCreateServerLink(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
sl, err := d.CreateServerLink(
ctx, "sl1", "peer1",
"https://peer.example.com", "keyhash", true,
)
if err != nil {
t.Fatalf("CreateServerLink: %v", err)
}
if sl.ID != "sl1" || !sl.IsActive {
t.Errorf("unexpected server link: %+v", sl)
}
}
func TestUserChannels(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#alpha", "", "")
_, _ = d.CreateChannel(ctx, "c2", "#beta", "", "")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "")
_, _ = d.AddChannelMember(ctx, "c2", "u1", "")
channels, err := u.Channels(ctx)
if err != nil {
t.Fatalf("User.Channels: %v", err)
}
if len(channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(channels))
}
if channels[0].Name != "#alpha" {
t.Errorf("first channel: got %s", channels[0].Name)
}
if channels[1].Name != "#beta" {
t.Errorf("second channel: got %s", channels[1].Name)
}
}
func TestUserChannelsEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
channels, err := u.Channels(ctx)
if err != nil {
t.Fatalf("User.Channels: %v", err)
}
if len(channels) != 0 {
t.Errorf("expected 0 channels, got %d", len(channels))
}
}
func TestUserQueuedMessages(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
for i := range 3 {
id := fmt.Sprintf("m%d", i)
_, _ = d.CreateMessage(
ctx, id, "u2", nickBob, "u1",
"message", fmt.Sprintf("msg%d", i),
)
time.Sleep(10 * time.Millisecond)
_, _ = d.QueueMessage(ctx, "u1", id)
}
msgs, err := u.QueuedMessages(ctx)
if err != nil {
t.Fatalf("User.QueuedMessages: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
for i, msg := range msgs {
want := fmt.Sprintf("msg%d", i)
if msg.Body != want {
t.Errorf("msg %d: got %q, want %q", i, msg.Body, want)
}
}
}
func TestUserQueuedMessagesEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
u, _ := d.CreateUser(ctx, "u1", nickAlice, "h")
msgs, err := u.QueuedMessages(ctx)
if err != nil {
t.Fatalf("User.QueuedMessages: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestChannelMembers(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
_, _ = d.CreateUser(ctx, "u3", nickCharlie, "h")
_, _ = d.AddChannelMember(ctx, "c1", "u1", "+o")
_, _ = d.AddChannelMember(ctx, "c1", "u2", "+v")
_, _ = d.AddChannelMember(ctx, "c1", "u3", "")
members, err := ch.Members(ctx)
if err != nil {
t.Fatalf("Channel.Members: %v", err)
}
if len(members) != 3 {
t.Fatalf("expected 3 members, got %d", len(members))
}
nicks := map[string]bool{}
for _, m := range members {
nicks[m.Nick] = true
}
for _, want := range []string{
nickAlice, nickBob, nickCharlie,
} {
if !nicks[want] {
t.Errorf("missing nick %q", want)
}
}
}
func TestChannelMembersEmpty(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#empty", "", "")
members, err := ch.Members(ctx)
if err != nil {
t.Fatalf("Channel.Members: %v", err)
}
if len(members) != 0 {
t.Errorf("expected 0, got %d", len(members))
}
}
func TestChannelRecentMessages(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
for i := range 5 {
id := fmt.Sprintf("m%d", i)
_, _ = d.CreateMessage(
ctx, id, "u1", nickAlice, "#general",
"message", fmt.Sprintf("msg%d", i),
)
time.Sleep(10 * time.Millisecond)
}
msgs, err := ch.RecentMessages(ctx, 3)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3, got %d", len(msgs))
}
if msgs[0].Body != "msg4" {
t.Errorf("first: got %q, want msg4", msgs[0].Body)
}
if msgs[2].Body != "msg2" {
t.Errorf("last: got %q, want msg2", msgs[2].Body)
}
}
func TestChannelRecentMessagesLargeLimit(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
ch, _ := d.CreateChannel(ctx, "c1", "#general", "", "")
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateMessage(
ctx, "m1", "u1", nickAlice,
"#general", "message", "only",
)
msgs, err := ch.RecentMessages(ctx, 100)
if err != nil {
t.Fatalf("RecentMessages: %v", err)
}
if len(msgs) != 1 {
t.Errorf("expected 1, got %d", len(msgs))
}
}
func TestChannelMemberUser(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "", "")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "+o")
u, err := cm.User(ctx)
if err != nil {
t.Fatalf("ChannelMember.User: %v", err)
}
if u.ID != "u1" || u.Nick != nickAlice {
t.Errorf("got %+v", u)
}
}
func TestChannelMemberChannel(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateChannel(ctx, "c1", "#general", "topic", "+n")
cm, _ := d.AddChannelMember(ctx, "c1", "u1", "")
ch, err := cm.Channel(ctx)
if err != nil {
t.Fatalf("ChannelMember.Channel: %v", err)
}
if ch.ID != "c1" || ch.Topic != "topic" {
t.Errorf("got %+v", ch)
}
}
func TestDMMessage(t *testing.T) {
t.Parallel()
d := setupTestDB(t)
ctx := context.Background()
_, _ = d.CreateUser(ctx, "u1", nickAlice, "h")
_, _ = d.CreateUser(ctx, "u2", nickBob, "h")
msg, err := d.CreateMessage(
ctx, "m1", "u1", nickAlice, "u2", "message", "hey",
)
if err != nil {
t.Fatalf("CreateMessage DM: %v", err)
}
if msg.Target != "u2" {
t.Errorf("target: got %q, want u2", msg.Target)
}
}

View File

@@ -1,47 +0,0 @@
package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sync/atomic"
)
//nolint:gochecknoglobals // test counter
var testDBCounter atomic.Int64
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
n := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
n,
)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
database := &Database{db: d, log: slog.Default()}
err = database.runMigrations(context.Background())
if err != nil {
closeErr := d.Close()
if closeErr != nil {
return nil, closeErr
}
return nil, err
}
return database, nil
}
// Close closes the underlying database connection.
func (s *Database) Close() error {
return s.db.Close()
}

View File

@@ -2,217 +2,64 @@ package db
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
const (
tokenBytes = 32
defaultPollLimit = 100
defaultHistLimit = 50
)
// GetOrCreateChannel returns the channel id, creating it if needed.
func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (string, error) {
var id string
func generateToken() string {
b := make([]byte, tokenBytes)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// IRCMessage is the IRC envelope for all messages.
type IRCMessage struct {
ID string `json:"id"`
Command string `json:"command"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"`
DBID int64 `json:"-"`
}
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
}
// MemberInfo represents a channel member.
type MemberInfo struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
LastSeen time.Time `json:"lastSeen"`
}
// CreateUser registers a new user with the given nick.
func (s *Database) CreateUser(
ctx context.Context,
nick string,
) (int64, string, error) {
token := generateToken()
now := time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO users
(nick, token, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
nick, token, now, now)
if err != nil {
return 0, "", fmt.Errorf("create user: %w", err)
}
id, _ := res.LastInsertId()
return id, token, nil
}
// GetUserByToken returns user id and nick for a token.
func (s *Database) GetUserByToken(
ctx context.Context,
token string,
) (int64, string, error) {
var id int64
var nick string
err := s.db.QueryRowContext(
ctx,
"SELECT id, nick FROM users WHERE token = ?",
token,
).Scan(&id, &nick)
if err != nil {
return 0, "", err
}
_, _ = s.db.ExecContext(
ctx,
"UPDATE users SET last_seen = ? WHERE id = ?",
time.Now(), id,
)
return id, nick, nil
}
// GetUserByNick returns user id for a given nick.
func (s *Database) GetUserByNick(
ctx context.Context,
nick string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM users WHERE nick = ?",
nick,
).Scan(&id)
return id, err
}
// GetChannelByName returns the channel ID for a name.
func (s *Database) GetChannelByName(
ctx context.Context,
name string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&id)
return id, err
}
// GetOrCreateChannel returns channel id, creating if needed.
func (s *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
var id int64
err := s.db.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&id)
err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id)
if err == nil {
return id, nil
}
now := time.Now()
id = fmt.Sprintf("ch-%d", now.UnixNano())
res, err := s.db.ExecContext(ctx,
`INSERT INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
_, err = s.db.ExecContext(ctx,
"INSERT INTO channels (id, name, topic, modes, created_at, updated_at) VALUES (?, ?, '', '', ?, ?)",
id, name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
return "", fmt.Errorf("create channel: %w", err)
}
id, _ = res.LastInsertId()
return id, nil
}
// JoinChannel adds a user to a channel.
func (s *Database) JoinChannel(
ctx context.Context,
channelID, userID int64,
) error {
func (s *Database) JoinChannel(ctx context.Context, channelID, userID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, user_id, joined_at)
VALUES (?, ?, ?)`,
"INSERT OR IGNORE INTO channel_members (channel_id, user_id, modes, joined_at) VALUES (?, ?, '', ?)",
channelID, userID, time.Now())
return err
}
// PartChannel removes a user from a channel.
func (s *Database) PartChannel(
ctx context.Context,
channelID, userID int64,
) error {
func (s *Database) PartChannel(ctx context.Context, channelID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND user_id = ?`,
"DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?",
channelID, userID)
return err
}
// DeleteChannelIfEmpty removes a channel with no members.
func (s *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
// ListChannels returns all channels the user has joined.
func (s *Database) ListChannels(ctx context.Context, userID string) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic FROM channels c
INNER JOIN channel_members cm ON cm.channel_id = c.id
WHERE cm.user_id = ? ORDER BY c.name`, userID)
if err != nil {
return nil, err
}
return err
}
// scanChannels scans rows into a ChannelInfo slice.
func scanChannels(
rows *sql.Rows,
) ([]ChannelInfo, error) {
defer func() { _ = rows.Close() }()
var out []ChannelInfo
channels := []ChannelInfo{}
for rows.Next() {
var ch ChannelInfo
@@ -222,78 +69,37 @@ func scanChannels(
return nil, err
}
out = append(out, ch)
channels = append(channels, ch)
}
err := rows.Err()
if err != nil {
return nil, err
}
if out == nil {
out = []ChannelInfo{}
}
return out, nil
return channels, rows.Err()
}
// ListChannels returns channels the user has joined.
func (s *Database) ListChannels(
ctx context.Context,
userID int64,
) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic
FROM channels c
INNER JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.user_id = ?
ORDER BY c.name`, userID)
if err != nil {
return nil, err
}
return scanChannels(rows)
}
// ListAllChannels returns every channel.
func (s *Database) ListAllChannels(
ctx context.Context,
) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, topic
FROM channels ORDER BY name`)
if err != nil {
return nil, err
}
return scanChannels(rows)
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
}
// ChannelMembers returns all members of a channel.
func (s *Database) ChannelMembers(
ctx context.Context,
channelID int64,
) ([]MemberInfo, error) {
func (s *Database) ChannelMembers(ctx context.Context, channelID string) ([]MemberInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT u.id, u.nick, u.last_seen
FROM users u
INNER JOIN channel_members cm
ON cm.user_id = u.id
WHERE cm.channel_id = ?
ORDER BY u.nick`, channelID)
`SELECT u.id, u.nick, u.last_seen_at FROM users u
INNER JOIN channel_members cm ON cm.user_id = u.id
WHERE cm.channel_id = ? ORDER BY u.nick`, channelID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var members []MemberInfo
members := []MemberInfo{}
for rows.Next() {
var m MemberInfo
err = rows.Scan(&m.ID, &m.Nick, &m.LastSeen)
err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen)
if err != nil {
return nil, err
}
@@ -301,323 +107,253 @@ func (s *Database) ChannelMembers(
members = append(members, m)
}
err = rows.Err()
return members, rows.Err()
}
// MemberInfo represents a channel member.
type MemberInfo struct {
ID string `json:"id"`
Nick string `json:"nick"`
LastSeen *time.Time `json:"lastSeen"`
}
// MessageInfo represents a chat message.
type MessageInfo struct {
ID string `json:"id"`
Channel string `json:"channel,omitempty"`
Nick string `json:"nick"`
Content string `json:"content"`
IsDM bool `json:"isDm,omitempty"`
DMTarget string `json:"dmTarget,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// SendMessage inserts a channel message.
func (s *Database) SendMessage(ctx context.Context, channelID, userID, nick, content string) (string, error) {
now := time.Now()
id := fmt.Sprintf("msg-%d", now.UnixNano())
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`,
id, now, userID, nick, channelID, content, now)
if err != nil {
return "", err
}
return id, nil
}
// SendDM inserts a direct message.
func (s *Database) SendDM(ctx context.Context, fromID, fromNick, toID, content string) (string, error) {
now := time.Now()
id := fmt.Sprintf("msg-%d", now.UnixNano())
_, err := s.db.ExecContext(ctx,
`INSERT INTO messages (id, ts, from_user_id, from_nick, target, type, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, 'message', ?, '{}', ?)`,
id, now, fromID, fromNick, toID, content, now)
if err != nil {
return "", err
}
return id, nil
}
// PollMessages returns all new messages for a user's joined channels, ordered by timestamp.
func (s *Database) PollMessages(ctx context.Context, userID string, afterTS string, limit int) ([]MessageInfo, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, m.target, m.from_nick, m.body, m.created_at
FROM messages m
WHERE m.created_at > COALESCE(NULLIF(?, ''), '1970-01-01')
AND (
m.target IN (SELECT cm.channel_id FROM channel_members cm WHERE cm.user_id = ?)
OR m.target = ?
OR m.from_user_id = ?
)
ORDER BY m.created_at ASC LIMIT ?`,
afterTS, userID, userID, userID, limit)
if err != nil {
return nil, err
}
if members == nil {
members = []MemberInfo{}
}
return members, nil
}
// scanInt64s scans rows into an int64 slice.
func scanInt64s(rows *sql.Rows) ([]int64, error) {
defer func() { _ = rows.Close() }()
var ids []int64
msgs := []MessageInfo{}
for rows.Next() {
var id int64
var m MessageInfo
err := rows.Scan(&id)
err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt)
if err != nil {
return nil, err
}
ids = append(ids, id)
msgs = append(msgs, m)
}
err := rows.Err()
if err != nil {
return nil, err
}
return ids, nil
return msgs, rows.Err()
}
// GetChannelMemberIDs returns user IDs in a channel.
func (s *Database) GetChannelMemberIDs(
ctx context.Context,
channelID int64,
) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT user_id FROM channel_members
WHERE channel_id = ?`, channelID)
if err != nil {
return nil, err
}
return scanInt64s(rows)
}
// GetUserChannelIDs returns channel IDs the user is in.
func (s *Database) GetUserChannelIDs(
ctx context.Context,
userID int64,
) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
return scanInt64s(rows)
}
// InsertMessage stores a message and returns its DB ID.
func (s *Database) InsertMessage(
ctx context.Context,
command, from, to string,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) {
msgUUID := uuid.New().String()
now := time.Now().UTC()
if body == nil {
body = json.RawMessage("[]")
}
if meta == nil {
meta = json.RawMessage("{}")
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO messages
(uuid, command, msg_from, msg_to,
body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, to,
string(body), string(meta), now)
if err != nil {
return 0, "", err
}
id, _ := res.LastInsertId()
return id, msgUUID, nil
}
// EnqueueMessage adds a message to a user's queue.
func (s *Database) EnqueueMessage(
ctx context.Context,
userID, messageID int64,
) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues
(user_id, message_id, created_at)
VALUES (?, ?, ?)`,
userID, messageID, time.Now())
return err
}
// PollMessages returns queued messages for a user.
func (s *Database) PollMessages(
ctx context.Context,
userID, afterQueueID int64,
limit int,
) ([]IRCMessage, int64, error) {
// GetMessagesBefore returns channel messages before a given timestamp (for history scrollback).
func (s *Database) GetMessagesBefore(
ctx context.Context, target string, beforeTS string, limit int,
) ([]MessageInfo, error) {
if limit <= 0 {
limit = defaultPollLimit
limit = 50
}
rows, err := s.db.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to,
m.body, m.meta, m.created_at
FROM client_queues cq
INNER JOIN messages m
ON m.id = cq.message_id
WHERE cq.user_id = ? AND cq.id > ?
ORDER BY cq.id ASC LIMIT ?`,
userID, afterQueueID, limit)
if err != nil {
return nil, afterQueueID, err
var rows *sql.Rows
var err error
if beforeTS != "" {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.target, m.from_nick, m.body, m.created_at
FROM messages m
WHERE m.target = ? AND m.created_at < ?
ORDER BY m.created_at DESC LIMIT ?`,
target, beforeTS, limit)
} else {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.target, m.from_nick, m.body, m.created_at
FROM messages m
WHERE m.target = ?
ORDER BY m.created_at DESC LIMIT ?`,
target, limit)
}
msgs, lastQID, scanErr := scanMessages(
rows, afterQueueID,
)
if scanErr != nil {
return nil, afterQueueID, scanErr
}
return msgs, lastQID, nil
}
// GetHistory returns message history for a target.
func (s *Database) GetHistory(
ctx context.Context,
target string,
beforeID int64,
limit int,
) ([]IRCMessage, error) {
if limit <= 0 {
limit = defaultHistLimit
}
rows, err := s.queryHistory(
ctx, target, beforeID, limit,
)
if err != nil {
return nil, err
}
msgs, _, scanErr := scanMessages(rows, 0)
if scanErr != nil {
return nil, scanErr
}
if msgs == nil {
msgs = []IRCMessage{}
}
reverseMessages(msgs)
return msgs, nil
}
func (s *Database) queryHistory(
ctx context.Context,
target string,
beforeID int64,
limit int,
) (*sql.Rows, error) {
if beforeID > 0 {
return s.db.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, beforeID, limit)
}
return s.db.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, limit)
}
func scanMessages(
rows *sql.Rows,
fallbackQID int64,
) ([]IRCMessage, int64, error) {
defer func() { _ = rows.Close() }()
var msgs []IRCMessage
lastQID := fallbackQID
msgs := []MessageInfo{}
for rows.Next() {
var (
m IRCMessage
qID int64
body, meta string
ts time.Time
)
var m MessageInfo
err := rows.Scan(
&qID, &m.ID, &m.Command,
&m.From, &m.To,
&body, &meta, &ts,
)
err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt)
if err != nil {
return nil, fallbackQID, err
return nil, err
}
m.Body = json.RawMessage(body)
m.Meta = json.RawMessage(meta)
m.TS = ts.Format(time.RFC3339Nano)
m.DBID = qID
lastQID = qID
msgs = append(msgs, m)
}
err := rows.Err()
if err != nil {
return nil, fallbackQID, err
}
if msgs == nil {
msgs = []IRCMessage{}
}
return msgs, lastQID, nil
}
func reverseMessages(msgs []IRCMessage) {
// Reverse to ascending order.
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, rows.Err()
}
// GetDMsBefore returns DMs between two users before a given timestamp.
func (s *Database) GetDMsBefore(
ctx context.Context, userA, userB string, beforeTS string, limit int,
) ([]MessageInfo, error) {
if limit <= 0 {
limit = 50
}
var rows *sql.Rows
var err error
if beforeTS != "" {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.from_nick, m.body, m.target, m.created_at
FROM messages m
WHERE m.created_at < ?
AND ((m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?))
ORDER BY m.created_at DESC LIMIT ?`,
beforeTS, userA, userB, userB, userA, limit)
} else {
rows, err = s.db.QueryContext(ctx,
`SELECT m.id, m.from_nick, m.body, m.target, m.created_at
FROM messages m
WHERE (m.from_user_id = ? AND m.target = ?) OR (m.from_user_id = ? AND m.target = ?)
ORDER BY m.created_at DESC LIMIT ?`,
userA, userB, userB, userA, limit)
}
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
msgs := []MessageInfo{}
for rows.Next() {
var m MessageInfo
err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt)
if err != nil {
return nil, err
}
m.IsDM = true
msgs = append(msgs, m)
}
// Reverse to ascending order.
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, rows.Err()
}
// ChangeNick updates a user's nickname.
func (s *Database) ChangeNick(
ctx context.Context,
userID int64,
newNick string,
) error {
func (s *Database) ChangeNick(ctx context.Context, userID string, newNick string) error {
_, err := s.db.ExecContext(ctx,
"UPDATE users SET nick = ? WHERE id = ?",
newNick, userID)
"UPDATE users SET nick = ? WHERE id = ?", newNick, userID)
return err
}
// SetTopic sets the topic for a channel.
func (s *Database) SetTopic(
ctx context.Context,
channelName, topic string,
) error {
func (s *Database) SetTopic(ctx context.Context, channelName string, _ string, topic string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE channels SET topic = ?,
updated_at = ? WHERE name = ?`,
topic, time.Now(), channelName)
"UPDATE channels SET topic = ? WHERE name = ?", topic, channelName)
return err
}
// DeleteUser removes a user and all their data.
func (s *Database) DeleteUser(
ctx context.Context,
userID int64,
) error {
_, err := s.db.ExecContext(
ctx,
"DELETE FROM users WHERE id = ?",
userID,
)
return err
// GetServerName returns the server name (unused, config provides this).
func (s *Database) GetServerName() string {
return ""
}
// GetAllChannelMembershipsForUser returns channels
// a user belongs to.
func (s *Database) GetAllChannelMembershipsForUser(
ctx context.Context,
userID int64,
) ([]ChannelInfo, error) {
// ListAllChannels returns all channels.
func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.name, c.topic
FROM channels c
INNER JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.user_id = ?`, userID)
"SELECT id, name, topic FROM channels ORDER BY name")
if err != nil {
return nil, err
}
return scanChannels(rows)
defer func() { _ = rows.Close() }()
channels := []ChannelInfo{}
for rows.Next() {
var ch ChannelInfo
err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic)
if err != nil {
return nil, err
}
channels = append(channels, ch)
}
return channels, rows.Err()
}

View File

@@ -1,550 +0,0 @@
package db_test
import (
"context"
"encoding/json"
"testing"
"git.eeqj.de/sneak/chat/internal/db"
_ "modernc.org/sqlite"
)
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
d, err := db.NewTestDatabase()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
closeErr := d.Close()
if closeErr != nil {
t.Logf("close db: %v", closeErr)
}
})
return d
}
func TestCreateUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
id, token, err := database.CreateUser(ctx, "alice")
if err != nil {
t.Fatal(err)
}
if id == 0 || token == "" {
t.Fatal("expected valid id and token")
}
_, _, err = database.CreateUser(ctx, "alice")
if err == nil {
t.Fatal("expected error for duplicate nick")
}
}
func TestGetUserByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, token, err := database.CreateUser(ctx, "bob")
if err != nil {
t.Fatal(err)
}
id, nick, err := database.GetUserByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "bob" || id == 0 {
t.Fatalf("expected bob, got %s", nick)
}
_, _, err = database.GetUserByToken(ctx, "badtoken")
if err == nil {
t.Fatal("expected error for bad token")
}
}
func TestGetUserByNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, _, err := database.CreateUser(ctx, "charlie")
if err != nil {
t.Fatal(err)
}
id, err := database.GetUserByNick(ctx, "charlie")
if err != nil || id == 0 {
t.Fatal("expected to find charlie")
}
_, err = database.GetUserByNick(ctx, "nobody")
if err == nil {
t.Fatal("expected error for unknown nick")
}
}
func TestChannelOperations(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
chID, err := database.GetOrCreateChannel(ctx, "#test")
if err != nil || chID == 0 {
t.Fatal("expected channel id")
}
chID2, err := database.GetOrCreateChannel(ctx, "#test")
if err != nil || chID2 != chID {
t.Fatal("expected same channel id")
}
chID3, err := database.GetChannelByName(ctx, "#test")
if err != nil || chID3 != chID {
t.Fatal("expected same channel id")
}
_, err = database.GetChannelByName(ctx, "#nope")
if err == nil {
t.Fatal("expected error for nonexistent channel")
}
}
func TestJoinAndPart(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "user1")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(ctx, "#chan")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
ids, err := database.GetChannelMemberIDs(ctx, chID)
if err != nil || len(ids) != 1 || ids[0] != uid {
t.Fatal("expected user in channel")
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
ids, _ = database.GetChannelMemberIDs(ctx, chID)
if len(ids) != 0 {
t.Fatal("expected empty channel")
}
}
func TestDeleteChannelIfEmpty(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
chID, err := database.GetOrCreateChannel(
ctx, "#empty",
)
if err != nil {
t.Fatal(err)
}
uid, _, err := database.CreateUser(ctx, "temp")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil {
t.Fatal(err)
}
_, err = database.GetChannelByName(ctx, "#empty")
if err == nil {
t.Fatal("expected channel to be deleted")
}
}
func createUserWithChannels(
t *testing.T,
database *db.Database,
nick, ch1Name, ch2Name string,
) (int64, int64, int64) {
t.Helper()
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, nick)
if err != nil {
t.Fatal(err)
}
ch1, err := database.GetOrCreateChannel(
ctx, ch1Name,
)
if err != nil {
t.Fatal(err)
}
ch2, err := database.GetOrCreateChannel(
ctx, ch2Name,
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch1, uid)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch2, uid)
if err != nil {
t.Fatal(err)
}
return uid, ch1, ch2
}
func TestListChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
t, database, "lister", "#a", "#b",
)
channels, err := database.ListChannels(
context.Background(), uid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
"expected 2 channels, got %d",
len(channels),
)
}
}
func TestListAllChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, err := database.GetOrCreateChannel(ctx, "#x")
if err != nil {
t.Fatal(err)
}
_, err = database.GetOrCreateChannel(ctx, "#y")
if err != nil {
t.Fatal(err)
}
channels, err := database.ListAllChannels(ctx)
if err != nil || len(channels) < 2 {
t.Fatalf(
"expected >= 2 channels, got %d",
len(channels),
)
}
}
func TestChangeNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, token, err := database.CreateUser(ctx, "old")
if err != nil {
t.Fatal(err)
}
err = database.ChangeNick(ctx, uid, "new")
if err != nil {
t.Fatal(err)
}
_, nick, err := database.GetUserByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "new" {
t.Fatalf("expected new, got %s", nick)
}
}
func TestSetTopic(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
_, err := database.GetOrCreateChannel(
ctx, "#topictest",
)
if err != nil {
t.Fatal(err)
}
err = database.SetTopic(ctx, "#topictest", "Hello")
if err != nil {
t.Fatal(err)
}
channels, err := database.ListAllChannels(ctx)
if err != nil {
t.Fatal(err)
}
for _, ch := range channels {
if ch.Name == "#topictest" &&
ch.Topic != "Hello" {
t.Fatalf(
"expected topic Hello, got %s",
ch.Topic,
)
}
}
}
func TestInsertAndPollMessages(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "poller")
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil || dbID == 0 || msgUUID == "" {
t.Fatal("insert failed")
}
err = database.EnqueueMessage(ctx, uid, dbID)
if err != nil {
t.Fatal(err)
}
const batchSize = 10
msgs, lastQID, err := database.PollMessages(
ctx, uid, 0, batchSize,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf(
"expected 1 message, got %d", len(msgs),
)
}
if msgs[0].Command != "PRIVMSG" {
t.Fatalf(
"expected PRIVMSG, got %s", msgs[0].Command,
)
}
if lastQID == 0 {
t.Fatal("expected nonzero lastQID")
}
msgs, _, _ = database.PollMessages(
ctx, uid, lastQID, batchSize,
)
if len(msgs) != 0 {
t.Fatalf(
"expected 0 messages, got %d", len(msgs),
)
}
}
func TestGetHistory(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
const msgCount = 10
for range msgCount {
_, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil,
)
if err != nil {
t.Fatal(err)
}
}
const histLimit = 5
msgs, err := database.GetHistory(
ctx, "#hist", 0, histLimit,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != histLimit {
t.Fatalf("expected %d, got %d",
histLimit, len(msgs))
}
if msgs[0].DBID > msgs[histLimit-1].DBID {
t.Fatal("expected ascending order")
}
}
func TestDeleteUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid, _, err := database.CreateUser(ctx, "deleteme")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#delchan",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteUser(ctx, uid)
if err != nil {
t.Fatal(err)
}
_, err = database.GetUserByNick(ctx, "deleteme")
if err == nil {
t.Fatal("user should be deleted")
}
ids, _ := database.GetChannelMemberIDs(ctx, chID)
if len(ids) != 0 {
t.Fatal("expected no members after deletion")
}
}
func TestChannelMembers(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := context.Background()
uid1, _, err := database.CreateUser(ctx, "m1")
if err != nil {
t.Fatal(err)
}
uid2, _, err := database.CreateUser(ctx, "m2")
if err != nil {
t.Fatal(err)
}
chID, err := database.GetOrCreateChannel(
ctx, "#members",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid1)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid2)
if err != nil {
t.Fatal(err)
}
members, err := database.ChannelMembers(ctx, chID)
if err != nil || len(members) != 2 {
t.Fatalf(
"expected 2 members, got %d",
len(members),
)
}
}
func TestGetAllChannelMembershipsForUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
t, database, "multi", "#m1", "#m2",
)
channels, err :=
database.GetAllChannelMembershipsForUser(
context.Background(), uid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
"expected 2 channels, got %d",
len(channels),
)
}
}

View File

@@ -1,54 +1,4 @@
-- Chat server schema (pre-1.0 consolidated)
PRAGMA foreign_keys = ON;
-- Users: IRC-style sessions (no passwords, just nick + token)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick TEXT NOT NULL UNIQUE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
-- Channels
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Channel members
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
);
-- Messages: IRC envelope format
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);

View File

@@ -0,0 +1,89 @@
-- All schema changes go into this file until 1.0.0 is tagged.
-- There will not be migrations during the early development phase.
-- After 1.0.0, new changes get their own numbered migration files.
-- Users: accounts and authentication
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- UUID
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME
);
-- Auth tokens: one user can have multiple active tokens (multiple devices)
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY, -- random token string
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME, -- NULL = no expiry
last_used_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id);
-- Channels: chat rooms
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL UNIQUE, -- #general, etc.
topic TEXT NOT NULL DEFAULT '',
modes TEXT NOT NULL DEFAULT '', -- +i, +m, +s, +t, +n
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Channel members: who is in which channel, with per-user modes
CREATE TABLE IF NOT EXISTS channel_members (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
modes TEXT NOT NULL DEFAULT '', -- +o (operator), +v (voice)
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_members_user_id ON channel_members(user_id);
-- Messages: channel and DM history (rotated per MAX_HISTORY)
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, -- UUID
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
from_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_nick TEXT NOT NULL, -- denormalized for history
target TEXT NOT NULL, -- #channel name or user UUID for DMs
type TEXT NOT NULL DEFAULT 'message', -- message, action, notice, join, part, quit, topic, mode, nick, system
body TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}', -- JSON extensible metadata
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_target_ts ON messages(target, ts);
CREATE INDEX IF NOT EXISTS idx_messages_from_user ON messages(from_user_id);
-- Message queue: per-user pending delivery (unread messages)
CREATE TABLE IF NOT EXISTS message_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_message_queue_user_id ON message_queue(user_id, queued_at);
-- Sessions: server-held session state
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME -- idle timeout
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-- Server links: federation peer configuration
CREATE TABLE IF NOT EXISTS server_links (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL UNIQUE, -- human-readable peer name
url TEXT NOT NULL, -- base URL of peer server
shared_key_hash TEXT NOT NULL, -- hashed shared secret
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME
);

View File

@@ -0,0 +1,4 @@
-- Migration 003: no-op (schema already created by 002_schema.sql)
-- This migration previously conflicted with 002 by attempting to recreate
-- tables with incompatible column types (INTEGER vs TEXT IDs).
SELECT 1;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,9 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
@@ -17,8 +15,6 @@ import (
"go.uber.org/fx"
)
var errUnauthorized = errors.New("unauthorized")
// Params defines the dependencies for creating Handlers.
type Params struct {
fx.In
@@ -35,19 +31,14 @@ type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
}
// New creates a new Handlers instance.
func New(
lc fx.Lifecycle,
params Params,
) (*Handlers, error) {
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
s := new(Handlers)
s.params = &params
s.log = params.Logger.Get()
s.hc = params.Healthcheck
s.broker = broker.New()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
@@ -58,17 +49,9 @@ func New(
return s, nil
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
w.Header().Set(
"Content-Type",
"application/json; charset=utf-8",
)
func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data any, status int) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
if data != nil {
err := json.NewEncoder(w).Encode(data)

View File

@@ -0,0 +1,26 @@
package models
import (
"context"
"time"
)
// AuthToken represents an authentication token for a user session.
type AuthToken struct {
Base
Token string `json:"-"`
UserID string `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// User returns the user who owns this token.
func (t *AuthToken) User(ctx context.Context) (*User, error) {
if ul := t.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, t.UserID)
}
return nil, ErrUserLookupNotAvailable
}

View File

@@ -0,0 +1,96 @@
package models
import (
"context"
"time"
)
// Channel represents a chat channel.
type Channel struct {
Base
ID string `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
Modes string `json:"modes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Members returns all users who are members of this channel.
func (c *Channel) Members(ctx context.Context) ([]*ChannelMember, error) {
rows, err := c.GetDB().QueryContext(ctx, `
SELECT cm.channel_id, cm.user_id, cm.modes, cm.joined_at,
u.nick
FROM channel_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.channel_id = ?
ORDER BY cm.joined_at`,
c.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
members := []*ChannelMember{}
for rows.Next() {
m := &ChannelMember{}
m.SetDB(c.db)
err = rows.Scan(
&m.ChannelID, &m.UserID, &m.Modes,
&m.JoinedAt, &m.Nick,
)
if err != nil {
return nil, err
}
members = append(members, m)
}
return members, rows.Err()
}
// RecentMessages returns the most recent messages in this channel.
func (c *Channel) RecentMessages(
ctx context.Context,
limit int,
) ([]*Message, error) {
rows, err := c.GetDB().QueryContext(ctx, `
SELECT id, ts, from_user_id, from_nick,
target, type, body, meta, created_at
FROM messages
WHERE target = ?
ORDER BY ts DESC
LIMIT ?`,
c.Name, limit,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
messages := []*Message{}
for rows.Next() {
msg := &Message{}
msg.SetDB(c.db)
err = rows.Scan(
&msg.ID, &msg.Timestamp, &msg.FromUserID,
&msg.FromNick, &msg.Target, &msg.Type,
&msg.Body, &msg.Meta, &msg.CreatedAt,
)
if err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, rows.Err()
}

View File

@@ -0,0 +1,35 @@
package models
import (
"context"
"time"
)
// ChannelMember represents a user's membership in a channel.
type ChannelMember struct {
Base
ChannelID string `json:"channelId"`
UserID string `json:"userId"`
Modes string `json:"modes"`
JoinedAt time.Time `json:"joinedAt"`
Nick string `json:"nick"` // denormalized from users table
}
// User returns the full User for this membership.
func (cm *ChannelMember) User(ctx context.Context) (*User, error) {
if ul := cm.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, cm.UserID)
}
return nil, ErrUserLookupNotAvailable
}
// Channel returns the full Channel for this membership.
func (cm *ChannelMember) Channel(ctx context.Context) (*Channel, error) {
if cl := cm.GetChannelLookup(); cl != nil {
return cl.GetChannelByID(ctx, cm.ChannelID)
}
return nil, ErrChannelLookupNotAvailable
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
)
// Message represents a chat message (channel or DM).
type Message struct {
Base
ID string `json:"id"`
Timestamp time.Time `json:"ts"`
FromUserID string `json:"fromUserId"`
FromNick string `json:"from"`
Target string `json:"to"`
Type string `json:"type"`
Body string `json:"body"`
Meta string `json:"meta"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
)
// MessageQueueEntry represents a pending message delivery for a user.
type MessageQueueEntry struct {
Base
ID int64 `json:"id"`
UserID string `json:"userId"`
MessageID string `json:"messageId"`
QueuedAt time.Time `json:"queuedAt"`
}

69
internal/models/model.go Normal file
View File

@@ -0,0 +1,69 @@
// Package models defines the data models used by the chat application.
// All model structs embed Base, which provides database access for
// relation-fetching methods directly on model instances.
package models
import (
"context"
"database/sql"
"errors"
)
var (
// ErrUserLookupNotAvailable is returned when the user lookup interface is not set.
ErrUserLookupNotAvailable = errors.New("user lookup not available")
// ErrChannelLookupNotAvailable is returned when the channel lookup interface is not set.
ErrChannelLookupNotAvailable = errors.New("channel lookup not available")
)
// DB is the interface that models use to query the database.
// This avoids a circular import with the db package.
type DB interface {
GetDB() *sql.DB
}
// UserLookup provides user lookup by ID without circular imports.
type UserLookup interface {
GetUserByID(ctx context.Context, id string) (*User, error)
}
// ChannelLookup provides channel lookup by ID without circular imports.
type ChannelLookup interface {
GetChannelByID(ctx context.Context, id string) (*Channel, error)
}
// Base is embedded in all model structs to provide database access.
type Base struct {
db DB
}
// SetDB injects the database reference into a model.
func (b *Base) SetDB(d DB) {
b.db = d
}
// GetDB returns the database interface for use in model methods.
func (b *Base) GetDB() *sql.DB {
return b.db.GetDB()
}
// GetUserLookup returns the DB as a UserLookup if it implements the interface.
func (b *Base) GetUserLookup() UserLookup { //nolint:ireturn // intentional interface return for dependency inversion
if ul, ok := b.db.(UserLookup); ok {
return ul
}
return nil
}
// GetChannelLookup returns the DB as a ChannelLookup
// if it implements the interface.
//
//nolint:ireturn // intentional interface return for dependency inversion
func (b *Base) GetChannelLookup() ChannelLookup {
if cl, ok := b.db.(ChannelLookup); ok {
return cl
}
return nil
}

View File

@@ -0,0 +1,18 @@
package models
import (
"time"
)
// ServerLink represents a federation peer server configuration.
type ServerLink struct {
Base
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
SharedKeyHash string `json:"-"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
}

View File

@@ -0,0 +1,26 @@
package models
import (
"context"
"time"
)
// Session represents a server-held user session.
type Session struct {
Base
ID string `json:"id"`
UserID string `json:"userId"`
CreatedAt time.Time `json:"createdAt"`
LastActiveAt time.Time `json:"lastActiveAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
// User returns the user who owns this session.
func (s *Session) User(ctx context.Context) (*User, error) {
if ul := s.GetUserLookup(); ul != nil {
return ul.GetUserByID(ctx, s.UserID)
}
return nil, ErrUserLookupNotAvailable
}

92
internal/models/user.go Normal file
View File

@@ -0,0 +1,92 @@
package models
import (
"context"
"time"
)
// User represents a registered user account.
type User struct {
Base
ID string `json:"id"`
Nick string `json:"nick"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
}
// Channels returns all channels the user is a member of.
func (u *User) Channels(ctx context.Context) ([]*Channel, error) {
rows, err := u.GetDB().QueryContext(ctx, `
SELECT c.id, c.name, c.topic, c.modes, c.created_at, c.updated_at
FROM channels c
JOIN channel_members cm ON cm.channel_id = c.id
WHERE cm.user_id = ?
ORDER BY c.name`,
u.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
channels := []*Channel{}
for rows.Next() {
c := &Channel{}
c.SetDB(u.db)
err = rows.Scan(
&c.ID, &c.Name, &c.Topic, &c.Modes,
&c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
channels = append(channels, c)
}
return channels, rows.Err()
}
// QueuedMessages returns undelivered messages for this user.
func (u *User) QueuedMessages(ctx context.Context) ([]*Message, error) {
rows, err := u.GetDB().QueryContext(ctx, `
SELECT m.id, m.ts, m.from_user_id, m.from_nick,
m.target, m.type, m.body, m.meta, m.created_at
FROM messages m
JOIN message_queue mq ON mq.message_id = m.id
WHERE mq.user_id = ?
ORDER BY mq.queued_at ASC`,
u.ID,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
messages := []*Message{}
for rows.Next() {
msg := &Message{}
msg.SetDB(u.db)
err = rows.Scan(
&msg.ID, &msg.Timestamp, &msg.FromUserID,
&msg.FromNick, &msg.Target, &msg.Type,
&msg.Body, &msg.Meta, &msg.CreatedAt,
)
if err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, rows.Err()
}

View File

@@ -4,6 +4,6 @@ import "time"
const (
httpReadTimeout = 10 * time.Second
httpWriteTimeout = 60 * time.Second
httpWriteTimeout = 10 * time.Second
maxHeaderBytes = 1 << 20
)

View File

@@ -16,7 +16,7 @@ import (
const routeTimeout = 60 * time.Second
// SetupRoutes configures the HTTP routes and middleware.
// SetupRoutes configures the HTTP routes and middleware chain.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
@@ -39,19 +39,13 @@ func (s *Server) SetupRoutes() {
}
// Health check
s.router.Get(
"/.well-known/healthcheck.json",
s.h.HandleHealthCheck(),
)
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
// Protected metrics endpoint
if viper.GetString("METRICS_USERNAME") != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
))
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
})
}
@@ -59,60 +53,44 @@ func (s *Server) SetupRoutes() {
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/server", s.h.HandleServerInfo())
r.Post("/session", s.h.HandleCreateSession())
// Unified state and message endpoints
r.Get("/state", s.h.HandleState())
r.Get("/messages", s.h.HandleGetMessages())
r.Post("/messages", s.h.HandleSendCommand())
r.Get("/history", s.h.HandleGetHistory())
// Channels
r.Get("/channels", s.h.HandleListAllChannels())
r.Get(
"/channels/{channel}/members",
s.h.HandleChannelMembers(),
)
r.Get("/channels/{channel}/members", s.h.HandleChannelMembers())
})
// Serve embedded SPA
s.setupSPA()
}
func (s *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist")
if err != nil {
s.log.Error(
"failed to get web dist filesystem",
"error", err,
)
s.log.Error("failed to get web dist filesystem", "error", err)
return
}
fileServer := http.FileServer(http.FS(distFS))
s.router.Get("/*", func(
w http.ResponseWriter,
r *http.Request,
) {
s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
readFS, ok := distFS.(fs.ReadFileFS)
if !ok {
fileServer.ServeHTTP(w, r)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
f, readErr := readFS.ReadFile(r.URL.Path[1:])
if readErr != nil || len(f) == 0 {
indexHTML, indexErr := readFS.ReadFile(
"index.html",
)
if indexErr != nil {
http.NotFound(w, r)
f, err := readFS.ReadFile(r.URL.Path[1:])
if err != nil || len(f) == 0 {
indexHTML, _ := readFS.ReadFile("index.html")
return
}
w.Header().Set(
"Content-Type",
"text/html; charset=utf-8",
)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexHTML)

View File

@@ -148,9 +148,7 @@ func (s *Server) cleanupForExit() {
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout,
)
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil {

465
web/dist/app.js vendored

File diff suppressed because one or more lines are too long