Compare commits
1 Commits
feature/lo
...
fix/golang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bcedd8d4 |
@@ -1,9 +1,9 @@
|
|||||||
.git
|
.git
|
||||||
*.md
|
node_modules
|
||||||
!README.md
|
.DS_Store
|
||||||
neoircd
|
bin/
|
||||||
neoirc-cli
|
|
||||||
data.db
|
data.db
|
||||||
data.db-wal
|
|
||||||
data.db-shm
|
|
||||||
.env
|
.env
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
debug.log
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,8 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
web/dist/
|
/chatd
|
||||||
/neoircd
|
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -35,5 +34,5 @@ vendor/
|
|||||||
# Project
|
# Project
|
||||||
data.db
|
data.db
|
||||||
debug.log
|
debug.log
|
||||||
/neoirc-cli
|
/chat-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ run:
|
|||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
disable:
|
disable:
|
||||||
- wsl # Deprecated in v2, replaced by wsl_v5
|
# Genuinely incompatible with project patterns
|
||||||
settings:
|
- 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:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
funlen:
|
funlen:
|
||||||
@@ -18,17 +25,6 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
gosec:
|
|
||||||
excludes:
|
|
||||||
- G704
|
|
||||||
depguard:
|
|
||||||
rules:
|
|
||||||
all:
|
|
||||||
deny:
|
|
||||||
- pkg: "io/ioutil"
|
|
||||||
desc: "Deprecated; use io and os packages."
|
|
||||||
- pkg: "math/rand$"
|
|
||||||
desc: "Use crypto/rand for security-sensitive code."
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
||||||
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
||||||
3. **Test**: `go test -race ./...` — all passing
|
3. **Test**: `go test -race ./...` — all passing
|
||||||
4. **Build**: `go build ./cmd/neoircd` — compiles clean
|
4. **Build**: `go build ./cmd/chatd` — compiles clean
|
||||||
|
|
||||||
No commit lands on main with lint errors, test failures, or formatting issues.
|
No commit lands on main with lint errors, test failures, or formatting issues.
|
||||||
|
|
||||||
|
|||||||
58
Dockerfile
58
Dockerfile
@@ -1,59 +1,29 @@
|
|||||||
# Web build stage — compile SPA from source
|
|
||||||
# node:22-alpine, 2026-03-09
|
|
||||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
|
||||||
WORKDIR /web
|
|
||||||
COPY web/package.json web/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY web/src/ src/
|
|
||||||
COPY web/build.sh build.sh
|
|
||||||
RUN sh build.sh
|
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
|
||||||
# without depending on the web-builder stage (lint should fail fast)
|
|
||||||
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
# golang:1.24-alpine, 2026-02-26
|
# golang:1.24-alpine, 2026-02-26
|
||||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git build-base
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache git build-base make
|
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist/ web/dist/
|
|
||||||
|
|
||||||
RUN make test
|
# Run all checks — build fails if branch is not green
|
||||||
|
# golangci-lint v2.1.6
|
||||||
|
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
|
||||||
|
RUN make check
|
||||||
|
|
||||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
|
RUN go build -ldflags "-X main.Version=${VERSION}" -o /chatd ./cmd/chatd
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# alpine:3.21, 2026-02-26
|
# alpine:3.21, 2026-02-26
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
RUN apk add --no-cache ca-certificates \
|
|
||||||
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
|
|
||||||
&& mkdir -p /var/lib/neoirc \
|
|
||||||
&& chown neoirc:neoirc /var/lib/neoirc
|
|
||||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
|
||||||
|
|
||||||
USER neoirc
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
EXPOSE 8080
|
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"]
|
||||||
ENTRYPOINT ["neoircd"]
|
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||||
|
|
||||||
BINARY := neoircd
|
BINARY := chatd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
BUILDARCH := $(shell go env GOARCH)
|
BUILDARCH := $(shell go env GOARCH)
|
||||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||||
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -27,7 +27,7 @@ test:
|
|||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
check: test lint fmt-check
|
check: test lint fmt-check
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
@@ -37,10 +37,10 @@ debug: build
|
|||||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/ neoircd
|
rm -rf bin/ chatd data.db
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t neoirc .
|
docker build -t chat .
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-03-09
|
last_modified: 2026-02-22
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,13 +98,6 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
|
||||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
|
||||||
should generate these at build time. Notable exception: Go protobuf generated
|
|
||||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
|
||||||
downloads code but does not execute code generation.
|
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -151,14 +144,8 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
Post-1.0.0: add new migration files.
|
||||||
tracking table itself. Nothing else.
|
|
||||||
- `001_schema.sql` — the full application schema.
|
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
||||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
||||||
Never edit existing migrations after release.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
292
cmd/chat-cli/api/client.go
Normal file
292
cmd/chat-cli/api/client.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
// Package chatapi provides a client for the chat server HTTP API.
|
||||||
|
package chatapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
httpTimeout = 30 * time.Second
|
||||||
|
pollExtraDelay = 5
|
||||||
|
httpErrThreshold = 400
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrHTTP is returned for non-2xx responses.
|
||||||
|
var ErrHTTP = errors.New("http error")
|
||||||
|
|
||||||
|
// ErrUnexpectedFormat is returned when the response format is
|
||||||
|
// not recognised.
|
||||||
|
var ErrUnexpectedFormat = errors.New("unexpected format")
|
||||||
|
|
||||||
|
// Client wraps HTTP calls to the chat server API.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new API client.
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: httpTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new session on the server.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp SessionResponse
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Token = resp.Token
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the current user state.
|
||||||
|
func (c *Client) GetState() (*StateResponse, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/state", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp StateResponse
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message (any IRC command).
|
||||||
|
func (c *Client) SendMessage(msg *Message) error {
|
||||||
|
_, err := c.do("POST", "/api/v1/messages", msg)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollMessages long-polls for new messages.
|
||||||
|
func (c *Client) PollMessages(
|
||||||
|
afterID string,
|
||||||
|
timeout int,
|
||||||
|
) ([]Message, error) {
|
||||||
|
pollTimeout := time.Duration(
|
||||||
|
timeout+pollExtraDelay,
|
||||||
|
) * time.Second
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: pollTimeout}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
if afterID != "" {
|
||||||
|
params.Set("after", afterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Set("timeout", strconv.Itoa(timeout))
|
||||||
|
|
||||||
|
path := "/api/v1/messages"
|
||||||
|
if len(params) > 0 {
|
||||||
|
path += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest( //nolint:noctx // CLI tool
|
||||||
|
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,nolintlint // URL from user config
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= httpErrThreshold {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: %d: %s",
|
||||||
|
ErrHTTP, resp.StatusCode, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeMessages(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMessages(data []byte) ([]Message, error) {
|
||||||
|
var msgs []Message
|
||||||
|
|
||||||
|
err := json.Unmarshal(data, &msgs)
|
||||||
|
if err == nil {
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapped MessagesResponse
|
||||||
|
|
||||||
|
err2 := json.Unmarshal(data, &wrapped)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"decode messages: %w (raw: %s)",
|
||||||
|
err, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped.Messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinChannel joins a channel via the unified command
|
||||||
|
// endpoint.
|
||||||
|
func (c *Client) JoinChannel(channel string) error {
|
||||||
|
return c.SendMessage(
|
||||||
|
&Message{Command: "JOIN", To: 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},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannels returns all channels on the server.
|
||||||
|
func (c *Client) ListChannels() ([]Channel, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/channels", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var channels []Channel
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &channels)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembers returns members of a channel.
|
||||||
|
func (c *Client) GetMembers(
|
||||||
|
channel string,
|
||||||
|
) ([]string, error) {
|
||||||
|
path := "/api/v1/channels/" +
|
||||||
|
url.PathEscape(channel) + "/members"
|
||||||
|
|
||||||
|
data, err := c.do("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var members []string
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &members)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: members: %s",
|
||||||
|
ErrUnexpectedFormat, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerInfo returns server info.
|
||||||
|
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/server", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ServerInfo
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(
|
||||||
|
method, path string,
|
||||||
|
body any,
|
||||||
|
) ([]byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest( //nolint:noctx // CLI tool
|
||||||
|
method, c.BaseURL+path, bodyReader,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req) //nolint:gosec,nolintlint // URL from user config
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= httpErrThreshold {
|
||||||
|
return data, fmt.Errorf(
|
||||||
|
"%w: %d: %s",
|
||||||
|
ErrHTTP, resp.StatusCode, string(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
package neoircapi
|
package chatapi
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// SessionRequest is the body for POST /api/v1/session.
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
type SessionRequest struct {
|
type SessionRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionResponse is the response from session creation.
|
// SessionResponse is the response from POST /api/v1/session.
|
||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
ID int64 `json:"id"`
|
SessionID string `json:"sessionId"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
type StateResponse struct {
|
type StateResponse struct {
|
||||||
ID int64 `json:"id"`
|
SessionID string `json:"sessionId"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a neoirc message envelope.
|
// Message represents a chat message envelope.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
@@ -34,21 +35,22 @@ type Message struct {
|
|||||||
Meta any `json:"meta,omitempty"`
|
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 {
|
func (m *Message) BodyLines() []string {
|
||||||
switch bodyVal := m.Body.(type) {
|
switch v := m.Body.(type) {
|
||||||
case []any:
|
case []any:
|
||||||
lines := make([]string, 0, len(bodyVal))
|
lines := make([]string, 0, len(v))
|
||||||
|
|
||||||
for _, item := range bodyVal {
|
for _, item := range v {
|
||||||
if str, ok := item.(string); ok {
|
if s, ok := item.(string); ok {
|
||||||
lines = append(lines, str)
|
lines = append(lines, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
case []string:
|
case []string:
|
||||||
return bodyVal
|
return v
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -67,19 +69,11 @@ type ServerInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
type MessagesResponse struct {
|
type MessagesResponse struct {
|
||||||
Messages []Message `json:"messages"`
|
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.
|
// ParseTS parses the message timestamp.
|
||||||
850
cmd/chat-cli/main.go
Normal file
850
cmd/chat-cli/main.go
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
// Package main implements chat-cli, an IRC-style terminal client.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pollTimeoutSec = 15
|
||||||
|
retryDelay = 2 * time.Second
|
||||||
|
maxNickLength = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// App holds the application state.
|
||||||
|
type App struct {
|
||||||
|
ui *UI
|
||||||
|
client *api.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nick string
|
||||||
|
target string // current target (#channel or nick for DM)
|
||||||
|
connected bool
|
||||||
|
lastMsgID string
|
||||||
|
stopPoll chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := &App{
|
||||||
|
ui: NewUI(),
|
||||||
|
nick: "guest",
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ui.OnInput(app.handleInput)
|
||||||
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
|
app.ui.AddStatus(
|
||||||
|
"Welcome to chat-cli \u2014 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 {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleInput(text string) {
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
a.handleCommand(text)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sendPlainText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) sendPlainText(text string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Not connected. Use /connect <url>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]No target. " +
|
||||||
|
"Use /join #channel or /query nick",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Send error: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Format("15:04")
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
ts, nick, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCommand(text string) { //nolint:cyclop // command dispatch
|
||||||
|
parts := strings.SplitN(text, " ", 2) //nolint:mnd // split into cmd+args
|
||||||
|
cmd := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
args := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
args = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdConnect(serverURL string) {
|
||||||
|
if serverURL == "" {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
"[red]Usage: /connect <server-url>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL = strings.TrimRight(serverURL, "/")
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("Connecting to %s...", serverURL),
|
||||||
|
)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
client := api.NewClient(serverURL)
|
||||||
|
|
||||||
|
resp, err := client.CreateSession(nick)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Connection failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.client = client
|
||||||
|
a.nick = resp.Nick
|
||||||
|
a.connected = true
|
||||||
|
a.lastMsgID = ""
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[green]Connected! Nick: %s, Session: %s",
|
||||||
|
resp.Nick, resp.SessionID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||||
|
|
||||||
|
a.stopPoll = make(chan struct{})
|
||||||
|
|
||||||
|
go a.pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNick(nick string) {
|
||||||
|
if nick == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /nick <name>")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Nick set to %s (will be used on connect)",
|
||||||
|
nick,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "NICK",
|
||||||
|
Body: []string{nick},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Nick change failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
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")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(channel, "#") {
|
||||||
|
channel = "#" + channel
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.JoinChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Join failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = channel
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(channel)
|
||||||
|
a.ui.AddLine(
|
||||||
|
channel,
|
||||||
|
"[yellow]*** Joined "+channel,
|
||||||
|
)
|
||||||
|
a.ui.SetStatus(nick, channel, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdPart(channel string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
if channel == "" {
|
||||||
|
channel = a.target
|
||||||
|
}
|
||||||
|
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if channel == "" || !strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus("[red]No channel to part")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.PartChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Part failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
channel,
|
||||||
|
"[yellow]*** Left "+channel,
|
||||||
|
)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
if a.target == channel {
|
||||||
|
a.target = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchBuffer(0)
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMsg(args string) {
|
||||||
|
parts := strings.SplitN(args, " ", 2) //nolint:mnd // split into target+text
|
||||||
|
if len(parts) < 2 { //nolint:mnd // min args
|
||||||
|
a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target, text := parts[0], parts[1]
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]Send failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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>")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = nick
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(nick)
|
||||||
|
a.ui.SetStatus(myNick, nick, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdTopic(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "TOPIC",
|
||||||
|
To: target,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Topic query failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "TOPIC",
|
||||||
|
To: target,
|
||||||
|
Body: []string{args},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[red]Topic set failed: %v", err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNames() {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := a.client.GetMembers(target)
|
||||||
|
if err != nil {
|
||||||
|
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, " "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdList() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := a.client.ListChannels()
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf("[red]List failed: %v", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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("[cyan]*** End of channel list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWindow(args string) {
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /window <number>")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, _ := strconv.Atoi(args)
|
||||||
|
a.ui.SwitchBuffer(n)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if n >= 0 && 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")
|
||||||
|
} else {
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdQuit() {
|
||||||
|
a.mu.Lock()
|
||||||
|
|
||||||
|
if a.connected && a.client != nil {
|
||||||
|
_ = a.client.SendMessage(
|
||||||
|
&api.Message{Command: "QUIT"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.stopPoll != nil {
|
||||||
|
close(a.stopPoll)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdHelp() {
|
||||||
|
help := []string{
|
||||||
|
"[cyan]*** chat-cli commands:",
|
||||||
|
" /connect <url> \u2014 Connect to server",
|
||||||
|
" /nick <name> \u2014 Change nickname",
|
||||||
|
" /join #channel \u2014 Join channel",
|
||||||
|
" /part [#chan] \u2014 Leave channel",
|
||||||
|
" /msg <nick> <text> \u2014 Send DM",
|
||||||
|
" /query <nick> \u2014 Open DM window",
|
||||||
|
" /topic [text] \u2014 View/set topic",
|
||||||
|
" /names \u2014 List channel members",
|
||||||
|
" /list \u2014 List channels",
|
||||||
|
" /window <n> \u2014 Switch buffer (Alt+0-9)",
|
||||||
|
" /quit \u2014 Disconnect and exit",
|
||||||
|
" /help \u2014 This help",
|
||||||
|
" Plain text sends to current target.",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range help {
|
||||||
|
a.ui.AddStatus(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop long-polls for messages in the background.
|
||||||
|
func (a *App) pollLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.stopPoll:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
client := a.client
|
||||||
|
lastID := a.lastMsgID
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := client.PollMessages(
|
||||||
|
lastID, pollTimeoutSec,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range msgs {
|
||||||
|
a.handleServerMessage(&msgs[i])
|
||||||
|
|
||||||
|
if msgs[i].ID != "" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.lastMsgID = msgs[i].ID
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleServerMessage(
|
||||||
|
msg *api.Message,
|
||||||
|
) {
|
||||||
|
ts := a.parseMessageTS(msg)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
switch msg.Command {
|
||||||
|
case "PRIVMSG":
|
||||||
|
a.handlePrivmsgMsg(msg, ts, myNick)
|
||||||
|
case "JOIN":
|
||||||
|
a.handleJoinMsg(msg, ts)
|
||||||
|
case "PART":
|
||||||
|
a.handlePartMsg(msg, ts)
|
||||||
|
case "QUIT":
|
||||||
|
a.handleQuitMsg(msg, ts)
|
||||||
|
case "NICK":
|
||||||
|
a.handleNickMsg(msg, ts, myNick)
|
||||||
|
case "NOTICE":
|
||||||
|
a.handleNoticeMsg(msg, ts)
|
||||||
|
case "TOPIC":
|
||||||
|
a.handleTopicMsg(msg, ts)
|
||||||
|
default:
|
||||||
|
a.handleDefaultMsg(msg, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) parseMessageTS(msg *api.Message) string {
|
||||||
|
if msg.TS != "" {
|
||||||
|
t := msg.ParseTS()
|
||||||
|
|
||||||
|
return t.In(time.Local).Format("15:04") //nolint:gosmopolitan // CLI uses local time
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Now().Format("15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePrivmsgMsg(
|
||||||
|
msg *api.Message,
|
||||||
|
ts, myNick string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if msg.From == myNick {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := msg.To
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
target = msg.From
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [green]<%s>[white] %s",
|
||||||
|
ts, msg.From, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleJoinMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
target := msg.To
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has joined %s",
|
||||||
|
ts, msg.From, target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handlePartMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
target := msg.To
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if reason != "" {
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||||
|
ts, msg.From, target, reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
a.ui.AddLine(
|
||||||
|
target,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [yellow]*** %s has left %s",
|
||||||
|
ts, msg.From, target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleQuitMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleNickMsg(
|
||||||
|
msg *api.Message, ts, myNick string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
|
||||||
|
newNick := ""
|
||||||
|
if len(lines) > 0 {
|
||||||
|
newNick = lines[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleNoticeMsg(
|
||||||
|
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) handleTopicMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
if msg.To == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) handleDefaultMsg(
|
||||||
|
msg *api.Message, ts string,
|
||||||
|
) {
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"[gray]%s [white][%s] %s",
|
||||||
|
ts, msg.Command, text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package cli
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -32,38 +32,25 @@ type UI struct {
|
|||||||
|
|
||||||
// NewUI creates the tview-based IRC-like UI.
|
// NewUI creates the tview-based IRC-like UI.
|
||||||
func NewUI() *UI {
|
func NewUI() *UI {
|
||||||
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic
|
ui := &UI{
|
||||||
app: tview.NewApplication(),
|
app: tview.NewApplication(),
|
||||||
buffers: []*Buffer{
|
buffers: []*Buffer{
|
||||||
{Name: "(status)", Lines: nil, Unread: 0},
|
{Name: "(status)", Lines: nil},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.initMessages()
|
ui.setupMessages()
|
||||||
ui.initStatusBar()
|
ui.setupStatusBar()
|
||||||
ui.initInput()
|
ui.setupInput()
|
||||||
ui.initKeyCapture()
|
ui.setupKeybindings()
|
||||||
|
ui.setupLayout()
|
||||||
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)
|
|
||||||
|
|
||||||
ui.app.SetRoot(ui.layout, true)
|
|
||||||
ui.app.SetFocus(ui.input)
|
|
||||||
|
|
||||||
return ui
|
return ui
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the UI event loop (blocks).
|
// Run starts the UI event loop (blocks).
|
||||||
func (ui *UI) Run() error {
|
func (ui *UI) Run() error {
|
||||||
err := ui.app.Run()
|
return ui.app.Run()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run ui: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the UI.
|
// Stop stops the UI.
|
||||||
@@ -77,43 +64,45 @@ func (ui *UI) OnInput(fn func(string)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddLine adds a line to the specified buffer.
|
// 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() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
buf := ui.getOrCreateBuffer(bufferName)
|
buf := ui.getOrCreateBuffer(bufferName)
|
||||||
buf.Lines = append(buf.Lines, line)
|
buf.Lines = append(buf.Lines, line)
|
||||||
|
|
||||||
cur := ui.buffers[ui.currentBuffer]
|
// Mark unread if not currently viewing this buffer.
|
||||||
if cur != buf {
|
if ui.buffers[ui.currentBuffer] != buf {
|
||||||
buf.Unread++
|
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)
|
_, _ = 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) {
|
func (ui *UI) AddStatus(line string) {
|
||||||
ts := time.Now().Format("15:04")
|
ts := time.Now().Format("15:04")
|
||||||
|
|
||||||
ui.AddLine(
|
ui.AddLine(
|
||||||
"(status)",
|
"(status)",
|
||||||
"[gray]"+ts+"[white] "+line,
|
fmt.Sprintf("[gray]%s[white] %s", ts, line),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchBuffer switches to the buffer at index n.
|
// SwitchBuffer switches to the buffer at index n.
|
||||||
func (ui *UI) SwitchBuffer(bufIndex int) {
|
func (ui *UI) SwitchBuffer(n int) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
|
if n < 0 || n >= len(ui.buffers) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.currentBuffer = bufIndex
|
ui.currentBuffer = n
|
||||||
|
|
||||||
buf := ui.buffers[bufIndex]
|
buf := ui.buffers[n]
|
||||||
buf.Unread = 0
|
buf.Unread = 0
|
||||||
|
|
||||||
ui.messages.Clear()
|
ui.messages.Clear()
|
||||||
@@ -123,12 +112,11 @@ func (ui *UI) SwitchBuffer(bufIndex int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.messages.ScrollToEnd()
|
ui.messages.ScrollToEnd()
|
||||||
ui.refreshStatusBar()
|
ui.refreshStatus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchToBuffer switches to named buffer, creating if
|
// SwitchToBuffer switches to the named buffer, creating it
|
||||||
// needed.
|
|
||||||
func (ui *UI) SwitchToBuffer(name string) {
|
func (ui *UI) SwitchToBuffer(name string) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
buf := ui.getOrCreateBuffer(name)
|
buf := ui.getOrCreateBuffer(name)
|
||||||
@@ -150,7 +138,7 @@ func (ui *UI) SwitchToBuffer(name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.messages.ScrollToEnd()
|
ui.messages.ScrollToEnd()
|
||||||
ui.refreshStatusBar()
|
ui.refreshStatus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +147,7 @@ func (ui *UI) SetStatus(
|
|||||||
nick, target, connStatus string,
|
nick, target, connStatus string,
|
||||||
) {
|
) {
|
||||||
ui.app.QueueUpdateDraw(func() {
|
ui.app.QueueUpdateDraw(func() {
|
||||||
ui.renderStatusBar(nick, target, connStatus)
|
ui.refreshStatusWith(nick, target, connStatus)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +156,7 @@ func (ui *UI) BufferCount() int {
|
|||||||
return len(ui.buffers)
|
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 {
|
func (ui *UI) BufferIndex(name string) int {
|
||||||
for i, buf := range ui.buffers {
|
for i, buf := range ui.buffers {
|
||||||
if buf.Name == name {
|
if buf.Name == name {
|
||||||
@@ -179,7 +167,7 @@ func (ui *UI) BufferIndex(name string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) initMessages() {
|
func (ui *UI) setupMessages() {
|
||||||
ui.messages = tview.NewTextView().
|
ui.messages = tview.NewTextView().
|
||||||
SetDynamicColors(true).
|
SetDynamicColors(true).
|
||||||
SetScrollable(true).
|
SetScrollable(true).
|
||||||
@@ -190,14 +178,14 @@ func (ui *UI) initMessages() {
|
|||||||
ui.messages.SetBorder(false)
|
ui.messages.SetBorder(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) initStatusBar() {
|
func (ui *UI) setupStatusBar() {
|
||||||
ui.statusBar = tview.NewTextView().
|
ui.statusBar = tview.NewTextView().
|
||||||
SetDynamicColors(true)
|
SetDynamicColors(true)
|
||||||
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
||||||
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) initInput() {
|
func (ui *UI) setupInput() {
|
||||||
ui.input = tview.NewInputField().
|
ui.input = tview.NewInputField().
|
||||||
SetFieldBackgroundColor(tcell.ColorBlack).
|
SetFieldBackgroundColor(tcell.ColorBlack).
|
||||||
SetFieldTextColor(tcell.ColorWhite)
|
SetFieldTextColor(tcell.ColorWhite)
|
||||||
@@ -220,7 +208,7 @@ func (ui *UI) initInput() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) initKeyCapture() {
|
func (ui *UI) setupKeybindings() {
|
||||||
ui.app.SetInputCapture(
|
ui.app.SetInputCapture(
|
||||||
func(event *tcell.EventKey) *tcell.EventKey {
|
func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
if event.Modifiers()&tcell.ModAlt == 0 {
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
||||||
@@ -240,21 +228,34 @@ func (ui *UI) initKeyCapture() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) refreshStatusBar() {
|
func (ui *UI) setupLayout() {
|
||||||
// Placeholder; full refresh needs nick/target context.
|
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)
|
||||||
|
|
||||||
|
ui.app.SetRoot(ui.layout, true)
|
||||||
|
ui.app.SetFocus(ui.input)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ui *UI) renderStatusBar(
|
// if needed.
|
||||||
|
|
||||||
|
func (ui *UI) refreshStatus() {
|
||||||
|
// Rebuilt from app state by parent QueueUpdateDraw.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) refreshStatusWith(
|
||||||
nick, target, connStatus string,
|
nick, target, connStatus string,
|
||||||
) {
|
) {
|
||||||
var unreadParts []string
|
var unreadParts []string
|
||||||
|
|
||||||
for i, buf := range ui.buffers {
|
for i, buf := range ui.buffers {
|
||||||
if buf.Unread > 0 {
|
if buf.Unread > 0 {
|
||||||
unreadParts = append(unreadParts,
|
unreadParts = append(
|
||||||
|
unreadParts,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"%d:%s(%d)",
|
"%d:%s(%d)", i, buf.Name, buf.Unread,
|
||||||
i, buf.Name, buf.Unread,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -274,8 +275,8 @@ func (ui *UI) renderStatusBar(
|
|||||||
|
|
||||||
ui.statusBar.Clear()
|
ui.statusBar.Clear()
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(ui.statusBar,
|
_, _ = fmt.Fprintf(
|
||||||
" [%s] %s %s %s%s",
|
ui.statusBar, " [%s] %s %s %s%s",
|
||||||
connStatus, nick, bufInfo, target, unread,
|
connStatus, nick, bufInfo, target, unread,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -287,7 +288,7 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := &Buffer{Name: name, Lines: nil, Unread: 0}
|
buf := &Buffer{Name: name}
|
||||||
ui.buffers = append(ui.buffers, buf)
|
ui.buffers = append(ui.buffers, buf)
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
41
cmd/chatd/main.go
Normal file
41
cmd/chatd/main.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Package main is the entry point for the chatd server.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/server"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Appname is the application name, set at build time.
|
||||||
|
Appname = "chat" //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// Version is the application version, set at build time.
|
||||||
|
Version string //nolint:gochecknoglobals
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
globals.Appname = Appname
|
||||||
|
globals.Version = Version
|
||||||
|
|
||||||
|
fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
config.New,
|
||||||
|
db.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
logger.New,
|
||||||
|
server.New,
|
||||||
|
middleware.New,
|
||||||
|
healthcheck.New,
|
||||||
|
),
|
||||||
|
fx.Invoke(func(*server.Server) {}),
|
||||||
|
).Run()
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cli.Run()
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Package main is the entry point for the neoircd server.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Appname is the application name, set at build time.
|
|
||||||
Appname = "neoirc" //nolint:gochecknoglobals
|
|
||||||
|
|
||||||
// Version is the application version, set at build time.
|
|
||||||
Version string //nolint:gochecknoglobals
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
|
||||||
|
|
||||||
fx.New(
|
|
||||||
fx.Provide(
|
|
||||||
config.New,
|
|
||||||
db.New,
|
|
||||||
globals.New,
|
|
||||||
handlers.New,
|
|
||||||
logger.New,
|
|
||||||
server.New,
|
|
||||||
middleware.New,
|
|
||||||
healthcheck.New,
|
|
||||||
stats.New,
|
|
||||||
),
|
|
||||||
fx.Invoke(func(*server.Server) {}),
|
|
||||||
).Run()
|
|
||||||
}
|
|
||||||
18
go.mod
18
go.mod
@@ -1,22 +1,17 @@
|
|||||||
module git.eeqj.de/sneak/neoirc
|
module git.eeqj.de/sneak/chat
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rivo/tview v0.42.0
|
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
|
||||||
golang.org/x/time v0.6.0
|
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +21,9 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
@@ -36,6 +33,7 @@ require (
|
|||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/tview v0.42.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
@@ -49,9 +47,9 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
36
go.sum
36
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
@@ -113,14 +113,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
@@ -128,8 +126,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -137,28 +135,30 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
|||||||
@@ -1,73 +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{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Broker.
|
|
||||||
func New() *Broker {
|
|
||||||
return &Broker{ //nolint:exhaustruct // mu has zero-value default
|
|
||||||
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{} {
|
|
||||||
waitCh := make(chan struct{}, 1)
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
b.listeners[userID] = append(
|
|
||||||
b.listeners[userID], waitCh,
|
|
||||||
)
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
return waitCh
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 _, waiter := range waiters {
|
|
||||||
select {
|
|
||||||
case waiter <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a specific wait channel (for cleanup on timeout).
|
|
||||||
func (b *Broker) Remove(
|
|
||||||
userID int64,
|
|
||||||
waitCh chan struct{},
|
|
||||||
) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
waiters := b.listeners[userID]
|
|
||||||
|
|
||||||
for i, waiter := range waiters {
|
|
||||||
if waiter == waitCh {
|
|
||||||
b.listeners[userID] = append(
|
|
||||||
waiters[:i], waiters[i+1:]...,
|
|
||||||
)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(b.listeners[userID]) == 0 {
|
|
||||||
delete(b.listeners, userID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package broker_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
if brk == nil {
|
|
||||||
t.Fatal("expected non-nil broker")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWaitAndNotify(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
waitCh := brk.Wait(1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
brk.Notify(1)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNotifyWithoutWaiters(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
brk.Notify(42) // should not panic.
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemove(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
waitCh := brk.Wait(1)
|
|
||||||
|
|
||||||
brk.Remove(1, waitCh)
|
|
||||||
|
|
||||||
brk.Notify(1)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
t.Fatal("should not receive after remove")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipleWaiters(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
waitCh1 := brk.Wait(1)
|
|
||||||
waitCh2 := brk.Wait(1)
|
|
||||||
|
|
||||||
brk.Notify(1)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCh1:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ch1 timeout")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCh2:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("ch2 timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConcurrentWaitNotify(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
|
|
||||||
var waitGroup sync.WaitGroup
|
|
||||||
|
|
||||||
const concurrency = 100
|
|
||||||
|
|
||||||
for idx := range concurrency {
|
|
||||||
waitGroup.Add(1)
|
|
||||||
|
|
||||||
go func(uid int64) {
|
|
||||||
defer waitGroup.Done()
|
|
||||||
|
|
||||||
waitCh := brk.Wait(uid)
|
|
||||||
|
|
||||||
brk.Notify(uid)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Error("timeout")
|
|
||||||
}
|
|
||||||
}(int64(idx % 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
waitGroup.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveNonexistent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
waitCh := make(chan struct{}, 1)
|
|
||||||
|
|
||||||
brk.Remove(999, waitCh) // should not panic.
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
// Package neoircapi provides a client for the neoirc server API.
|
|
||||||
package neoircapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
httpTimeout = 30 * time.Second
|
|
||||||
pollExtraTime = 5
|
|
||||||
httpErrThreshold = 400
|
|
||||||
)
|
|
||||||
|
|
||||||
var errHTTP = errors.New("HTTP error")
|
|
||||||
|
|
||||||
// Client wraps HTTP calls to the neoirc server API.
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
Token string
|
|
||||||
HTTPClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new API client.
|
|
||||||
func NewClient(baseURL string) *Client {
|
|
||||||
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
|
||||||
BaseURL: baseURL,
|
|
||||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
|
||||||
Timeout: httpTimeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
|
||||||
// If the server requires hashcash proof-of-work, it
|
|
||||||
// automatically fetches the difficulty and computes a
|
|
||||||
// valid stamp.
|
|
||||||
func (client *Client) CreateSession(
|
|
||||||
nick string,
|
|
||||||
) (*SessionResponse, error) {
|
|
||||||
// Fetch server info to check for hashcash requirement.
|
|
||||||
info, err := client.GetServerInfo()
|
|
||||||
|
|
||||||
var hashcashStamp string
|
|
||||||
|
|
||||||
if err == nil && info.HashcashBits > 0 {
|
|
||||||
resource := info.Name
|
|
||||||
if resource == "" {
|
|
||||||
resource = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := client.do(
|
|
||||||
http.MethodPost,
|
|
||||||
"/api/v1/session",
|
|
||||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp SessionResponse
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Token = resp.Token
|
|
||||||
|
|
||||||
return &resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetState returns the current user state.
|
|
||||||
func (client *Client) GetState() (*StateResponse, error) {
|
|
||||||
data, err := client.do(
|
|
||||||
http.MethodGet, "/api/v1/state", nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp StateResponse
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMessage sends a message (any IRC command).
|
|
||||||
func (client *Client) SendMessage(msg *Message) error {
|
|
||||||
_, err := client.do(
|
|
||||||
http.MethodPost, "/api/v1/messages", msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// PollMessages long-polls for new messages.
|
|
||||||
func (client *Client) PollMessages(
|
|
||||||
afterID int64,
|
|
||||||
timeout int,
|
|
||||||
) (*PollResult, error) {
|
|
||||||
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine
|
|
||||||
Timeout: time.Duration(
|
|
||||||
timeout+pollExtraTime,
|
|
||||||
) * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
params := url.Values{}
|
|
||||||
if afterID > 0 {
|
|
||||||
params.Set(
|
|
||||||
"after",
|
|
||||||
strconv.FormatInt(afterID, 10),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
params.Set("timeout", strconv.Itoa(timeout))
|
|
||||||
|
|
||||||
path := "/api/v1/messages?" + params.Encode()
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet,
|
|
||||||
client.BaseURL+path,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("new request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set(
|
|
||||||
"Authorization", "Bearer "+client.Token,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp, err := pollClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("poll request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read poll body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= httpErrThreshold {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w %d: %s",
|
|
||||||
errHTTP, resp.StatusCode, string(data),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var wrapped MessagesResponse
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &wrapped)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"decode messages: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PollResult{
|
|
||||||
Messages: wrapped.Messages,
|
|
||||||
LastID: wrapped.LastID,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinChannel joins a channel.
|
|
||||||
func (client *Client) JoinChannel(channel string) error {
|
|
||||||
return client.SendMessage(
|
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
|
||||||
Command: irc.CmdJoin, To: channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PartChannel leaves a channel.
|
|
||||||
func (client *Client) PartChannel(channel string) error {
|
|
||||||
return client.SendMessage(
|
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
|
||||||
Command: irc.CmdPart, To: channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListChannels returns all channels on the server.
|
|
||||||
func (client *Client) ListChannels() (
|
|
||||||
[]Channel, error,
|
|
||||||
) {
|
|
||||||
data, err := client.do(
|
|
||||||
http.MethodGet, "/api/v1/channels", nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var channels []Channel
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &channels)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"decode channels: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMembers returns members of a channel.
|
|
||||||
func (client *Client) GetMembers(
|
|
||||||
channel string,
|
|
||||||
) ([]string, error) {
|
|
||||||
name := strings.TrimPrefix(channel, "#")
|
|
||||||
|
|
||||||
data, err := client.do(
|
|
||||||
http.MethodGet,
|
|
||||||
"/api/v1/channels/"+url.PathEscape(name)+
|
|
||||||
"/members",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var members []string
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &members)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"unexpected members format: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return members, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetServerInfo returns server info.
|
|
||||||
func (client *Client) GetServerInfo() (
|
|
||||||
*ServerInfo, error,
|
|
||||||
) {
|
|
||||||
data, err := client.do(
|
|
||||||
http.MethodGet, "/api/v1/server", nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var info ServerInfo
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &info)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"decode server info: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) do(
|
|
||||||
method, path string,
|
|
||||||
body any,
|
|
||||||
) ([]byte, error) {
|
|
||||||
var bodyReader io.Reader
|
|
||||||
|
|
||||||
if body != nil {
|
|
||||||
data, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyReader = bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
method,
|
|
||||||
client.BaseURL+path,
|
|
||||||
bodyReader,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set(
|
|
||||||
"Content-Type", "application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
if client.Token != "" {
|
|
||||||
request.Header.Set(
|
|
||||||
"Authorization", "Bearer "+client.Token,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= httpErrThreshold {
|
|
||||||
return data, fmt.Errorf(
|
|
||||||
"%w %d: %s",
|
|
||||||
errHTTP, resp.StatusCode, string(data),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package neoircapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// bitsPerByte is the number of bits in a byte.
|
|
||||||
bitsPerByte = 8
|
|
||||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
||||||
fullByteMask = 0xFF
|
|
||||||
// counterSpace is the range for random counter seeds.
|
|
||||||
counterSpace = 1 << 48
|
|
||||||
)
|
|
||||||
|
|
||||||
// MintHashcash computes a hashcash stamp with the given
|
|
||||||
// difficulty (leading zero bits) and resource string.
|
|
||||||
func MintHashcash(bits int, resource string) string {
|
|
||||||
date := time.Now().UTC().Format("060102")
|
|
||||||
prefix := fmt.Sprintf(
|
|
||||||
"1:%d:%s:%s::", bits, date, resource,
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
counter := randomCounter()
|
|
||||||
stamp := prefix + counter
|
|
||||||
hash := sha256.Sum256([]byte(stamp))
|
|
||||||
|
|
||||||
if hasLeadingZeroBits(hash[:], bits) {
|
|
||||||
return stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if hash has at least numBits
|
|
||||||
// leading zero bits.
|
|
||||||
func hasLeadingZeroBits(
|
|
||||||
hash []byte,
|
|
||||||
numBits int,
|
|
||||||
) bool {
|
|
||||||
fullBytes := numBits / bitsPerByte
|
|
||||||
remainBits := numBits % bitsPerByte
|
|
||||||
|
|
||||||
for idx := range fullBytes {
|
|
||||||
if hash[idx] != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remainBits > 0 && fullBytes < len(hash) {
|
|
||||||
mask := byte(
|
|
||||||
fullByteMask << (bitsPerByte - remainBits),
|
|
||||||
)
|
|
||||||
|
|
||||||
if hash[fullBytes]&mask != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomCounter generates a random hex counter string.
|
|
||||||
func randomCounter() string {
|
|
||||||
counterVal, err := rand.Int(
|
|
||||||
rand.Reader, big.NewInt(counterSpace),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to timestamp-based counter on error.
|
|
||||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(counterVal.Bytes())
|
|
||||||
}
|
|
||||||
@@ -1,912 +0,0 @@
|
|||||||
// Package cli implements the neoirc-cli terminal client.
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
splitParts = 2
|
|
||||||
pollTimeout = 15
|
|
||||||
pollRetry = 2 * time.Second
|
|
||||||
timeFormat = "15:04"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App holds the application state.
|
|
||||||
type App struct {
|
|
||||||
ui *UI
|
|
||||||
client *api.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
nick string
|
|
||||||
target string
|
|
||||||
connected bool
|
|
||||||
lastQID int64
|
|
||||||
stopPoll chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run creates and runs the CLI application.
|
|
||||||
func Run() {
|
|
||||||
app := &App{ //nolint:exhaustruct
|
|
||||||
ui: NewUI(),
|
|
||||||
nick: "guest",
|
|
||||||
}
|
|
||||||
|
|
||||||
app.ui.OnInput(app.handleInput)
|
|
||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
|
||||||
|
|
||||||
app.ui.AddStatus(
|
|
||||||
"Welcome to neoirc-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 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleInput(text string) {
|
|
||||||
if strings.HasPrefix(text, "/") {
|
|
||||||
a.handleCommand(text)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Not connected. Use /connect <url>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if target == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]No target. " +
|
|
||||||
"Use /join #channel or /query nick",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdPrivmsg,
|
|
||||||
To: target,
|
|
||||||
Body: []string{text},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Send error: " + err.Error(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format(timeFormat)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, nick, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleCommand(text string) {
|
|
||||||
parts := strings.SplitN(text, " ", splitParts)
|
|
||||||
cmd := strings.ToLower(parts[0])
|
|
||||||
|
|
||||||
args := ""
|
|
||||||
if len(parts) > 1 {
|
|
||||||
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 "/window", "/w":
|
|
||||||
a.cmdWindow(args)
|
|
||||||
case "/quit":
|
|
||||||
a.cmdQuit()
|
|
||||||
case "/help":
|
|
||||||
a.cmdHelp()
|
|
||||||
default:
|
|
||||||
a.dispatchInfoCommand(cmd, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
|
||||||
switch cmd {
|
|
||||||
case "/names":
|
|
||||||
a.cmdNames()
|
|
||||||
case "/list":
|
|
||||||
a.cmdList()
|
|
||||||
case "/motd":
|
|
||||||
a.cmdMotd()
|
|
||||||
case "/who":
|
|
||||||
a.cmdWho(args)
|
|
||||||
case "/whois":
|
|
||||||
a.cmdWhois(args)
|
|
||||||
default:
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Unknown command: " + cmd,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdConnect(serverURL string) {
|
|
||||||
if serverURL == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /connect <server-url>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverURL = strings.TrimRight(serverURL, "/")
|
|
||||||
|
|
||||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
client := api.NewClient(serverURL)
|
|
||||||
|
|
||||||
resp, err := client.CreateSession(nick)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Connection failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.client = client
|
|
||||||
a.nick = resp.Nick
|
|
||||||
a.connected = true
|
|
||||||
a.lastQID = 0
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[green]Connected! Nick: %s, Session: %d",
|
|
||||||
resp.Nick, resp.ID,
|
|
||||||
))
|
|
||||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
|
||||||
|
|
||||||
a.stopPoll = make(chan struct{})
|
|
||||||
|
|
||||||
go a.pollLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdNick(nick string) {
|
|
||||||
if nick == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /nick <name>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.nick = nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"Nick set to " + nick +
|
|
||||||
" (will be used on connect)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdNick,
|
|
||||||
Body: []string{nick},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Nick change failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(channel, "#") {
|
|
||||||
channel = "#" + channel
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.JoinChannel(channel)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Join failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = channel
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(channel)
|
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Joined "+channel,
|
|
||||||
)
|
|
||||||
a.ui.SetStatus(nick, channel, "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdPart(channel string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
if channel == "" {
|
|
||||||
channel = a.target
|
|
||||||
}
|
|
||||||
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if channel == "" ||
|
|
||||||
!strings.HasPrefix(channel, "#") {
|
|
||||||
a.ui.AddStatus("[red]No channel to part")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.PartChannel(channel)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Part failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(channel,
|
|
||||||
"[yellow]*** Left "+channel,
|
|
||||||
)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
if a.target == channel {
|
|
||||||
a.target = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchBuffer(0)
|
|
||||||
a.ui.SetStatus(nick, "", "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdMsg(args string) {
|
|
||||||
parts := strings.SplitN(args, " ", splitParts)
|
|
||||||
if len(parts) < splitParts {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /msg <nick> <text>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target, text := parts[0], parts[1]
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdPrivmsg,
|
|
||||||
To: target,
|
|
||||||
Body: []string{text},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Send failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format(timeFormat)
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, nick, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdQuery(nick string) {
|
|
||||||
if nick == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /query <nick>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = nick
|
|
||||||
myNick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SwitchToBuffer(nick)
|
|
||||||
a.ui.SetStatus(myNick, nick, "connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdTopic(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if args == "" {
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdTopic,
|
|
||||||
To: target,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Topic query failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdTopic,
|
|
||||||
To: target,
|
|
||||||
Body: []string{args},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]Topic set failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdNames() {
|
|
||||||
a.mu.Lock()
|
|
||||||
target := a.target
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
a.ui.AddStatus("[red]Not in a channel")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := a.client.GetMembers(target)
|
|
||||||
if err != nil {
|
|
||||||
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, " "),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdList() {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, err := a.client.ListChannels()
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]List failed: %v", err,
|
|
||||||
))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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("[cyan]*** End of channel list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdMotd() {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]MOTD failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWho(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
target := a.target
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channel := args
|
|
||||||
if channel == "" {
|
|
||||||
channel = target
|
|
||||||
}
|
|
||||||
|
|
||||||
if channel == "" ||
|
|
||||||
!strings.HasPrefix(channel, "#") {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /who #channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWho, To: channel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHO failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWhois(args string) {
|
|
||||||
a.mu.Lock()
|
|
||||||
connected := a.connected
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if !connected {
|
|
||||||
a.ui.AddStatus("[red]Not connected")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if args == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /whois <nick>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
|
||||||
&api.Message{ //nolint:exhaustruct
|
|
||||||
Command: irc.CmdWhois, To: args,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[red]WHOIS failed: %v", err,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdWindow(args string) {
|
|
||||||
if args == "" {
|
|
||||||
a.ui.AddStatus(
|
|
||||||
"[red]Usage: /window <number>",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var bufIndex int
|
|
||||||
|
|
||||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
|
||||||
|
|
||||||
a.ui.SwitchBuffer(bufIndex)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
nick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
|
||||||
buf := a.ui.buffers[bufIndex]
|
|
||||||
if buf.Name != "(status)" {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.target = buf.Name
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
a.ui.SetStatus(
|
|
||||||
nick, buf.Name, "connected",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
a.ui.SetStatus(nick, "", "connected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdQuit() {
|
|
||||||
a.mu.Lock()
|
|
||||||
|
|
||||||
if a.connected && a.client != nil {
|
|
||||||
_ = a.client.SendMessage(
|
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.stopPoll != nil {
|
|
||||||
close(a.stopPoll)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Unlock()
|
|
||||||
a.ui.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cmdHelp() {
|
|
||||||
help := []string{
|
|
||||||
"[cyan]*** neoirc-cli commands:",
|
|
||||||
" /connect <url> — Connect to server",
|
|
||||||
" /nick <name> — Change nickname",
|
|
||||||
" /join #channel — Join channel",
|
|
||||||
" /part [#chan] — Leave channel",
|
|
||||||
" /msg <nick> <text> — Send DM",
|
|
||||||
" /query <nick> — Open DM window",
|
|
||||||
" /topic [text] — View/set topic",
|
|
||||||
" /names — List channel members",
|
|
||||||
" /list — List channels",
|
|
||||||
" /who [#channel] — List users in channel",
|
|
||||||
" /whois <nick> — Show user info",
|
|
||||||
" /motd — Show message of the day",
|
|
||||||
" /window <n> — Switch buffer",
|
|
||||||
" /quit — Disconnect and exit",
|
|
||||||
" /help — This help",
|
|
||||||
" Plain text sends to current target.",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range help {
|
|
||||||
a.ui.AddStatus(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollLoop long-polls for messages in the background.
|
|
||||||
func (a *App) pollLoop() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-a.stopPoll:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
client := a.client
|
|
||||||
lastQID := a.lastQID
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
if client == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.PollMessages(
|
|
||||||
lastQID, pollTimeout,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(pollRetry)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.LastID > 0 {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.lastQID = result.LastID
|
|
||||||
a.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range result.Messages {
|
|
||||||
a.handleServerMessage(&result.Messages[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleServerMessage(msg *api.Message) {
|
|
||||||
timestamp := a.formatTS(msg)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
myNick := a.nick
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
switch msg.Command {
|
|
||||||
case irc.CmdPrivmsg:
|
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
|
||||||
case irc.CmdJoin:
|
|
||||||
a.handleJoinEvent(msg, timestamp)
|
|
||||||
case irc.CmdPart:
|
|
||||||
a.handlePartEvent(msg, timestamp)
|
|
||||||
case irc.CmdQuit:
|
|
||||||
a.handleQuitEvent(msg, timestamp)
|
|
||||||
case irc.CmdNick:
|
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
|
||||||
case irc.CmdNotice:
|
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
|
||||||
case irc.CmdTopic:
|
|
||||||
a.handleTopicEvent(msg, timestamp)
|
|
||||||
default:
|
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, timestamp, myNick string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if msg.From == myNick {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := msg.To
|
|
||||||
if !strings.HasPrefix(target, "#") {
|
|
||||||
target = msg.From
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(target, fmt.Sprintf(
|
|
||||||
"[gray]%s [green]<%s>[white] %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleJoinEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
if msg.To == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has joined %s",
|
|
||||||
timestamp, msg.From, msg.To,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handlePartEvent(
|
|
||||||
msg *api.Message, timestamp 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)",
|
|
||||||
timestamp, msg.From, msg.To, reason,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has left %s",
|
|
||||||
timestamp, msg.From, msg.To,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleQuitEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
reason := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if reason != "" {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
|
||||||
timestamp, msg.From, reason,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [yellow]*** %s has quit",
|
|
||||||
timestamp, msg.From,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleNickEvent(
|
|
||||||
msg *api.Message, timestamp, myNick string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
|
|
||||||
newNick := ""
|
|
||||||
if len(lines) > 0 {
|
|
||||||
newNick = lines[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
timestamp, msg.From, newNick,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleNoticeEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [magenta]--%s-- %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleTopicEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
if msg.To == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
|
||||||
"[gray]%s [cyan]*** %s set topic: %s",
|
|
||||||
timestamp, msg.From, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleDefaultEvent(
|
|
||||||
msg *api.Message, timestamp string,
|
|
||||||
) {
|
|
||||||
lines := msg.BodyLines()
|
|
||||||
text := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
if text != "" {
|
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
|
||||||
"[gray]%s [white][%s] %s",
|
|
||||||
timestamp, msg.Command, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
|
||||||
| '_ \ / _ \/ _ \ | | '__/ __|
|
|
||||||
| | | | __/ (_) || | | | (__
|
|
||||||
|_| |_|\___|\___/ |_|_| \___|
|
|
||||||
|
|
||||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
|
||||||
Type /help for available commands.`
|
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -38,24 +30,18 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MessageMaxAge string
|
MaxHistory int
|
||||||
|
SessionTimeout int
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
QueueMaxAge string
|
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
|
||||||
HashcashBits int
|
|
||||||
LoginRateLimit float64
|
|
||||||
LoginRateBurst int
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config by reading from files and environment variables.
|
// New creates a new Config by reading from files and environment variables.
|
||||||
func New(
|
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||||
_ fx.Lifecycle, params Params,
|
|
||||||
) (*Config, error) {
|
|
||||||
log := params.Logger.Get()
|
log := params.Logger.Get()
|
||||||
name := params.Globals.Appname
|
name := params.Globals.Appname
|
||||||
|
|
||||||
@@ -68,20 +54,16 @@ func New(
|
|||||||
viper.SetDefault("DEBUG", "false")
|
viper.SetDefault("DEBUG", "false")
|
||||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||||
viper.SetDefault("PORT", "8080")
|
viper.SetDefault("PORT", "8080")
|
||||||
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
|
viper.SetDefault("SESSION_TIMEOUT", "86400")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
viper.SetDefault("MOTD", "")
|
||||||
viper.SetDefault("MOTD", defaultMOTD)
|
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
|
||||||
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
|
||||||
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,7 +74,7 @@ func New(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
s := &Config{
|
||||||
DBURL: viper.GetString("DBURL"),
|
DBURL: viper.GetString("DBURL"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
@@ -100,24 +82,20 @@ func New(
|
|||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||||
|
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
|
||||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||||
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
|
||||||
MOTD: viper.GetString("MOTD"),
|
MOTD: viper.GetString("MOTD"),
|
||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
|
||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
|
||||||
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
|
||||||
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Debug {
|
if s.Debug {
|
||||||
params.Logger.EnableDebugLogging()
|
params.Logger.EnableDebugLogging()
|
||||||
cfg.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const bcryptCost = bcrypt.DefaultCost
|
|
||||||
|
|
||||||
var errNoPassword = errors.New(
|
|
||||||
"account has no password set",
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterUser creates a session with a hashed password
|
|
||||||
// and returns session ID, client ID, and token.
|
|
||||||
func (database *Database) RegisterUser(
|
|
||||||
ctx context.Context,
|
|
||||||
nick, password string,
|
|
||||||
) (int64, int64, string, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
|
||||||
[]byte(password), bcryptCost,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"hash password: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionUUID := uuid.New().String()
|
|
||||||
clientUUID := uuid.New().String()
|
|
||||||
|
|
||||||
token, err := generateToken()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"begin tx: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
|
||||||
`INSERT INTO sessions
|
|
||||||
(uuid, nick, password_hash,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
sessionUUID, nick, string(hash), now, now)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"create session: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
|
||||||
`INSERT INTO clients
|
|
||||||
(uuid, session_id, token,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"create client: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID, _ := clientRes.LastInsertId()
|
|
||||||
|
|
||||||
err = transaction.Commit()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"commit registration: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionID, clientID, token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginUser verifies a nick/password and creates a new
|
|
||||||
// client token.
|
|
||||||
func (database *Database) LoginUser(
|
|
||||||
ctx context.Context,
|
|
||||||
nick, password string,
|
|
||||||
) (int64, int64, string, error) {
|
|
||||||
var (
|
|
||||||
sessionID int64
|
|
||||||
passwordHash string
|
|
||||||
)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT id, password_hash
|
|
||||||
FROM sessions WHERE nick = ?`,
|
|
||||||
nick,
|
|
||||||
).Scan(&sessionID, &passwordHash)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"get session for login: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if passwordHash == "" {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"login: %w", errNoPassword,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword(
|
|
||||||
[]byte(passwordHash), []byte(password),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"verify password: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientUUID := uuid.New().String()
|
|
||||||
|
|
||||||
token, err := generateToken()
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT INTO clients
|
|
||||||
(uuid, session_id, token,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, "", fmt.Errorf(
|
|
||||||
"create login client: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID, _ := res.LastInsertId()
|
|
||||||
|
|
||||||
_, _ = database.conn.ExecContext(
|
|
||||||
ctx,
|
|
||||||
"UPDATE sessions SET last_seen = ? WHERE id = ?",
|
|
||||||
now, sessionID,
|
|
||||||
)
|
|
||||||
|
|
||||||
return sessionID, clientID, token, nil
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package db_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRegisterUser(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
database.RegisterUser(ctx, "reguser", "password123")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionID == 0 || clientID == 0 || token == "" {
|
|
||||||
t.Fatal("expected valid ids and token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify session works via token lookup.
|
|
||||||
sid, cid, nick, err :=
|
|
||||||
database.GetSessionByToken(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sid != sessionID || cid != clientID {
|
|
||||||
t.Fatal("session/client id mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if nick != "reguser" {
|
|
||||||
t.Fatalf("expected reguser, got %s", nick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
|
||||||
database.RegisterUser(ctx, "dupnick", "password123")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = regSID
|
|
||||||
_ = regCID
|
|
||||||
_ = regToken
|
|
||||||
|
|
||||||
dupSID, dupCID, dupToken, dupErr :=
|
|
||||||
database.RegisterUser(ctx, "dupnick", "other12345")
|
|
||||||
if dupErr == nil {
|
|
||||||
t.Fatal("expected error for duplicate nick")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = dupSID
|
|
||||||
_ = dupCID
|
|
||||||
_ = dupToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUser(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
|
||||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = regSID
|
|
||||||
_ = regCID
|
|
||||||
_ = regToken
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionID == 0 || clientID == 0 || token == "" {
|
|
||||||
t.Fatal("expected valid ids and token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the new token works.
|
|
||||||
_, _, nick, err :=
|
|
||||||
database.GetSessionByToken(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if nick != "loginuser" {
|
|
||||||
t.Fatalf("expected loginuser, got %s", nick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUserWrongPassword(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
|
||||||
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = regSID
|
|
||||||
_ = regCID
|
|
||||||
_ = regToken
|
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
|
||||||
if loginErr == nil {
|
|
||||||
t.Fatal("expected error for wrong password")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = loginSID
|
|
||||||
_ = loginCID
|
|
||||||
_ = loginToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUserNoPassword(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// Create anonymous session (no password).
|
|
||||||
anonSID, anonCID, anonToken, err :=
|
|
||||||
database.CreateSession(ctx, "anon")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = anonSID
|
|
||||||
_ = anonCID
|
|
||||||
_ = anonToken
|
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
|
||||||
database.LoginUser(ctx, "anon", "anything1")
|
|
||||||
if loginErr == nil {
|
|
||||||
t.Fatal(
|
|
||||||
"expected error for login on passwordless account",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = loginSID
|
|
||||||
_ = loginCID
|
|
||||||
_ = loginToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginUserNonexistent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
|
||||||
database.LoginUser(ctx, "ghost", "password123")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for nonexistent user")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = loginSID
|
|
||||||
_ = loginCID
|
|
||||||
_ = loginToken
|
|
||||||
}
|
|
||||||
@@ -11,16 +11,20 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/models"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
_ "modernc.org/sqlite" // driver
|
_ "modernc.org/sqlite" // SQLite driver
|
||||||
)
|
)
|
||||||
|
|
||||||
const minMigrationParts = 2
|
const (
|
||||||
|
minMigrationParts = 2
|
||||||
|
)
|
||||||
|
|
||||||
// SchemaFiles contains embedded SQL migration files.
|
// SchemaFiles contains embedded SQL migration files.
|
||||||
//
|
//
|
||||||
@@ -35,95 +39,527 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database manages the SQLite connection and migrations.
|
// Database manages the SQLite database connection and migrations.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
conn *sql.DB
|
db *sql.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Database and registers lifecycle hooks.
|
// New creates a new Database instance and registers lifecycle hooks.
|
||||||
func New(
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
lifecycle fx.Lifecycle,
|
s := new(Database)
|
||||||
params Params,
|
s.params = ¶ms
|
||||||
) (*Database, error) {
|
s.log = params.Logger.Get()
|
||||||
database := &Database{ //nolint:exhaustruct // conn set in OnStart
|
|
||||||
params: ¶ms,
|
|
||||||
log: params.Logger.Get(),
|
|
||||||
}
|
|
||||||
|
|
||||||
database.log.Info("Database instantiated")
|
s.log.Info("Database instantiated")
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
database.log.Info("Database OnStart Hook")
|
s.log.Info("Database OnStart Hook")
|
||||||
|
|
||||||
return database.connect(ctx)
|
return s.connect(ctx)
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
database.log.Info("Database OnStop Hook")
|
s.log.Info("Database OnStop Hook")
|
||||||
|
|
||||||
if database.conn != nil {
|
if s.db != nil {
|
||||||
closeErr := database.conn.Close()
|
return s.db.Close()
|
||||||
if closeErr != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"close db: %w", closeErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return database, nil
|
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.Exec("PRAGMA foreign_keys = ON") //nolint:noctx,nolintlint // no context in sql.Open path
|
||||||
|
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.
|
// GetDB returns the underlying sql.DB connection.
|
||||||
func (database *Database) GetDB() *sql.DB {
|
func (s *Database) GetDB() *sql.DB {
|
||||||
return database.conn
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (database *Database) connect(ctx context.Context) error {
|
// Hydrate injects the database reference into any model that
|
||||||
dbURL := database.params.Config.DBURL
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByNickModel looks up a user by their nick.
|
||||||
|
func (s *Database) GetUserByNickModel(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByTokenModel looks up a user by their auth token.
|
||||||
|
func (s *Database) GetUserByTokenModel(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserModel inserts a new user into the database.
|
||||||
|
func (s *Database) CreateUserModel(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf( //nolint:gosec // placeholders are ?, not user input
|
||||||
|
"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 == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
dbURL = "file:./data.db?_journal_mode=WAL"
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info(
|
s.log.Info("connecting to database", "url", dbURL)
|
||||||
"connecting to database", "url", dbURL,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", dbURL)
|
d, err := sql.Open("sqlite", dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open database: %w", err)
|
s.log.Error("failed to open database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = conn.PingContext(ctx)
|
err = d.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ping database: %w", err)
|
s.log.Error("failed to ping database", "error", err)
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.SetMaxOpenConns(1)
|
s.db = d
|
||||||
|
s.log.Info("database connected")
|
||||||
|
|
||||||
database.conn = conn
|
// Item 9: Enable foreign keys on every connection
|
||||||
database.log.Info("database connected")
|
_, err = s.db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
_, err = database.conn.ExecContext(
|
|
||||||
ctx, "PRAGMA foreign_keys = ON",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("enable foreign keys: %w", err)
|
return fmt.Errorf("enable foreign keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = database.conn.ExecContext(
|
return s.runMigrations(ctx)
|
||||||
ctx, "PRAGMA busy_timeout = 5000",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set busy timeout: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.runMigrations(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type migration struct {
|
type migration struct {
|
||||||
@@ -132,122 +568,48 @@ type migration struct {
|
|||||||
sql string
|
sql string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (database *Database) runMigrations(
|
func (s *Database) runMigrations(ctx context.Context) error {
|
||||||
|
err := s.bootstrapMigrationsTable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations, err := s.loadMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.applyMigrations(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("database migrations complete")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) bootstrapMigrationsTable(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
_, err := database.conn.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"create schema_migrations: %w", err,
|
"create schema_migrations table: %w", err,
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
migrations, err := database.loadMigrations()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, mig := range migrations {
|
|
||||||
err = database.applyMigration(ctx, mig)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
database.log.Info("database migrations complete")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (database *Database) applyMigration(
|
|
||||||
ctx context.Context,
|
|
||||||
mig migration,
|
|
||||||
) error {
|
|
||||||
var exists int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM schema_migrations
|
|
||||||
WHERE version = ?`,
|
|
||||||
mig.version,
|
|
||||||
).Scan(&exists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"check migration %d: %w", mig.version, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
database.log.Info(
|
|
||||||
"applying migration",
|
|
||||||
"version", mig.version,
|
|
||||||
"name", mig.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
return database.execMigration(ctx, mig)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (database *Database) execMigration(
|
|
||||||
ctx context.Context,
|
|
||||||
mig migration,
|
|
||||||
) error {
|
|
||||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"begin tx for migration %d: %w",
|
|
||||||
mig.version, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = transaction.ExecContext(ctx, mig.sql)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return fmt.Errorf(
|
|
||||||
"apply migration %d (%s): %w",
|
|
||||||
mig.version, mig.name, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = transaction.ExecContext(ctx,
|
|
||||||
`INSERT INTO schema_migrations (version)
|
|
||||||
VALUES (?)`,
|
|
||||||
mig.version,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
|
|
||||||
return fmt.Errorf(
|
|
||||||
"record migration %d: %w",
|
|
||||||
mig.version, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = transaction.Commit()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"commit migration %d: %w",
|
|
||||||
mig.version, err,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (database *Database) loadMigrations() (
|
func (s *Database) loadMigrations() ([]migration, error) {
|
||||||
[]migration,
|
|
||||||
error,
|
|
||||||
) {
|
|
||||||
entries, err := fs.ReadDir(SchemaFiles, "schema")
|
entries, err := fs.ReadDir(SchemaFiles, "schema")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("read schema dir: %w", err)
|
||||||
"read schema dir: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations := make([]migration, 0, len(entries))
|
migrations := make([]migration, 0, len(entries))
|
||||||
@@ -265,18 +627,17 @@ func (database *Database) loadMigrations() (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
version, parseErr := strconv.Atoi(parts[0])
|
version, err := strconv.Atoi(parts[0])
|
||||||
if parseErr != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
content, readErr := SchemaFiles.ReadFile(
|
content, err := SchemaFiles.ReadFile(
|
||||||
"schema/" + entry.Name(),
|
"schema/" + entry.Name(),
|
||||||
)
|
)
|
||||||
if readErr != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"read migration %s: %w",
|
"read migration %s: %w", entry.Name(), err,
|
||||||
entry.Name(), readErr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,3 +654,82 @@ func (database *Database) loadMigrations() (
|
|||||||
|
|
||||||
return migrations, nil
|
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 {
|
||||||
|
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 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info(
|
||||||
|
"applying migration",
|
||||||
|
"version", m.version, "name", m.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
err = s.executeMigration(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Database) executeMigration(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"commit migration %d: %w", m.version, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
424
internal/db/db_test.go
Normal file
424
internal/db/db_test.go
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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 := t.Context()
|
||||||
|
|
||||||
|
id, token, err := d.CreateUser(ctx, nickAlice)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id <= 0 {
|
||||||
|
t.Errorf("expected positive id, got %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
t.Error("expected non-empty token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
_, token, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
|
||||||
|
id, nick, err := d.GetUserByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id <= 0 || nick != nickAlice {
|
||||||
|
t.Errorf(
|
||||||
|
"got id=%d nick=%s, want nick=%s",
|
||||||
|
id, nick, nickAlice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
origID, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
|
||||||
|
id, err := d.GetUserByNick(ctx, nickAlice)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByNick: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id != origID {
|
||||||
|
t.Errorf("got id %d, want %d", id, origID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrCreateChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
id1, err := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateChannel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id1 <= 0 {
|
||||||
|
t.Errorf("expected positive id, got %d", id1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same channel returns same ID.
|
||||||
|
id2, err := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateChannel(2): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id1 != id2 {
|
||||||
|
t.Errorf("got different ids: %d vs %d", id1, id2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinAndListChannels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
ch1, _ := d.GetOrCreateChannel(ctx, "#alpha")
|
||||||
|
ch2, _ := d.GetOrCreateChannel(ctx, "#beta")
|
||||||
|
|
||||||
|
_ = d.JoinChannel(ctx, ch1, uid)
|
||||||
|
_ = d.JoinChannel(ctx, ch2, uid)
|
||||||
|
|
||||||
|
channels, err := d.ListChannels(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListChannels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) != 2 {
|
||||||
|
t.Fatalf("expected 2 channels, got %d", len(channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListChannelsEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
|
||||||
|
channels, err := d.ListChannels(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListChannels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) != 0 {
|
||||||
|
t.Errorf("expected 0 channels, got %d", len(channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid)
|
||||||
|
_ = d.PartChannel(ctx, chID, uid)
|
||||||
|
|
||||||
|
channels, err := d.ListChannels(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListChannels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) != 0 {
|
||||||
|
t.Errorf("expected 0 after part, got %d", len(channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendAndGetMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid)
|
||||||
|
|
||||||
|
_, err := d.SendMessage(ctx, chID, uid, "hello world")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := d.GetMessages(ctx, chID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgs[0].Content != "hello world" {
|
||||||
|
t.Errorf(
|
||||||
|
"got content %q, want %q",
|
||||||
|
msgs[0].Content, "hello world",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelMembers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid1, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
uid2, _, _ := d.CreateUser(ctx, nickBob)
|
||||||
|
uid3, _, _ := d.CreateUser(ctx, nickCharlie)
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid1)
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid2)
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid3)
|
||||||
|
|
||||||
|
members, err := d.ChannelMembers(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChannelMembers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members) != 3 {
|
||||||
|
t.Fatalf("expected 3 members, got %d", len(members))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelMembersEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#empty")
|
||||||
|
|
||||||
|
members, err := d.ChannelMembers(ctx, chID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChannelMembers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(members) != 0 {
|
||||||
|
t.Errorf("expected 0, got %d", len(members))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendDM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid1, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
uid2, _, _ := d.CreateUser(ctx, nickBob)
|
||||||
|
|
||||||
|
msgID, err := d.SendDM(ctx, uid1, uid2, "hey bob")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendDM: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgID <= 0 {
|
||||||
|
t.Errorf("expected positive msgID, got %d", msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := d.GetDMs(ctx, uid1, uid2, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDMs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 DM, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgs[0].Content != "hey bob" {
|
||||||
|
t.Errorf("got %q, want %q", msgs[0].Content, "hey bob")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid1, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
uid2, _, _ := d.CreateUser(ctx, nickBob)
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid1)
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid2)
|
||||||
|
|
||||||
|
_, _ = d.SendMessage(ctx, chID, uid2, "hello")
|
||||||
|
_, _ = d.SendDM(ctx, uid2, uid1, "private")
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
msgs, err := d.PollMessages(ctx, uid1, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PollMessages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) < 2 {
|
||||||
|
t.Fatalf("expected >=2 messages, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangeNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
_, token, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
|
||||||
|
err := d.ChangeNick(ctx, 1, "alice2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChangeNick: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, nick, err := d.GetUserByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nick != "alice2" {
|
||||||
|
t.Errorf("got nick %q, want alice2", nick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTopic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
_, _ = d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
|
||||||
|
err := d.SetTopic(ctx, "#general", uid, "new topic")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetTopic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := d.ListAllChannels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllChannels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
if ch.Name == "#general" && ch.Topic == "new topic" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("topic was not updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMessagesBefore(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
uid, _, _ := d.CreateUser(ctx, nickAlice)
|
||||||
|
chID, _ := d.GetOrCreateChannel(ctx, "#general")
|
||||||
|
|
||||||
|
_ = d.JoinChannel(ctx, chID, uid)
|
||||||
|
|
||||||
|
for i := range 5 {
|
||||||
|
_, _ = d.SendMessage(
|
||||||
|
ctx, chID, uid,
|
||||||
|
fmt.Sprintf("msg%d", i),
|
||||||
|
)
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := d.GetMessagesBefore(ctx, chID, 0, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessagesBefore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) != 3 {
|
||||||
|
t.Fatalf("expected 3, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAllChannels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
_, _ = d.GetOrCreateChannel(ctx, "#alpha")
|
||||||
|
_, _ = d.GetOrCreateChannel(ctx, "#beta")
|
||||||
|
|
||||||
|
channels, err := d.ListAllChannels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAllChannels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) != 2 {
|
||||||
|
t.Errorf("expected 2, got %d", len(channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// Package db provides database access and migration management.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"modernc.org/sqlite"
|
|
||||||
sqlite3 "modernc.org/sqlite/lib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsUniqueConstraintError reports whether err is a SQLite
|
|
||||||
// unique-constraint violation.
|
|
||||||
func IsUniqueConstraintError(err error) bool {
|
|
||||||
var sqliteErr *sqlite.Error
|
|
||||||
if !errors.As(err, &sqliteErr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
|
||||||
}
|
|
||||||
@@ -1,60 +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) {
|
|
||||||
counter := testDBCounter.Add(1)
|
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
|
||||||
"file:testdb%d?mode=memory"+
|
|
||||||
"&cache=shared&_pragma=foreign_keys(1)",
|
|
||||||
counter,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open test db: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
database := &Database{ //nolint:exhaustruct // test helper, params not needed
|
|
||||||
conn: conn,
|
|
||||||
log: slog.Default(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.runMigrations(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
closeErr := conn.Close()
|
|
||||||
if closeErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"close after migration failure: %w",
|
|
||||||
closeErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"run test migrations: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return database, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the underlying database connection.
|
|
||||||
func (database *Database) Close() error {
|
|
||||||
err := database.conn.Close()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("close database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,653 +0,0 @@
|
|||||||
package db_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *db.Database {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
database, err := db.NewTestDatabase()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
closeErr := database.Close()
|
|
||||||
if closeErr != nil {
|
|
||||||
t.Logf("close db: %v", closeErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return database
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSession(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sessionID, _, token, err := database.CreateSession(
|
|
||||||
ctx, "alice",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sessionID == 0 || token == "" {
|
|
||||||
t.Fatal("expected valid id and token")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, dupToken, dupErr := database.CreateSession(
|
|
||||||
ctx, "alice",
|
|
||||||
)
|
|
||||||
if dupErr == nil {
|
|
||||||
t.Fatal("expected error for duplicate nick")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = dupToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSessionByToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(ctx, "bob")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, clientID, nick, err :=
|
|
||||||
database.GetSessionByToken(ctx, token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if nick != "bob" || sessionID == 0 || clientID == 0 {
|
|
||||||
t.Fatalf("expected bob, got %s", nick)
|
|
||||||
}
|
|
||||||
|
|
||||||
badSID, badCID, badNick, badErr :=
|
|
||||||
database.GetSessionByToken(ctx, "badtoken")
|
|
||||||
if badErr == nil {
|
|
||||||
t.Fatal("expected error for bad token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if badSID != 0 || badCID != 0 || badNick != "" {
|
|
||||||
t.Fatal("expected zero values on error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSessionByNick(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
charlieID, charlieClientID, charlieToken, err :=
|
|
||||||
database.CreateSession(ctx, "charlie")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if charlieID == 0 || charlieClientID == 0 {
|
|
||||||
t.Fatal("expected valid session/client IDs")
|
|
||||||
}
|
|
||||||
|
|
||||||
if charlieToken == "" {
|
|
||||||
t.Fatal("expected non-empty token")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := database.GetSessionByNick(ctx, "charlie")
|
|
||||||
if err != nil || id == 0 {
|
|
||||||
t.Fatal("expected to find charlie")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = database.GetSessionByNick(ctx, "nobody")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for unknown nick")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelOperations(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(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, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ids, err := database.GetChannelMemberIDs(ctx, chID)
|
|
||||||
if err != nil || len(ids) != 1 || ids[0] != sid {
|
|
||||||
t.Fatal("expected session in channel")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.JoinChannel(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.PartChannel(ctx, chID, sid)
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(
|
|
||||||
ctx, "#empty",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, "temp")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.JoinChannel(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.PartChannel(ctx, chID, sid)
|
|
||||||
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 createSessionWithChannels(
|
|
||||||
t *testing.T,
|
|
||||||
database *db.Database,
|
|
||||||
nick, ch1Name, ch2Name string,
|
|
||||||
) (int64, int64, int64) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(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, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.JoinChannel(ctx, ch2, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sid, ch1, ch2
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListChannels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
sid, _, _ := createSessionWithChannels(
|
|
||||||
t, database, "lister", "#a", "#b",
|
|
||||||
)
|
|
||||||
|
|
||||||
channels, err := database.ListChannels(
|
|
||||||
t.Context(), sid,
|
|
||||||
)
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
_, 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 := t.Context()
|
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
|
||||||
ctx, "old",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.ChangeNick(ctx, sid, "new")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, nick, err := database.GetSessionByToken(
|
|
||||||
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 := t.Context()
|
|
||||||
|
|
||||||
_, 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 TestInsertMessage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
body := json.RawMessage(`["hello"]`)
|
|
||||||
|
|
||||||
dbID, msgUUID, err := database.InsertMessage(
|
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dbID == 0 || msgUUID == "" {
|
|
||||||
t.Fatal("expected valid id and uuid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPollMessages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
|
||||||
ctx, "poller",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, clientID, _, err := database.GetSessionByToken(
|
|
||||||
ctx, token,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := json.RawMessage(`["hello"]`)
|
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.EnqueueToSession(ctx, sid, dbID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchSize = 10
|
|
||||||
|
|
||||||
msgs, lastQID, err := database.PollMessages(
|
|
||||||
ctx, clientID, 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, clientID, 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 := t.Context()
|
|
||||||
|
|
||||||
const msgCount = 10
|
|
||||||
|
|
||||||
for range msgCount {
|
|
||||||
_, _, err := database.InsertMessage(
|
|
||||||
ctx, "PRIVMSG", "user", "#hist",
|
|
||||||
nil, 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 TestDeleteSession(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
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, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.DeleteSession(ctx, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = database.GetSessionByNick(ctx, "deleteme")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("session 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 := t.Context()
|
|
||||||
|
|
||||||
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sid2, _, _, err := database.CreateSession(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, sid1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.JoinChannel(ctx, chID, sid2)
|
|
||||||
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 TestGetSessionChannels(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
sid, _, _ := createSessionWithChannels(
|
|
||||||
t, database, "multi", "#m1", "#m2",
|
|
||||||
)
|
|
||||||
|
|
||||||
channels, err :=
|
|
||||||
database.GetSessionChannels(
|
|
||||||
t.Context(), sid,
|
|
||||||
)
|
|
||||||
if err != nil || len(channels) != 2 {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 2 channels, got %d",
|
|
||||||
len(channels),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnqueueToClient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(
|
|
||||||
ctx, "enqclient",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, clientID, _, err := database.GetSessionByToken(
|
|
||||||
ctx, token,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := json.RawMessage(`["test"]`)
|
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
|
||||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.EnqueueToClient(ctx, clientID, dbID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchSize = 10
|
|
||||||
|
|
||||||
msgs, _, err := database.PollMessages(
|
|
||||||
ctx, clientID, 0, batchSize,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msgs) != 1 {
|
|
||||||
t.Fatalf("expected 1, got %d", len(msgs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +1,4 @@
|
|||||||
-- Chat server schema (pre-1.0 consolidated)
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
PRAGMA foreign_keys = ON;
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
-- Sessions: each session is a user identity (nick + optional password + signing key)
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
nick TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(uuid);
|
|
||||||
|
|
||||||
-- Clients: each session can have multiple connected clients
|
|
||||||
CREATE TABLE IF NOT EXISTS clients (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_clients_token ON clients(token);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_clients_session ON clients(session_id);
|
|
||||||
|
|
||||||
-- Channels
|
|
||||||
CREATE TABLE IF NOT EXISTS channels (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
|
||||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
|
||||||
topic_set_at DATETIME,
|
|
||||||
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,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
||||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(channel_id, session_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 '',
|
|
||||||
params 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,
|
|
||||||
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
|
||||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(client_id, message_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id);
|
|
||||||
|
|||||||
89
internal/db/schema/002_schema.sql
Normal file
89
internal/db/schema/002_schema.sql
Normal 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
|
||||||
|
);
|
||||||
53
internal/db/schema/003_users.sql
Normal file
53
internal/db/schema/003_users.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration 003: Replace UUID-based tables with simple integer-keyed
|
||||||
|
-- tables for the HTTP API. Drops the 002 tables and recreates them.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS message_queue;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS server_links;
|
||||||
|
DROP TABLE IF EXISTS messages;
|
||||||
|
DROP TABLE IF EXISTS channel_members;
|
||||||
|
DROP TABLE IF EXISTS auth_tokens;
|
||||||
|
DROP TABLE IF EXISTS channels;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
CREATE TABLE 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 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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_dm INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
|
||||||
|
CREATE INDEX idx_messages_dm ON messages(user_id, dm_target_id, created_at);
|
||||||
|
CREATE INDEX idx_users_token ON users(token);
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,16 +17,14 @@ var (
|
|||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
StartTime time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Globals instance from the global state.
|
// New creates a new Globals instance from the global state.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
result := &Globals{
|
n := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
const minPasswordLength = 8
|
|
||||||
|
|
||||||
// clientIP extracts the client IP address from the request.
|
|
||||||
// It checks X-Forwarded-For and X-Real-IP headers before
|
|
||||||
// falling back to RemoteAddr.
|
|
||||||
func clientIP(request *http.Request) string {
|
|
||||||
if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
||||||
// X-Forwarded-For may contain a comma-separated list;
|
|
||||||
// the first entry is the original client.
|
|
||||||
parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd // split into two parts
|
|
||||||
ip := strings.TrimSpace(parts[0])
|
|
||||||
|
|
||||||
if ip != "" {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if realIP := request.Header.Get("X-Real-IP"); realIP != "" {
|
|
||||||
return strings.TrimSpace(realIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return request.RemoteAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleRegister creates a new user with a password.
|
|
||||||
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
|
||||||
return func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
request.Body = http.MaxBytesReader(
|
|
||||||
writer, request.Body, hdlr.maxBodySize(),
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.handleRegister(writer, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) handleRegister(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
type registerRequest struct {
|
|
||||||
Nick string `json:"nick"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload registerRequest
|
|
||||||
|
|
||||||
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid request body",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
||||||
|
|
||||||
if !validNickRe.MatchString(payload.Nick) {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid nick format",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(payload.Password) < minPasswordLength {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"password must be at least 8 characters",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
hdlr.params.Database.RegisterUser(
|
|
||||||
request.Context(),
|
|
||||||
payload.Nick,
|
|
||||||
payload.Password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.handleRegisterError(
|
|
||||||
writer, request, err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.stats.IncrSessions()
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
|
||||||
"id": sessionID,
|
|
||||||
"nick": payload.Nick,
|
|
||||||
"token": token,
|
|
||||||
}, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) handleRegisterError(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
err error,
|
|
||||||
) {
|
|
||||||
if db.IsUniqueConstraintError(err) {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"nick already taken",
|
|
||||||
http.StatusConflict,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.log.Error(
|
|
||||||
"register user failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLogin authenticates a user with nick and password.
|
|
||||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
|
||||||
return func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
request.Body = http.MaxBytesReader(
|
|
||||||
writer, request.Body, hdlr.maxBodySize(),
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.handleLogin(writer, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) handleLogin(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
ip := clientIP(request)
|
|
||||||
|
|
||||||
if !hdlr.loginLimiter.Allow(ip) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Retry-After", "1",
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"too many login attempts, try again later",
|
|
||||||
http.StatusTooManyRequests,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginRequest struct {
|
|
||||||
Nick string `json:"nick"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload loginRequest
|
|
||||||
|
|
||||||
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid request body",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
||||||
|
|
||||||
if payload.Nick == "" || payload.Password == "" {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"nick and password required",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
hdlr.params.Database.LoginUser(
|
|
||||||
request.Context(),
|
|
||||||
payload.Nick,
|
|
||||||
payload.Password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"invalid credentials",
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
|
||||||
// which channels the session already belongs to.
|
|
||||||
hdlr.initChannelState(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
|
||||||
"id": sessionID,
|
|
||||||
"nick": payload.Nick,
|
|
||||||
"token": token,
|
|
||||||
}, http.StatusOK)
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,20 @@
|
|||||||
// Package handlers provides HTTP request handlers for the neoirc server.
|
// Package handlers provides HTTP request handlers for the chat server.
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errUnauthorized = errors.New("unauthorized")
|
|
||||||
|
|
||||||
// Params defines the dependencies for creating Handlers.
|
// Params defines the dependencies for creating Handlers.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -32,274 +24,39 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
|
||||||
hashcashVal *hashcash.Validator
|
|
||||||
loginLimiter *ratelimit.Limiter
|
|
||||||
stats *stats.Tracker
|
|
||||||
cancelCleanup context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
lifecycle fx.Lifecycle,
|
s := new(Handlers)
|
||||||
params Params,
|
s.params = ¶ms
|
||||||
) (*Handlers, error) {
|
s.log = params.Logger.Get()
|
||||||
resource := params.Config.ServerName
|
s.hc = params.Healthcheck
|
||||||
if resource == "" {
|
|
||||||
resource = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
loginRate := params.Config.LoginRateLimit
|
|
||||||
if loginRate <= 0 {
|
|
||||||
loginRate = ratelimit.DefaultRate
|
|
||||||
}
|
|
||||||
|
|
||||||
loginBurst := params.Config.LoginRateBurst
|
|
||||||
if loginBurst <= 0 {
|
|
||||||
loginBurst = ratelimit.DefaultBurst
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
|
||||||
params: ¶ms,
|
|
||||||
log: params.Logger.Get(),
|
|
||||||
hc: params.Healthcheck,
|
|
||||||
broker: broker.New(),
|
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
|
||||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
|
||||||
stats: params.Stats,
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
|
||||||
OnStart: func(ctx context.Context) error {
|
|
||||||
hdlr.startCleanup(ctx)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
OnStop: func(_ context.Context) error {
|
|
||||||
hdlr.stopCleanup()
|
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return hdlr, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data any, status int) {
|
||||||
writer http.ResponseWriter,
|
w.WriteHeader(status)
|
||||||
_ *http.Request,
|
w.Header().Set("Content-Type", "application/json")
|
||||||
data any,
|
|
||||||
status int,
|
|
||||||
) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Type",
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
)
|
|
||||||
writer.WriteHeader(status)
|
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(writer).Encode(data)
|
err := json.NewEncoder(w).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error(
|
s.log.Error("json encode error", "error", err)
|
||||||
"json encode error", "error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) respondError(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
msg string,
|
|
||||||
status int,
|
|
||||||
) {
|
|
||||||
hdlr.respondJSON(
|
|
||||||
writer, request,
|
|
||||||
map[string]string{"error": msg},
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) idleTimeout() time.Duration {
|
|
||||||
raw := hdlr.params.Config.SessionIdleTimeout
|
|
||||||
if raw == "" {
|
|
||||||
return defaultIdleTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
dur, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"invalid SESSION_IDLE_TIMEOUT, using default",
|
|
||||||
"value", raw, "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return defaultIdleTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
return dur
|
|
||||||
}
|
|
||||||
|
|
||||||
// startCleanup launches the idle-user cleanup goroutine.
|
|
||||||
// We use context.Background rather than the OnStart ctx
|
|
||||||
// because the OnStart context is startup-scoped and would
|
|
||||||
// cancel the goroutine once all start hooks complete.
|
|
||||||
//
|
|
||||||
//nolint:contextcheck // intentional Background ctx
|
|
||||||
func (hdlr *Handlers) startCleanup(_ context.Context) {
|
|
||||||
cleanupCtx, cancel := context.WithCancel(
|
|
||||||
context.Background(),
|
|
||||||
)
|
|
||||||
hdlr.cancelCleanup = cancel
|
|
||||||
|
|
||||||
go hdlr.cleanupLoop(cleanupCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) stopCleanup() {
|
|
||||||
if hdlr.cancelCleanup != nil {
|
|
||||||
hdlr.cancelCleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
if hdlr.loginLimiter != nil {
|
|
||||||
hdlr.loginLimiter.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
|
||||||
timeout := hdlr.idleTimeout()
|
|
||||||
|
|
||||||
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
|
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
hdlr.runCleanup(ctx, timeout)
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) runCleanup(
|
|
||||||
ctx context.Context,
|
|
||||||
timeout time.Duration,
|
|
||||||
) {
|
|
||||||
cutoff := time.Now().Add(-timeout)
|
|
||||||
|
|
||||||
// Find sessions that will be orphaned so we can send
|
|
||||||
// QUIT notifications before deleting anything.
|
|
||||||
stale, err := hdlr.params.Database.
|
|
||||||
GetStaleOrphanSessions(ctx, cutoff)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"stale session lookup failed", "error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ss := range stale {
|
|
||||||
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, err := hdlr.params.Database.DeleteStaleUsers(
|
|
||||||
ctx, cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"user cleanup failed", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if deleted > 0 {
|
|
||||||
hdlr.log.Info(
|
|
||||||
"cleaned up stale users",
|
|
||||||
"deleted", deleted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.pruneQueuesAndMessages(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDurationConfig parses a Go duration string,
|
|
||||||
// returning zero on empty input and logging on error.
|
|
||||||
func (hdlr *Handlers) parseDurationConfig(
|
|
||||||
name, raw string,
|
|
||||||
) time.Duration {
|
|
||||||
if raw == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
dur, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"invalid duration config, skipping",
|
|
||||||
"name", name, "value", raw, "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return dur
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneQueuesAndMessages removes old client output queue
|
|
||||||
// entries per QUEUE_MAX_AGE and old messages per
|
|
||||||
// MESSAGE_MAX_AGE.
|
|
||||||
func (hdlr *Handlers) pruneQueuesAndMessages(
|
|
||||||
ctx context.Context,
|
|
||||||
) {
|
|
||||||
queueMaxAge := hdlr.parseDurationConfig(
|
|
||||||
"QUEUE_MAX_AGE",
|
|
||||||
hdlr.params.Config.QueueMaxAge,
|
|
||||||
)
|
|
||||||
if queueMaxAge > 0 {
|
|
||||||
queueCutoff := time.Now().Add(-queueMaxAge)
|
|
||||||
|
|
||||||
pruned, err := hdlr.params.Database.
|
|
||||||
PruneOldQueueEntries(ctx, queueCutoff)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"client output queue pruning failed", "error", err,
|
|
||||||
)
|
|
||||||
} else if pruned > 0 {
|
|
||||||
hdlr.log.Info(
|
|
||||||
"pruned old client output queue entries",
|
|
||||||
"deleted", pruned,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messageMaxAge := hdlr.parseDurationConfig(
|
|
||||||
"MESSAGE_MAX_AGE",
|
|
||||||
hdlr.params.Config.MessageMaxAge,
|
|
||||||
)
|
|
||||||
if messageMaxAge > 0 {
|
|
||||||
msgCutoff := time.Now().Add(-messageMaxAge)
|
|
||||||
|
|
||||||
pruned, err := hdlr.params.Database.
|
|
||||||
PruneOldMessages(ctx, msgCutoff)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"message pruning failed", "error", err,
|
|
||||||
)
|
|
||||||
} else if pruned > 0 {
|
|
||||||
hdlr.log.Info(
|
|
||||||
"pruned old messages",
|
|
||||||
"deleted", pruned,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ import (
|
|||||||
const httpStatusOK = 200
|
const httpStatusOK = 200
|
||||||
|
|
||||||
// HandleHealthCheck returns an HTTP handler for the health check endpoint.
|
// HandleHealthCheck returns an HTTP handler for the health check endpoint.
|
||||||
func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||||
return func(
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
writer http.ResponseWriter,
|
resp := s.hc.Healthcheck()
|
||||||
request *http.Request,
|
s.respondJSON(w, req, resp, httpStatusOK)
|
||||||
) {
|
|
||||||
resp := hdlr.hc.Healthcheck(request.Context())
|
|
||||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
// Package hashcash implements SHA-256-based hashcash
|
|
||||||
// proof-of-work validation for abuse prevention.
|
|
||||||
//
|
|
||||||
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
|
||||||
//
|
|
||||||
// The SHA-256 hash of the entire stamp string must have
|
|
||||||
// at least `bits` leading zero bits.
|
|
||||||
package hashcash
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// stampVersion is the only supported hashcash version.
|
|
||||||
stampVersion = "1"
|
|
||||||
// stampFields is the number of fields in a stamp.
|
|
||||||
stampFields = 6
|
|
||||||
// maxStampAge is how old a stamp can be before
|
|
||||||
// rejection.
|
|
||||||
maxStampAge = 48 * time.Hour
|
|
||||||
// maxFutureSkew allows stamps slightly in the future.
|
|
||||||
maxFutureSkew = 1 * time.Hour
|
|
||||||
// pruneInterval controls how often expired stamps are
|
|
||||||
// removed from the spent set.
|
|
||||||
pruneInterval = 10 * time.Minute
|
|
||||||
// dateFormatShort is the YYMMDD date layout.
|
|
||||||
dateFormatShort = "060102"
|
|
||||||
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
|
||||||
dateFormatLong = "060102150405"
|
|
||||||
// dateShortLen is the length of YYMMDD.
|
|
||||||
dateShortLen = 6
|
|
||||||
// dateLongLen is the length of YYMMDDHHMMSS.
|
|
||||||
dateLongLen = 12
|
|
||||||
// bitsPerByte is the number of bits in a byte.
|
|
||||||
bitsPerByte = 8
|
|
||||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
||||||
fullByteMask = 0xFF
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errInvalidFields = errors.New("invalid stamp field count")
|
|
||||||
errBadVersion = errors.New("unsupported stamp version")
|
|
||||||
errInsufficientBits = errors.New("insufficient difficulty")
|
|
||||||
errWrongResource = errors.New("wrong resource")
|
|
||||||
errStampExpired = errors.New("stamp expired")
|
|
||||||
errStampFuture = errors.New("stamp date in future")
|
|
||||||
errProofFailed = errors.New("proof-of-work failed")
|
|
||||||
errStampReused = errors.New("stamp already used")
|
|
||||||
errBadDateFormat = errors.New("unrecognized date format")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validator checks hashcash stamps for validity and
|
|
||||||
// prevents replay attacks via an in-memory spent set.
|
|
||||||
type Validator struct {
|
|
||||||
resource string
|
|
||||||
mu sync.Mutex
|
|
||||||
spent map[string]time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewValidator creates a Validator for the given resource.
|
|
||||||
func NewValidator(resource string) *Validator {
|
|
||||||
validator := &Validator{
|
|
||||||
resource: resource,
|
|
||||||
mu: sync.Mutex{},
|
|
||||||
spent: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
|
|
||||||
go validator.pruneLoop()
|
|
||||||
|
|
||||||
return validator
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks a hashcash stamp. It returns nil if the
|
|
||||||
// stamp is valid and has not been seen before.
|
|
||||||
func (v *Validator) Validate(
|
|
||||||
stamp string,
|
|
||||||
requiredBits int,
|
|
||||||
) error {
|
|
||||||
if requiredBits <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(stamp, ":")
|
|
||||||
if len(parts) != stampFields {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: expected %d, got %d",
|
|
||||||
errInvalidFields, stampFields, len(parts),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
version := parts[0]
|
|
||||||
bitsStr := parts[1]
|
|
||||||
dateStr := parts[2]
|
|
||||||
resource := parts[3]
|
|
||||||
|
|
||||||
if err := v.validateHeader(
|
|
||||||
version, bitsStr, resource, requiredBits,
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stampTime, err := parseStampDate(dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateTime(stampTime); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateProof(
|
|
||||||
stamp, requiredBits,
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return v.checkAndRecordStamp(stamp, stampTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) validateHeader(
|
|
||||||
version, bitsStr, resource string,
|
|
||||||
requiredBits int,
|
|
||||||
) error {
|
|
||||||
if version != stampVersion {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s", errBadVersion, version,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
claimedBits, err := strconv.Atoi(bitsStr)
|
|
||||||
if err != nil || claimedBits < requiredBits {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: need %d bits",
|
|
||||||
errInsufficientBits, requiredBits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resource != v.resource {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: got %q, want %q",
|
|
||||||
errWrongResource, resource, v.resource,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTime(stampTime time.Time) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if now.Sub(stampTime) > maxStampAge {
|
|
||||||
return errStampExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
if stampTime.Sub(now) > maxFutureSkew {
|
|
||||||
return errStampFuture
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateProof(stamp string, requiredBits int) error {
|
|
||||||
hash := sha256.Sum256([]byte(stamp))
|
|
||||||
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: need %d leading zero bits",
|
|
||||||
errProofFailed, requiredBits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) checkAndRecordStamp(
|
|
||||||
stamp string,
|
|
||||||
stampTime time.Time,
|
|
||||||
) error {
|
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := v.spent[stamp]; ok {
|
|
||||||
return errStampReused
|
|
||||||
}
|
|
||||||
|
|
||||||
v.spent[stamp] = stampTime
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if the hash has at least n
|
|
||||||
// leading zero bits.
|
|
||||||
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
|
||||||
fullBytes := numBits / bitsPerByte
|
|
||||||
remainBits := numBits % bitsPerByte
|
|
||||||
|
|
||||||
for idx := range fullBytes {
|
|
||||||
if hash[idx] != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remainBits > 0 && fullBytes < len(hash) {
|
|
||||||
mask := byte(
|
|
||||||
fullByteMask << (bitsPerByte - remainBits),
|
|
||||||
)
|
|
||||||
|
|
||||||
if hash[fullBytes]&mask != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseStampDate parses a hashcash date stamp.
|
|
||||||
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
|
||||||
func parseStampDate(dateStr string) (time.Time, error) {
|
|
||||||
switch len(dateStr) {
|
|
||||||
case dateShortLen:
|
|
||||||
parsed, err := time.Parse(
|
|
||||||
dateFormatShort, dateStr,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"parse date: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed, nil
|
|
||||||
case dateLongLen:
|
|
||||||
parsed, err := time.Parse(
|
|
||||||
dateFormatLong, dateStr,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"parse date: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed, nil
|
|
||||||
default:
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"%w: %q", errBadDateFormat, dateStr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneLoop periodically removes expired stamps from the
|
|
||||||
// spent set.
|
|
||||||
func (v *Validator) pruneLoop() {
|
|
||||||
ticker := time.NewTicker(pruneInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
v.prune()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) prune() {
|
|
||||||
cutoff := time.Now().Add(-maxStampAge)
|
|
||||||
|
|
||||||
v.mu.Lock()
|
|
||||||
defer v.mu.Unlock()
|
|
||||||
|
|
||||||
for stamp, stampTime := range v.spent {
|
|
||||||
if stampTime.Before(cutoff) {
|
|
||||||
delete(v.spent, stamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
package hashcash_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testBits = 2
|
|
||||||
|
|
||||||
// mintStampWithDate creates a valid hashcash stamp using
|
|
||||||
// the given date string.
|
|
||||||
func mintStampWithDate(
|
|
||||||
tb testing.TB,
|
|
||||||
bits int,
|
|
||||||
resource string,
|
|
||||||
date string,
|
|
||||||
) string {
|
|
||||||
tb.Helper()
|
|
||||||
|
|
||||||
prefix := fmt.Sprintf(
|
|
||||||
"1:%d:%s:%s::", bits, date, resource,
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
counterVal, err := rand.Int(
|
|
||||||
rand.Reader, big.NewInt(1<<48),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
tb.Fatalf("random counter: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp := prefix + hex.EncodeToString(
|
|
||||||
counterVal.Bytes(),
|
|
||||||
)
|
|
||||||
hash := sha256.Sum256([]byte(stamp))
|
|
||||||
|
|
||||||
if hasLeadingZeroBits(hash[:], bits) {
|
|
||||||
return stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if hash has at least numBits
|
|
||||||
// leading zero bits. Duplicated here for test minting.
|
|
||||||
func hasLeadingZeroBits(
|
|
||||||
hash []byte,
|
|
||||||
numBits int,
|
|
||||||
) bool {
|
|
||||||
fullBytes := numBits / 8
|
|
||||||
remainBits := numBits % 8
|
|
||||||
|
|
||||||
for idx := range fullBytes {
|
|
||||||
if hash[idx] != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remainBits > 0 && fullBytes < len(hash) {
|
|
||||||
mask := byte(0xFF << (8 - remainBits))
|
|
||||||
|
|
||||||
if hash[fullBytes]&mask != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func todayDate() string {
|
|
||||||
return time.Now().UTC().Format("060102")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMintAndValidate(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("valid stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplayDetection(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first use failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("replay not detected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResourceMismatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("correct-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "wrong-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected resource mismatch error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidStampFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
err := validator.Validate(
|
|
||||||
"not:a:valid:stamp", testBits,
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for bad format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBadVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"2:%d:%s:%s::abc123",
|
|
||||||
testBits, todayDate(), "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected bad version error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInsufficientDifficulty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
// Claimed bits=1, but we require testBits=2.
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"1:1:%s:%s::counter",
|
|
||||||
todayDate(), "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected insufficient bits error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpiredStamp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
oldDate := time.Now().Add(-72 * time.Hour).
|
|
||||||
UTC().Format("060102")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", oldDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected expired stamp error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestZeroBitsSkipsValidation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
err := validator.Validate("garbage", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zero bits should skip: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLongDateFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
longDate := time.Now().UTC().Format("060102150405")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", longDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("long date stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBadDateFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := fmt.Sprintf(
|
|
||||||
"1:%d:BADDATE:%s::counter",
|
|
||||||
testBits, "test-resource",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected bad date error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultipleUniqueStamps(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
|
|
||||||
for range 5 {
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, testBits, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unique stamp rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHigherBitsStillValid(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Mint with bits=4 but validate requiring only 2.
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
|
||||||
stamp := mintStampWithDate(
|
|
||||||
t, 4, "test-resource", todayDate(),
|
|
||||||
)
|
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"higher-difficulty stamp rejected: %v",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck tracks server uptime and provides health status.
|
// Healthcheck tracks server uptime and provides health status.
|
||||||
@@ -35,17 +33,14 @@ type Healthcheck struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Healthcheck instance.
|
// New creates a new Healthcheck instance.
|
||||||
func New(
|
func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
|
||||||
lifecycle fx.Lifecycle, params Params,
|
s := new(Healthcheck)
|
||||||
) (*Healthcheck, error) {
|
s.params = ¶ms
|
||||||
hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart
|
s.log = params.Logger.Get()
|
||||||
params: ¶ms,
|
|
||||||
log: params.Logger.Get(),
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
hcheck.StartupTime = time.Now()
|
s.StartupTime = time.Now()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -54,7 +49,7 @@ func New(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return hcheck, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response is the JSON response returned by the health endpoint.
|
// Response is the JSON response returned by the health endpoint.
|
||||||
@@ -66,90 +61,22 @@ type Response struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
Maintenance bool `json:"maintenanceMode"`
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
|
|
||||||
// Runtime statistics.
|
|
||||||
Sessions int64 `json:"sessions"`
|
|
||||||
Clients int64 `json:"clients"`
|
|
||||||
QueuedLines int64 `json:"queuedLines"`
|
|
||||||
Channels int64 `json:"channels"`
|
|
||||||
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
|
||||||
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
|
||||||
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (hcheck *Healthcheck) Healthcheck(
|
func (s *Healthcheck) Healthcheck() *Response {
|
||||||
ctx context.Context,
|
|
||||||
) *Response {
|
|
||||||
resp := &Response{
|
resp := &Response{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||||
UptimeHuman: hcheck.uptime().String(),
|
UptimeHuman: s.uptime().String(),
|
||||||
Appname: hcheck.params.Globals.Appname,
|
Appname: s.params.Globals.Appname,
|
||||||
Version: hcheck.params.Globals.Version,
|
Version: s.params.Globals.Version,
|
||||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
|
||||||
|
|
||||||
Sessions: 0,
|
|
||||||
Clients: 0,
|
|
||||||
QueuedLines: 0,
|
|
||||||
Channels: 0,
|
|
||||||
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
|
||||||
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
|
||||||
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hcheck.populateDBStats(ctx, resp)
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// populateDBStats fills in database-derived counters.
|
func (s *Healthcheck) uptime() time.Duration {
|
||||||
func (hcheck *Healthcheck) populateDBStats(
|
return time.Since(s.StartupTime)
|
||||||
ctx context.Context,
|
|
||||||
resp *Response,
|
|
||||||
) {
|
|
||||||
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: session count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Sessions = sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: client count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Clients = clients
|
|
||||||
}
|
|
||||||
|
|
||||||
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: queue entry count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.QueuedLines = queued
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: channel count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Channels = channels
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hcheck *Healthcheck) uptime() time.Duration {
|
|
||||||
return time.Since(hcheck.StartupTime)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,56 +23,51 @@ type Logger struct {
|
|||||||
params Params
|
params Params
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Logger with appropriate handler
|
// New creates a new Logger with appropriate handler based on terminal detection.
|
||||||
// based on terminal detection.
|
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
||||||
func New(
|
l := new(Logger)
|
||||||
_ fx.Lifecycle, params Params,
|
l.level = new(slog.LevelVar)
|
||||||
) (*Logger, error) {
|
l.level.Set(slog.LevelInfo)
|
||||||
logger := new(Logger)
|
|
||||||
logger.level = new(slog.LevelVar)
|
|
||||||
logger.level.Set(slog.LevelInfo)
|
|
||||||
|
|
||||||
tty := false
|
tty := false
|
||||||
|
|
||||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||||
tty = true
|
tty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := &slog.HandlerOptions{ //nolint:exhaustruct // ReplaceAttr optional
|
|
||||||
Level: logger.level,
|
|
||||||
AddSource: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
if tty {
|
if tty {
|
||||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: l.level,
|
||||||
|
AddSource: true,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: l.level,
|
||||||
|
AddSource: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log = slog.New(handler)
|
l.log = slog.New(handler)
|
||||||
logger.params = params
|
l.params = params
|
||||||
|
|
||||||
return logger, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnableDebugLogging switches the log level to debug.
|
// EnableDebugLogging switches the log level to debug.
|
||||||
func (logger *Logger) EnableDebugLogging() {
|
func (l *Logger) EnableDebugLogging() {
|
||||||
logger.level.Set(slog.LevelDebug)
|
l.level.Set(slog.LevelDebug)
|
||||||
logger.log.Debug(
|
l.log.Debug("debug logging enabled", "debug", true)
|
||||||
"debug logging enabled", "debug", true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the underlying slog.Logger.
|
// Get returns the underlying slog.Logger.
|
||||||
func (logger *Logger) Get() *slog.Logger {
|
func (l *Logger) Get() *slog.Logger {
|
||||||
return logger.log
|
return l.log
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify logs the application name and version at startup.
|
// Identify logs the application name and version at startup.
|
||||||
func (logger *Logger) Identify() {
|
func (l *Logger) Identify() {
|
||||||
logger.log.Info("starting",
|
l.log.Info("starting",
|
||||||
"appname", logger.params.Globals.Appname,
|
"appname", l.params.Globals.Appname,
|
||||||
"version", logger.params.Globals.Version,
|
"version", l.params.Globals.Version,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package middleware provides HTTP middleware for the neoirc server.
|
// Package middleware provides HTTP middleware for the chat server.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -38,28 +38,25 @@ type Middleware struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Middleware instance.
|
// New creates a new Middleware instance.
|
||||||
func New(
|
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
|
||||||
_ fx.Lifecycle, params Params,
|
s := new(Middleware)
|
||||||
) (*Middleware, error) {
|
s.params = ¶ms
|
||||||
mware := &Middleware{
|
s.log = params.Logger.Get()
|
||||||
params: ¶ms,
|
|
||||||
log: params.Logger.Get(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return mware, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ipFromHostPort(hostPort string) string {
|
func ipFromHostPort(hp string) string {
|
||||||
host, _, err := net.SplitHostPort(hostPort)
|
h, _, err := net.SplitHostPort(hp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(host) > 0 && host[0] == '[' {
|
if len(h) > 0 && h[0] == '[' {
|
||||||
return host[1 : len(host)-1]
|
return h[1 : len(h)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return host
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
@@ -68,15 +65,9 @@ type loggingResponseWriter struct {
|
|||||||
statusCode int
|
statusCode int
|
||||||
}
|
}
|
||||||
|
|
||||||
// newLoggingResponseWriter wraps a ResponseWriter
|
// newLoggingResponseWriter wraps a ResponseWriter to capture the status code.
|
||||||
// to capture the status code.
|
func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
||||||
func newLoggingResponseWriter(
|
return &loggingResponseWriter{w, http.StatusOK}
|
||||||
writer http.ResponseWriter,
|
|
||||||
) *loggingResponseWriter {
|
|
||||||
return &loggingResponseWriter{
|
|
||||||
ResponseWriter: writer,
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||||
@@ -85,78 +76,72 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Logging returns middleware that logs each HTTP request.
|
// Logging returns middleware that logs each HTTP request.
|
||||||
func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
lrw := newLoggingResponseWriter(writer)
|
lrw := newLoggingResponseWriter(w)
|
||||||
ctx := request.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
reqID, _ := ctx.Value(
|
reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
|
||||||
chimw.RequestIDKey,
|
|
||||||
).(string)
|
|
||||||
|
|
||||||
mware.log.InfoContext(
|
s.log.InfoContext(ctx, "request",
|
||||||
ctx, "request",
|
|
||||||
"request_start", start,
|
"request_start", start,
|
||||||
"method", request.Method,
|
"method", r.Method,
|
||||||
"url", request.URL.String(),
|
"url", r.URL.String(),
|
||||||
"useragent", request.UserAgent(),
|
"useragent", r.UserAgent(),
|
||||||
"request_id", reqID,
|
"request_id", reqID,
|
||||||
"referer", request.Referer(),
|
"referer", r.Referer(),
|
||||||
"proto", request.Proto,
|
"proto", r.Proto,
|
||||||
"remoteIP",
|
"remoteIP", ipFromHostPort(r.RemoteAddr),
|
||||||
ipFromHostPort(request.RemoteAddr),
|
|
||||||
"status", lrw.statusCode,
|
"status", lrw.statusCode,
|
||||||
"latency_ms",
|
"latency_ms", latency.Milliseconds(),
|
||||||
latency.Milliseconds(),
|
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(lrw, request)
|
next.ServeHTTP(lrw, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||||
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
},
|
|
||||||
AllowedHeaders: []string{
|
|
||||||
"Accept", "Authorization",
|
|
||||||
"Content-Type", "X-CSRF-Token",
|
|
||||||
},
|
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth returns middleware that performs authentication.
|
||||||
|
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.log.Info("AUTH: before request")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
mdlw := ghmm.New(ghmm.Config{
|
||||||
Recorder: metrics.NewRecorder(
|
Recorder: metrics.NewRecorder(metrics.Config{}),
|
||||||
metrics.Config{}, //nolint:exhaustruct // defaults
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return std.Handler("", metricsMiddleware, next)
|
return std.Handler("", mdlw, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricsAuth returns middleware that protects metrics with basic auth.
|
// MetricsAuth returns middleware that protects metrics with basic auth.
|
||||||
func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
return basicauth.New(
|
return basicauth.New(
|
||||||
"metrics",
|
"metrics",
|
||||||
map[string][]string{
|
map[string][]string{
|
||||||
@@ -166,36 +151,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cspPolicy is the Content-Security-Policy header value applied to all
|
|
||||||
// responses. The embedded SPA loads scripts and styles from same-origin
|
|
||||||
// files only (no inline scripts or inline style attributes), so a strict
|
|
||||||
// policy works without 'unsafe-inline'.
|
|
||||||
const cspPolicy = "default-src 'self'; " +
|
|
||||||
"script-src 'self'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"connect-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'self'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"frame-ancestors 'none'; " +
|
|
||||||
"base-uri 'self'; " +
|
|
||||||
"form-action 'self'"
|
|
||||||
|
|
||||||
// CSP returns middleware that sets the Content-Security-Policy header on
|
|
||||||
// every response for defense-in-depth against XSS.
|
|
||||||
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
cspPolicy,
|
|
||||||
)
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
26
internal/models/auth_token.go
Normal file
26
internal/models/auth_token.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/models/channel.go
Normal file
96
internal/models/channel.go
Normal 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()
|
||||||
|
}
|
||||||
35
internal/models/channel_member.go
Normal file
35
internal/models/channel_member.go
Normal 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
|
||||||
|
}
|
||||||
20
internal/models/message.go
Normal file
20
internal/models/message.go
Normal 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"`
|
||||||
|
}
|
||||||
15
internal/models/message_queue.go
Normal file
15
internal/models/message_queue.go
Normal 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"`
|
||||||
|
}
|
||||||
65
internal/models/model.go
Normal file
65
internal/models/model.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel errors for model lookup methods.
|
||||||
|
var (
|
||||||
|
ErrUserLookupNotAvailable = errors.New("user lookup not available")
|
||||||
|
ErrChannelLookupNotAvailable = errors.New("channel lookup not available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,nolintlint // returns interface by design
|
||||||
|
if ul, ok := b.db.(UserLookup); ok {
|
||||||
|
return ul
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelLookup returns the DB as a ChannelLookup if it implements the interface.
|
||||||
|
func (b *Base) GetChannelLookup() ChannelLookup { //nolint:ireturn,nolintlint // returns interface by design
|
||||||
|
if cl, ok := b.db.(ChannelLookup); ok {
|
||||||
|
return cl
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
18
internal/models/server_link.go
Normal file
18
internal/models/server_link.go
Normal 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"`
|
||||||
|
}
|
||||||
26
internal/models/session.go
Normal file
26
internal/models/session.go
Normal 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
92
internal/models/user.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
|
||||||
package ratelimit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// DefaultRate is the default number of allowed requests per second.
|
|
||||||
DefaultRate = 1.0
|
|
||||||
|
|
||||||
// DefaultBurst is the default maximum burst size.
|
|
||||||
DefaultBurst = 5
|
|
||||||
|
|
||||||
// DefaultSweepInterval controls how often stale entries are pruned.
|
|
||||||
DefaultSweepInterval = 10 * time.Minute
|
|
||||||
|
|
||||||
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
|
||||||
DefaultEntryTTL = 15 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// entry tracks a per-IP rate limiter and when it was last used.
|
|
||||||
type entry struct {
|
|
||||||
limiter *rate.Limiter
|
|
||||||
lastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limiter manages per-key rate limiters with automatic cleanup
|
|
||||||
// of stale entries.
|
|
||||||
type Limiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]*entry
|
|
||||||
rate rate.Limit
|
|
||||||
burst int
|
|
||||||
entryTTL time.Duration
|
|
||||||
stopCh chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new per-key rate Limiter.
|
|
||||||
// The ratePerSec parameter sets how many requests per second are
|
|
||||||
// allowed per key. The burst parameter sets the maximum number of
|
|
||||||
// requests that can be made in a single burst.
|
|
||||||
func New(ratePerSec float64, burst int) *Limiter {
|
|
||||||
limiter := &Limiter{
|
|
||||||
mu: sync.Mutex{},
|
|
||||||
entries: make(map[string]*entry),
|
|
||||||
rate: rate.Limit(ratePerSec),
|
|
||||||
burst: burst,
|
|
||||||
entryTTL: DefaultEntryTTL,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
go limiter.sweepLoop()
|
|
||||||
|
|
||||||
return limiter
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow reports whether a request from the given key should be
|
|
||||||
// allowed. It consumes one token from the key's rate limiter.
|
|
||||||
func (l *Limiter) Allow(key string) bool {
|
|
||||||
l.mu.Lock()
|
|
||||||
ent, exists := l.entries[key]
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
ent = &entry{
|
|
||||||
limiter: rate.NewLimiter(l.rate, l.burst),
|
|
||||||
lastSeen: time.Now(),
|
|
||||||
}
|
|
||||||
l.entries[key] = ent
|
|
||||||
} else {
|
|
||||||
ent.lastSeen = time.Now()
|
|
||||||
}
|
|
||||||
l.mu.Unlock()
|
|
||||||
|
|
||||||
return ent.limiter.Allow()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop terminates the background sweep goroutine.
|
|
||||||
func (l *Limiter) Stop() {
|
|
||||||
close(l.stopCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len returns the number of tracked keys (for testing).
|
|
||||||
func (l *Limiter) Len() int {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
return len(l.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweepLoop periodically removes entries that haven't been seen
|
|
||||||
// within the TTL.
|
|
||||||
func (l *Limiter) sweepLoop() {
|
|
||||||
ticker := time.NewTicker(DefaultSweepInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
l.sweep()
|
|
||||||
case <-l.stopCh:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweep removes stale entries.
|
|
||||||
func (l *Limiter) sweep() {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-l.entryTTL)
|
|
||||||
|
|
||||||
for key, ent := range l.entries {
|
|
||||||
if ent.lastSeen.Before(cutoff) {
|
|
||||||
delete(l.entries, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package ratelimit_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewCreatesLimiter(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if limiter == nil {
|
|
||||||
t.Fatal("expected non-nil limiter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowWithinBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 3)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
if !limiter.Allow("192.168.1.1") {
|
|
||||||
t.Fatalf(
|
|
||||||
"request %d should be allowed within burst",
|
|
||||||
i+1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowExceedsBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Rate of 0 means no token replenishment, only burst.
|
|
||||||
limiter := ratelimit.New(0, 3)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("fourth request should be denied after burst exhausted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowSeparateKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Rate of 0, burst of 1 — only one request per key.
|
|
||||||
limiter := ratelimit.New(0, 1)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if !limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("first request for key A should be allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !limiter.Allow("10.0.0.2") {
|
|
||||||
t.Fatal("first request for key B should be allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("second request for key A should be denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.2") {
|
|
||||||
t.Fatal("second request for key B should be denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLenTracksKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if limiter.Len() != 0 {
|
|
||||||
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
limiter.Allow("10.0.0.2")
|
|
||||||
|
|
||||||
if limiter.Len() != 2 {
|
|
||||||
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same key again should not increase count.
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
|
|
||||||
if limiter.Len() != 2 {
|
|
||||||
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStopDoesNotPanic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
limiter.Stop()
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,6 @@ import "time"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
httpReadTimeout = 10 * time.Second
|
httpReadTimeout = 10 * time.Second
|
||||||
httpWriteTimeout = 60 * time.Second
|
httpWriteTimeout = 10 * time.Second
|
||||||
maxHeaderBytes = 1 << 20
|
maxHeaderBytes = 1 << 20
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,165 +5,103 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/chat/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const routeTimeout = 60 * time.Second
|
const routeTimeout = 60 * time.Second
|
||||||
|
|
||||||
// SetupRoutes configures the HTTP routes and middleware.
|
// SetupRoutes configures the HTTP routes and middleware chain.
|
||||||
func (srv *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
srv.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
srv.router.Use(middleware.Recoverer)
|
s.router.Use(middleware.Recoverer)
|
||||||
srv.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
srv.router.Use(srv.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
srv.router.Use(srv.mw.Metrics())
|
s.router.Use(s.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
srv.router.Use(srv.mw.CSP())
|
s.router.Use(middleware.Timeout(routeTimeout))
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentryHandler := sentryhttp.New(
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
sentryhttp.Options{ //nolint:exhaustruct // optional fields
|
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
},
|
})
|
||||||
)
|
s.router.Use(sentryHandler.Handle)
|
||||||
|
|
||||||
srv.router.Use(sentryHandler.Handle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check.
|
// Health check
|
||||||
srv.router.Get(
|
s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck())
|
||||||
"/.well-known/healthcheck.json",
|
|
||||||
srv.handlers.HandleHealthCheck(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Protected metrics endpoint.
|
// Protected metrics endpoint
|
||||||
if viper.GetString("METRICS_USERNAME") != "" {
|
if viper.GetString("METRICS_USERNAME") != "" {
|
||||||
srv.router.Group(func(router chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
router.Use(srv.mw.MetricsAuth())
|
r.Use(s.mw.MetricsAuth())
|
||||||
router.Get("/metrics",
|
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
||||||
http.HandlerFunc(
|
|
||||||
promhttp.Handler().ServeHTTP,
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// API v1.
|
// API v1
|
||||||
srv.router.Route("/api/v1", srv.setupAPIv1)
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/server", s.h.HandleServerInfo())
|
||||||
|
r.Post("/session", s.h.HandleCreateSession())
|
||||||
|
|
||||||
// Serve embedded SPA.
|
// Unified state and message endpoints
|
||||||
srv.setupSPA()
|
r.Get("/state", s.h.HandleState())
|
||||||
}
|
r.Get("/messages", s.h.HandleGetMessages())
|
||||||
|
r.Post("/messages", s.h.HandleSendCommand())
|
||||||
|
r.Get("/history", s.h.HandleGetHistory())
|
||||||
|
|
||||||
func (srv *Server) setupAPIv1(router chi.Router) {
|
// Channels
|
||||||
router.Get(
|
r.Get("/channels", s.h.HandleListAllChannels())
|
||||||
"/server",
|
r.Get("/channels/{channel}/members", s.h.HandleChannelMembers())
|
||||||
srv.handlers.HandleServerInfo(),
|
})
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/session",
|
|
||||||
srv.handlers.HandleCreateSession(),
|
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/register",
|
|
||||||
srv.handlers.HandleRegister(),
|
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/login",
|
|
||||||
srv.handlers.HandleLogin(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/state",
|
|
||||||
srv.handlers.HandleState(),
|
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/logout",
|
|
||||||
srv.handlers.HandleLogout(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/users/me",
|
|
||||||
srv.handlers.HandleUsersMe(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/messages",
|
|
||||||
srv.handlers.HandleGetMessages(),
|
|
||||||
)
|
|
||||||
router.Post(
|
|
||||||
"/messages",
|
|
||||||
srv.handlers.HandleSendCommand(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/history",
|
|
||||||
srv.handlers.HandleGetHistory(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/channels",
|
|
||||||
srv.handlers.HandleListAllChannels(),
|
|
||||||
)
|
|
||||||
router.Get(
|
|
||||||
"/channels/{channel}/members",
|
|
||||||
srv.handlers.HandleChannelMembers(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) setupSPA() {
|
// Serve embedded SPA
|
||||||
distFS, err := fs.Sub(web.Dist, "dist")
|
distFS, err := fs.Sub(web.Dist, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
srv.log.Error(
|
s.log.Error("failed to get web dist filesystem", "error", err)
|
||||||
"failed to get web dist filesystem",
|
} else {
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileServer := http.FileServer(http.FS(distFS))
|
fileServer := http.FileServer(http.FS(distFS))
|
||||||
|
|
||||||
srv.router.Get("/*", func(
|
s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writer http.ResponseWriter,
|
s.serveSPA(distFS, fileServer, w, r)
|
||||||
request *http.Request,
|
})
|
||||||
) {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveSPA(
|
||||||
|
distFS fs.FS,
|
||||||
|
fileServer http.Handler,
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
readFS, ok := distFS.(fs.ReadFileFS)
|
readFS, ok := distFS.(fs.ReadFileFS)
|
||||||
if !ok {
|
if !ok {
|
||||||
fileServer.ServeHTTP(writer, request)
|
http.Error(w, "filesystem error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fileData, readErr := readFS.ReadFile(
|
// Try to serve the file; fall back to index.html for SPA routing.
|
||||||
request.URL.Path[1:],
|
f, err := readFS.ReadFile(r.URL.Path[1:])
|
||||||
)
|
if err != nil || len(f) == 0 {
|
||||||
if readErr != nil || len(fileData) == 0 {
|
indexHTML, _ := readFS.ReadFile("index.html")
|
||||||
indexHTML, indexErr := readFS.ReadFile(
|
|
||||||
"index.html",
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
)
|
w.WriteHeader(http.StatusOK)
|
||||||
if indexErr != nil {
|
_, _ = w.Write(indexHTML)
|
||||||
http.NotFound(writer, request)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.Header().Set(
|
fileServer.ServeHTTP(w, r)
|
||||||
"Content-Type",
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
)
|
|
||||||
writer.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = writer.Write(indexHTML)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileServer.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package server implements the main HTTP server for the neoirc application.
|
// Package server implements the main HTTP server for the chat application.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
@@ -41,8 +41,7 @@ type Params struct {
|
|||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main HTTP server.
|
// Server is the main HTTP server. It manages routing, middleware, and lifecycle.
|
||||||
// It manages routing, middleware, and lifecycle.
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
@@ -54,24 +53,21 @@ type Server struct {
|
|||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
params Params
|
params Params
|
||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
handlers *handlers.Handlers
|
h *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Server and registers its lifecycle hooks.
|
// New creates a new Server and registers its lifecycle hooks.
|
||||||
func New(
|
func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
||||||
lifecycle fx.Lifecycle, params Params,
|
s := new(Server)
|
||||||
) (*Server, error) {
|
s.params = params
|
||||||
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle
|
s.mw = params.Middleware
|
||||||
params: params,
|
s.h = params.Handlers
|
||||||
mw: params.Middleware,
|
s.log = params.Logger.Get()
|
||||||
handlers: params.Handlers,
|
|
||||||
log: params.Logger.Get(),
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
s.startupTime = time.Now()
|
||||||
go srv.Run() //nolint:contextcheck
|
go s.Run() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -80,140 +76,120 @@ func New(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return srv, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
// Run starts the server configuration, Sentry, and begins serving.
|
||||||
func (srv *Server) Run() {
|
func (s *Server) Run() {
|
||||||
srv.configure()
|
s.configure()
|
||||||
srv.enableSentry()
|
s.enableSentry()
|
||||||
srv.serve()
|
s.serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP delegates to the chi router.
|
// ServeHTTP delegates to the chi router.
|
||||||
func (srv *Server) ServeHTTP(
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
writer http.ResponseWriter,
|
s.router.ServeHTTP(w, r)
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
srv.router.ServeHTTP(writer, request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaintenanceMode reports whether the server is in maintenance mode.
|
// MaintenanceMode reports whether the server is in maintenance mode.
|
||||||
func (srv *Server) MaintenanceMode() bool {
|
func (s *Server) MaintenanceMode() bool {
|
||||||
return srv.params.Config.MaintenanceMode
|
return s.params.Config.MaintenanceMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) enableSentry() {
|
func (s *Server) enableSentry() {
|
||||||
srv.sentryEnabled = false
|
s.sentryEnabled = false
|
||||||
|
|
||||||
if srv.params.Config.SentryDSN == "" {
|
if s.params.Config.SentryDSN == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
Dsn: srv.params.Config.SentryDSN,
|
Dsn: s.params.Config.SentryDSN,
|
||||||
Release: fmt.Sprintf(
|
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
||||||
"%s-%s",
|
|
||||||
srv.params.Globals.Appname,
|
|
||||||
srv.params.Globals.Version,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
srv.log.Error("sentry init failure", "error", err)
|
s.log.Error("sentry init failure", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.log.Info("sentry error reporting activated")
|
s.log.Info("sentry error reporting activated")
|
||||||
srv.sentryEnabled = true
|
s.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serve() int {
|
func (s *Server) serve() int {
|
||||||
srv.ctx, srv.cancelFunc = context.WithCancel(
|
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||||
context.Background(),
|
|
||||||
)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
sigCh := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
sig := <-c
|
||||||
|
s.log.Info("signal received", "signal", sig)
|
||||||
|
|
||||||
sig := <-sigCh
|
if s.cancelFunc != nil {
|
||||||
|
s.cancelFunc()
|
||||||
srv.log.Info("signal received", "signal", sig)
|
|
||||||
|
|
||||||
if srv.cancelFunc != nil {
|
|
||||||
srv.cancelFunc()
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go srv.serveUntilShutdown()
|
go s.serveUntilShutdown()
|
||||||
|
|
||||||
<-srv.ctx.Done()
|
<-s.ctx.Done()
|
||||||
|
|
||||||
srv.cleanShutdown()
|
s.cleanShutdown()
|
||||||
|
|
||||||
return srv.exitCode
|
return s.exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanupForExit() {
|
func (s *Server) cleanupForExit() {
|
||||||
srv.log.Info("cleaning up")
|
s.log.Info("cleaning up")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanShutdown() {
|
func (s *Server) cleanShutdown() {
|
||||||
srv.exitCode = 0
|
s.exitCode = 0
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
context.Background(), shutdownTimeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := srv.httpServer.Shutdown(ctxShutdown)
|
err := s.httpServer.Shutdown(ctxShutdown)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
srv.log.Error(
|
s.log.Error("server clean shutdown failed", "error", err)
|
||||||
"server clean shutdown failed", "error", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shutdownCancel != nil {
|
if shutdownCancel != nil {
|
||||||
shutdownCancel()
|
shutdownCancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.cleanupForExit()
|
s.cleanupForExit()
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentry.Flush(sentryFlushTime)
|
sentry.Flush(sentryFlushTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) configure() {
|
func (s *Server) configure() {
|
||||||
// Server configuration placeholder.
|
// server configuration placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serveUntilShutdown() {
|
func (s *Server) serveUntilShutdown() {
|
||||||
listenAddr := fmt.Sprintf(
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||||
":%d", srv.params.Config.Port,
|
s.httpServer = &http.Server{
|
||||||
)
|
|
||||||
|
|
||||||
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
ReadTimeout: httpReadTimeout,
|
ReadTimeout: httpReadTimeout,
|
||||||
WriteTimeout: httpWriteTimeout,
|
WriteTimeout: httpWriteTimeout,
|
||||||
MaxHeaderBytes: maxHeaderBytes,
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
Handler: srv,
|
Handler: s,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
srv.log.Info(
|
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||||
"http begin listen", "listenaddr", listenAddr,
|
|
||||||
)
|
|
||||||
|
|
||||||
err := srv.httpServer.ListenAndServe()
|
err := s.httpServer.ListenAndServe()
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
srv.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if srv.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
srv.cancelFunc()
|
s.cancelFunc()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// Package stats tracks runtime statistics since server boot.
|
|
||||||
package stats
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tracker holds atomic counters for runtime statistics
|
|
||||||
// that accumulate since the server started.
|
|
||||||
type Tracker struct {
|
|
||||||
connectionsSinceBoot atomic.Int64
|
|
||||||
sessionsSinceBoot atomic.Int64
|
|
||||||
messagesSinceBoot atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Tracker with all counters at zero.
|
|
||||||
func New() *Tracker {
|
|
||||||
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrConnections increments the total connection count.
|
|
||||||
func (t *Tracker) IncrConnections() {
|
|
||||||
t.connectionsSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrSessions increments the total session count.
|
|
||||||
func (t *Tracker) IncrSessions() {
|
|
||||||
t.sessionsSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrMessages increments the total PRIVMSG/NOTICE count.
|
|
||||||
func (t *Tracker) IncrMessages() {
|
|
||||||
t.messagesSinceBoot.Add(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectionsSinceBoot returns the total number of
|
|
||||||
// client connections since boot.
|
|
||||||
func (t *Tracker) ConnectionsSinceBoot() int64 {
|
|
||||||
return t.connectionsSinceBoot.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionsSinceBoot returns the total number of sessions
|
|
||||||
// created since boot.
|
|
||||||
func (t *Tracker) SessionsSinceBoot() int64 {
|
|
||||||
return t.sessionsSinceBoot.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessagesSinceBoot returns the total number of
|
|
||||||
// PRIVMSG/NOTICE messages sent since boot.
|
|
||||||
func (t *Tracker) MessagesSinceBoot() int64 {
|
|
||||||
return t.messagesSinceBoot.Load()
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package stats_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
if tracker == nil {
|
|
||||||
t.Fatal("expected non-nil tracker")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.ConnectionsSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 connections, got %d",
|
|
||||||
tracker.ConnectionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.SessionsSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 sessions, got %d",
|
|
||||||
tracker.SessionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.MessagesSinceBoot() != 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 0 messages, got %d",
|
|
||||||
tracker.MessagesSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrConnections(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrConnections()
|
|
||||||
|
|
||||||
got := tracker.ConnectionsSinceBoot()
|
|
||||||
if got != 3 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 3 connections, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrSessions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrSessions()
|
|
||||||
tracker.IncrSessions()
|
|
||||||
|
|
||||||
got := tracker.SessionsSinceBoot()
|
|
||||||
if got != 2 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 2 sessions, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIncrMessages(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrMessages()
|
|
||||||
|
|
||||||
got := tracker.MessagesSinceBoot()
|
|
||||||
if got != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 message, got %d", got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountersAreIndependent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tracker := stats.New()
|
|
||||||
|
|
||||||
tracker.IncrConnections()
|
|
||||||
tracker.IncrSessions()
|
|
||||||
tracker.IncrMessages()
|
|
||||||
tracker.IncrMessages()
|
|
||||||
|
|
||||||
if tracker.ConnectionsSinceBoot() != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 connection, got %d",
|
|
||||||
tracker.ConnectionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.SessionsSinceBoot() != 1 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 1 session, got %d",
|
|
||||||
tracker.SessionsSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracker.MessagesSinceBoot() != 2 {
|
|
||||||
t.Errorf(
|
|
||||||
"expected 2 messages, got %d",
|
|
||||||
tracker.MessagesSinceBoot(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package irc
|
|
||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
|
||||||
const (
|
|
||||||
CmdAway = "AWAY"
|
|
||||||
CmdJoin = "JOIN"
|
|
||||||
CmdList = "LIST"
|
|
||||||
CmdLusers = "LUSERS"
|
|
||||||
CmdMode = "MODE"
|
|
||||||
CmdMotd = "MOTD"
|
|
||||||
CmdNames = "NAMES"
|
|
||||||
CmdNick = "NICK"
|
|
||||||
CmdNotice = "NOTICE"
|
|
||||||
CmdPart = "PART"
|
|
||||||
CmdPing = "PING"
|
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IRCMessageType represents an IRC numeric reply or error code.
|
|
||||||
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for this numeric code
|
|
||||||
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
|
||||||
// Returns an empty string if the code is unknown.
|
|
||||||
func (t IRCMessageType) Name() string {
|
|
||||||
return names[t]
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the name and numeric code in angle brackets
|
|
||||||
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
|
||||||
// If the code is unknown, returns "UNKNOWN <NNN>".
|
|
||||||
func (t IRCMessageType) String() string {
|
|
||||||
n := names[t]
|
|
||||||
if n == "" {
|
|
||||||
n = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s <%03d>", n, int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code returns the three-digit zero-padded string representation
|
|
||||||
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
|
||||||
func (t IRCMessageType) Code() string {
|
|
||||||
return fmt.Sprintf("%03d", int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Int returns the bare integer value of the numeric code.
|
|
||||||
func (t IRCMessageType) Int() int {
|
|
||||||
return int(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
|
||||||
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
|
||||||
|
|
||||||
// FromInt converts an integer to an IRCMessageType, returning an error
|
|
||||||
// if the numeric code is not a known IRC reply or error code.
|
|
||||||
func FromInt(n int) (IRCMessageType, error) {
|
|
||||||
t := IRCMessageType(n)
|
|
||||||
if _, ok := names[t]; !ok {
|
|
||||||
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome IRCMessageType = 1
|
|
||||||
RplYourHost IRCMessageType = 2
|
|
||||||
RplCreated IRCMessageType = 3
|
|
||||||
RplMyInfo IRCMessageType = 4
|
|
||||||
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
|
||||||
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
// RFC 2812 trace/stats/links replies (200-219).
|
|
||||||
RplTraceLink IRCMessageType = 200
|
|
||||||
RplTraceConnecting IRCMessageType = 201
|
|
||||||
RplTraceHandshake IRCMessageType = 202
|
|
||||||
RplTraceUnknown IRCMessageType = 203
|
|
||||||
RplTraceOperator IRCMessageType = 204
|
|
||||||
RplTraceUser IRCMessageType = 205
|
|
||||||
RplTraceServer IRCMessageType = 206
|
|
||||||
RplTraceService IRCMessageType = 207
|
|
||||||
RplTraceNewType IRCMessageType = 208
|
|
||||||
RplTraceClass IRCMessageType = 209
|
|
||||||
RplStatsLinkInfo IRCMessageType = 211
|
|
||||||
RplStatsCommands IRCMessageType = 212
|
|
||||||
RplStatsCLine IRCMessageType = 213
|
|
||||||
RplStatsNLine IRCMessageType = 214
|
|
||||||
RplStatsILine IRCMessageType = 215
|
|
||||||
RplStatsKLine IRCMessageType = 216
|
|
||||||
RplStatsQLine IRCMessageType = 217
|
|
||||||
RplStatsYLine IRCMessageType = 218
|
|
||||||
RplEndOfStats IRCMessageType = 219
|
|
||||||
|
|
||||||
RplUmodeIs IRCMessageType = 221
|
|
||||||
RplServList IRCMessageType = 234
|
|
||||||
RplServListEnd IRCMessageType = 235
|
|
||||||
RplStatsLLine IRCMessageType = 241
|
|
||||||
RplStatsUptime IRCMessageType = 242
|
|
||||||
RplStatsOLine IRCMessageType = 243
|
|
||||||
RplStatsHLine IRCMessageType = 244
|
|
||||||
RplLuserClient IRCMessageType = 251
|
|
||||||
RplLuserOp IRCMessageType = 252
|
|
||||||
RplLuserUnknown IRCMessageType = 253
|
|
||||||
|
|
||||||
RplLuserChannels IRCMessageType = 254
|
|
||||||
RplLuserMe IRCMessageType = 255
|
|
||||||
RplAdminMe IRCMessageType = 256
|
|
||||||
RplAdminLoc1 IRCMessageType = 257
|
|
||||||
RplAdminLoc2 IRCMessageType = 258
|
|
||||||
RplAdminEmail IRCMessageType = 259
|
|
||||||
RplTraceLog IRCMessageType = 261
|
|
||||||
RplTraceEnd IRCMessageType = 262
|
|
||||||
RplTryAgain IRCMessageType = 263
|
|
||||||
|
|
||||||
RplAway IRCMessageType = 301
|
|
||||||
RplUserHost IRCMessageType = 302
|
|
||||||
RplIson IRCMessageType = 303
|
|
||||||
RplUnaway IRCMessageType = 305
|
|
||||||
RplNowAway IRCMessageType = 306
|
|
||||||
RplWhoisUser IRCMessageType = 311
|
|
||||||
RplWhoisServer IRCMessageType = 312
|
|
||||||
RplWhoisOperator IRCMessageType = 313
|
|
||||||
RplWhoWasUser IRCMessageType = 314
|
|
||||||
RplEndOfWho IRCMessageType = 315
|
|
||||||
RplWhoisIdle IRCMessageType = 317
|
|
||||||
RplEndOfWhois IRCMessageType = 318
|
|
||||||
RplWhoisChannels IRCMessageType = 319
|
|
||||||
RplListStart IRCMessageType = 321
|
|
||||||
RplList IRCMessageType = 322
|
|
||||||
RplListEnd IRCMessageType = 323
|
|
||||||
RplChannelModeIs IRCMessageType = 324
|
|
||||||
|
|
||||||
RplUniqOpIs IRCMessageType = 325
|
|
||||||
RplCreationTime IRCMessageType = 329
|
|
||||||
RplNoTopic IRCMessageType = 331
|
|
||||||
RplTopic IRCMessageType = 332
|
|
||||||
RplTopicWhoTime IRCMessageType = 333
|
|
||||||
RplInviting IRCMessageType = 341
|
|
||||||
RplSummoning IRCMessageType = 342
|
|
||||||
RplInviteList IRCMessageType = 346
|
|
||||||
RplEndOfInviteList IRCMessageType = 347
|
|
||||||
RplExceptList IRCMessageType = 348
|
|
||||||
RplEndOfExceptList IRCMessageType = 349
|
|
||||||
RplVersion IRCMessageType = 351
|
|
||||||
RplWhoReply IRCMessageType = 352
|
|
||||||
RplNamReply IRCMessageType = 353
|
|
||||||
RplLinks IRCMessageType = 364
|
|
||||||
RplEndOfLinks IRCMessageType = 365
|
|
||||||
RplEndOfNames IRCMessageType = 366
|
|
||||||
RplBanList IRCMessageType = 367
|
|
||||||
RplEndOfBanList IRCMessageType = 368
|
|
||||||
RplEndOfWhowas IRCMessageType = 369
|
|
||||||
RplInfo IRCMessageType = 371
|
|
||||||
RplMotd IRCMessageType = 372
|
|
||||||
RplEndOfInfo IRCMessageType = 374
|
|
||||||
RplMotdStart IRCMessageType = 375
|
|
||||||
RplEndOfMotd IRCMessageType = 376
|
|
||||||
RplYoureOper IRCMessageType = 381
|
|
||||||
RplRehashing IRCMessageType = 382
|
|
||||||
RplYoureService IRCMessageType = 383
|
|
||||||
RplTime IRCMessageType = 391
|
|
||||||
RplUsersStart IRCMessageType = 392
|
|
||||||
RplUsers IRCMessageType = 393
|
|
||||||
RplEndOfUsers IRCMessageType = 394
|
|
||||||
RplNoUsers IRCMessageType = 395
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick IRCMessageType = 401
|
|
||||||
ErrNoSuchServer IRCMessageType = 402
|
|
||||||
ErrNoSuchChannel IRCMessageType = 403
|
|
||||||
ErrCannotSendToChan IRCMessageType = 404
|
|
||||||
ErrTooManyChannels IRCMessageType = 405
|
|
||||||
ErrWasNoSuchNick IRCMessageType = 406
|
|
||||||
ErrTooManyTargets IRCMessageType = 407
|
|
||||||
ErrNoSuchService IRCMessageType = 408
|
|
||||||
ErrNoOrigin IRCMessageType = 409
|
|
||||||
ErrNoRecipient IRCMessageType = 411
|
|
||||||
ErrNoTextToSend IRCMessageType = 412
|
|
||||||
ErrNoTopLevel IRCMessageType = 413
|
|
||||||
ErrWildTopLevel IRCMessageType = 414
|
|
||||||
ErrBadMask IRCMessageType = 415
|
|
||||||
ErrUnknownCommand IRCMessageType = 421
|
|
||||||
ErrNoMotd IRCMessageType = 422
|
|
||||||
ErrNoAdminInfo IRCMessageType = 423
|
|
||||||
ErrFileError IRCMessageType = 424
|
|
||||||
ErrNoNicknameGiven IRCMessageType = 431
|
|
||||||
ErrErroneusNickname IRCMessageType = 432
|
|
||||||
ErrNicknameInUse IRCMessageType = 433
|
|
||||||
ErrNickCollision IRCMessageType = 436
|
|
||||||
ErrUnavailResource IRCMessageType = 437
|
|
||||||
|
|
||||||
ErrUserNotInChannel IRCMessageType = 441
|
|
||||||
ErrNotOnChannel IRCMessageType = 442
|
|
||||||
ErrUserOnChannel IRCMessageType = 443
|
|
||||||
ErrNoLogin IRCMessageType = 444
|
|
||||||
ErrSummonDisabled IRCMessageType = 445
|
|
||||||
ErrUsersDisabled IRCMessageType = 446
|
|
||||||
ErrNotRegistered IRCMessageType = 451
|
|
||||||
ErrNeedMoreParams IRCMessageType = 461
|
|
||||||
ErrAlreadyRegistered IRCMessageType = 462
|
|
||||||
ErrNoPermForHost IRCMessageType = 463
|
|
||||||
ErrPasswdMismatch IRCMessageType = 464
|
|
||||||
ErrYoureBannedCreep IRCMessageType = 465
|
|
||||||
ErrYouWillBeBanned IRCMessageType = 466
|
|
||||||
ErrKeySet IRCMessageType = 467
|
|
||||||
ErrChannelIsFull IRCMessageType = 471
|
|
||||||
ErrUnknownMode IRCMessageType = 472
|
|
||||||
ErrInviteOnlyChan IRCMessageType = 473
|
|
||||||
ErrBannedFromChan IRCMessageType = 474
|
|
||||||
ErrBadChannelKey IRCMessageType = 475
|
|
||||||
ErrBadChanMask IRCMessageType = 476
|
|
||||||
ErrNoChanModes IRCMessageType = 477
|
|
||||||
ErrBanListFull IRCMessageType = 478
|
|
||||||
ErrNoPrivileges IRCMessageType = 481
|
|
||||||
ErrChanOpPrivsNeeded IRCMessageType = 482
|
|
||||||
ErrCantKillServer IRCMessageType = 483
|
|
||||||
ErrRestricted IRCMessageType = 484
|
|
||||||
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
|
||||||
ErrNoOperHost IRCMessageType = 491
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag IRCMessageType = 501
|
|
||||||
ErrUsersDoNotMatch IRCMessageType = 502
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[IRCMessageType]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplBounce: "RPL_BOUNCE",
|
|
||||||
|
|
||||||
RplTraceLink: "RPL_TRACELINK",
|
|
||||||
RplTraceConnecting: "RPL_TRACECONNECTING",
|
|
||||||
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
|
||||||
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
|
||||||
RplTraceOperator: "RPL_TRACEOPERATOR",
|
|
||||||
RplTraceUser: "RPL_TRACEUSER",
|
|
||||||
RplTraceServer: "RPL_TRACESERVER",
|
|
||||||
RplTraceService: "RPL_TRACESERVICE",
|
|
||||||
RplTraceNewType: "RPL_TRACENEWTYPE",
|
|
||||||
RplTraceClass: "RPL_TRACECLASS",
|
|
||||||
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
|
||||||
RplStatsCommands: "RPL_STATSCOMMANDS",
|
|
||||||
RplStatsCLine: "RPL_STATSCLINE",
|
|
||||||
RplStatsNLine: "RPL_STATSNLINE",
|
|
||||||
RplStatsILine: "RPL_STATSILINE",
|
|
||||||
RplStatsKLine: "RPL_STATSKLINE",
|
|
||||||
RplStatsQLine: "RPL_STATSQLINE",
|
|
||||||
RplStatsYLine: "RPL_STATSYLINE",
|
|
||||||
RplEndOfStats: "RPL_ENDOFSTATS",
|
|
||||||
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplServList: "RPL_SERVLIST",
|
|
||||||
RplServListEnd: "RPL_SERVLISTEND",
|
|
||||||
RplStatsLLine: "RPL_STATSLLINE",
|
|
||||||
RplStatsUptime: "RPL_STATSUPTIME",
|
|
||||||
RplStatsOLine: "RPL_STATSOLINE",
|
|
||||||
RplStatsHLine: "RPL_STATSHLINE",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAdminMe: "RPL_ADMINME",
|
|
||||||
RplAdminLoc1: "RPL_ADMINLOC1",
|
|
||||||
RplAdminLoc2: "RPL_ADMINLOC2",
|
|
||||||
RplAdminEmail: "RPL_ADMINEMAIL",
|
|
||||||
RplTraceLog: "RPL_TRACELOG",
|
|
||||||
RplTraceEnd: "RPL_TRACEEND",
|
|
||||||
RplTryAgain: "RPL_TRYAGAIN",
|
|
||||||
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplWhoWasUser: "RPL_WHOWASUSER",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplListStart: "RPL_LISTSTART",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
|
|
||||||
RplUniqOpIs: "RPL_UNIQOPIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplSummoning: "RPL_SUMMONING",
|
|
||||||
RplInviteList: "RPL_INVITELIST",
|
|
||||||
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
|
||||||
RplExceptList: "RPL_EXCEPTLIST",
|
|
||||||
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
|
||||||
RplVersion: "RPL_VERSION",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplLinks: "RPL_LINKS",
|
|
||||||
RplEndOfLinks: "RPL_ENDOFLINKS",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
|
||||||
RplInfo: "RPL_INFO",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplEndOfInfo: "RPL_ENDOFINFO",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
RplYoureOper: "RPL_YOUREOPER",
|
|
||||||
RplRehashing: "RPL_REHASHING",
|
|
||||||
RplYoureService: "RPL_YOURESERVICE",
|
|
||||||
RplTime: "RPL_TIME",
|
|
||||||
RplUsersStart: "RPL_USERSSTART",
|
|
||||||
RplUsers: "RPL_USERS",
|
|
||||||
RplEndOfUsers: "RPL_ENDOFUSERS",
|
|
||||||
RplNoUsers: "RPL_NOUSERS",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
|
||||||
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
|
||||||
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
|
||||||
ErrNoOrigin: "ERR_NOORIGIN",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
|
||||||
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
|
||||||
ErrBadMask: "ERR_BADMASK",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoMotd: "ERR_NOMOTD",
|
|
||||||
ErrNoAdminInfo: "ERR_NOADMININFO",
|
|
||||||
ErrFileError: "ERR_FILEERROR",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrNickCollision: "ERR_NICKCOLLISION",
|
|
||||||
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
|
||||||
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
|
||||||
ErrNoLogin: "ERR_NOLOGIN",
|
|
||||||
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
|
||||||
ErrUsersDisabled: "ERR_USERSDISABLED",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
|
||||||
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
|
||||||
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
|
||||||
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
|
||||||
ErrKeySet: "ERR_KEYSET",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrBadChanMask: "ERR_BADCHANMASK",
|
|
||||||
ErrNoChanModes: "ERR_NOCHANMODES",
|
|
||||||
ErrBanListFull: "ERR_BANLISTFULL",
|
|
||||||
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
|
||||||
ErrRestricted: "ERR_RESTRICTED",
|
|
||||||
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
|
||||||
ErrNoOperHost: "ERR_NOOPERHOST",
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
|
||||||
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
//
|
|
||||||
// Deprecated: Use IRCMessageType.Name() instead.
|
|
||||||
func Name(code IRCMessageType) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package irc_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
|
||||||
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Name(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.String(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "001"},
|
|
||||||
{irc.RplBounce, "005"},
|
|
||||||
{irc.RplLuserOp, "252"},
|
|
||||||
{irc.ErrCannotSendToChan, "404"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Code(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, 1},
|
|
||||||
{irc.RplBounce, 5},
|
|
||||||
{irc.RplLuserOp, 252},
|
|
||||||
{irc.ErrCannotSendToChan, 404},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Int(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Known(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
code int
|
|
||||||
want irc.IRCMessageType
|
|
||||||
}{
|
|
||||||
{1, irc.RplWelcome},
|
|
||||||
{5, irc.RplBounce},
|
|
||||||
{252, irc.RplLuserOp},
|
|
||||||
{404, irc.ErrCannotSendToChan},
|
|
||||||
{433, irc.ErrNicknameInUse},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
got, err := irc.FromInt(test.code)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != test.want {
|
|
||||||
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Unknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknowns := []int{0, 999, 600, -1}
|
|
||||||
for _, code := range unknowns {
|
|
||||||
_, err := irc.FromInt(code)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("FromInt(%d) expected error, got nil", code)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
|
||||||
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_Name(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
if got := unknown.Name(); got != "" {
|
|
||||||
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_String(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
want := "UNKNOWN <999>"
|
|
||||||
|
|
||||||
if got := unknown.String(); got != want {
|
|
||||||
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeprecatedNameFunc(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
|
||||||
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Message Schemas
|
# Message Schemas
|
||||||
|
|
||||||
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
||||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||||
over HTTP.
|
over HTTP.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"title": "PRIVMSG",
|
||||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"title": "PUBKEY",
|
||||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
||||||
"title": "TOPIC",
|
"title": "TOPIC",
|
||||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -17,6 +17,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["command", "to"],
|
"required": ["command", "to"],
|
||||||
"examples": [
|
"examples": [
|
||||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"title": "IRC Message Envelope",
|
||||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
||||||
"title": "002 RPL_YOURHOST",
|
"title": "002 RPL_YOURHOST",
|
||||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "002",
|
"command": "002",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"body": [
|
"body": [
|
||||||
"Your host is neoirc.example.com, running version 0.1.0"
|
"Your host is chat.example.com, running version 0.1.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
||||||
"title": "004 RPL_MYINFO",
|
"title": "004 RPL_MYINFO",
|
||||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "004",
|
"command": "004",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"params": [
|
"params": [
|
||||||
"neoirc.example.com",
|
"chat.example.com",
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"o",
|
"o",
|
||||||
"imnst+ov"
|
"imnst+ov"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
||||||
"title": "332 RPL_TOPIC",
|
"title": "332 RPL_TOPIC",
|
||||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"#general"
|
"#general"
|
||||||
],
|
],
|
||||||
"body": [
|
"body": [
|
||||||
"Welcome to the channel"
|
"Welcome to the chat"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
||||||
"title": "401 ERR_NOSUCHNICK",
|
"title": "401 ERR_NOSUCHNICK",
|
||||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
||||||
"title": "403 ERR_NOSUCHCHANNEL",
|
"title": "403 ERR_NOSUCHCHANNEL",
|
||||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
12
web/build.sh
12
web/build.sh
@@ -16,11 +16,19 @@ fi
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
# Build JS bundle
|
||||||
|
${NPX:+$NPX} esbuild src/app.jsx \
|
||||||
|
--bundle \
|
||||||
|
--minify \
|
||||||
|
--jsx-factory=h \
|
||||||
|
--jsx-fragment=Fragment \
|
||||||
|
--define:process.env.NODE_ENV=\"production\" \
|
||||||
|
--external:preact \
|
||||||
|
--outfile=dist/app.js \
|
||||||
|
2>/dev/null || \
|
||||||
${NPX:+$NPX} esbuild src/app.jsx \
|
${NPX:+$NPX} esbuild src/app.jsx \
|
||||||
--bundle \
|
--bundle \
|
||||||
--minify \
|
--minify \
|
||||||
--format=esm \
|
|
||||||
--jsx-factory=h \
|
--jsx-factory=h \
|
||||||
--jsx-fragment=Fragment \
|
--jsx-fragment=Fragment \
|
||||||
--define:process.env.NODE_ENV=\"production\" \
|
--define:process.env.NODE_ENV=\"production\" \
|
||||||
|
|||||||
1
web/dist/app.js
vendored
Normal file
1
web/dist/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
Normal file
13
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
274
web/dist/style.css
vendored
Normal file
274
web/dist/style.css
vendored
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-input: #0f3460;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #888;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent2: #0f3460;
|
||||||
|
--border: #2a2a4a;
|
||||||
|
--nick: #53a8b6;
|
||||||
|
--timestamp: #666;
|
||||||
|
--tab-active: #e94560;
|
||||||
|
--tab-bg: #16213e;
|
||||||
|
--tab-hover: #1a1a3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login screen */
|
||||||
|
.login-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen h1 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen input {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen button {
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen .error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen .motd {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: var(--tab-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .close-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .close-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.messages-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 2px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .timestamp {
|
||||||
|
color: var(--timestamp);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .nick {
|
||||||
|
color: var(--nick);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .nick::before { content: '<'; }
|
||||||
|
.message .nick::after { content: '>'; }
|
||||||
|
|
||||||
|
.message.system {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system .nick {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system .nick::before,
|
||||||
|
.message.system .nick::after { content: ''; }
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User list */
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list h3 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user {
|
||||||
|
padding: 3px 4px;
|
||||||
|
color: var(--nick);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user:hover {
|
||||||
|
background: var(--tab-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server tab */
|
||||||
|
.server-messages {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel join dialog */
|
||||||
|
.join-dialog {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--accent2);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.user-list { display: none; }
|
||||||
|
.tab { padding: 6px 10px; font-size: 13px; }
|
||||||
|
}
|
||||||
1362
web/src/app.jsx
1362
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user