Compare commits
40 Commits
feature/mv
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2af55e4a5 | ||
| e36bd99ef6 | |||
| e9d794764b | |||
| 052674b4ee | |||
| cab5784913 | |||
| 75cecd9803 | |||
| f2e7a6ec85 | |||
| 27df999942 | |||
| b19c8b5759 | |||
| 67446b36a1 | |||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 | |||
| f287fdf6d1 | |||
| 687c958bd1 | |||
| 946f208ac2 | |||
| 47fb089969 | |||
| f8f0b6afbb | |||
| 8e3166969e | |||
| c0e344d6fc | |||
| 2da7f11484 | |||
| 6e7bf028c1 | |||
| 2761ee156a | |||
| cd909d59c4 | |||
|
|
f5cc098b7b | ||
|
|
4d7b7618b2 | ||
|
|
910a5c2606 | ||
| bdc243224b | |||
| 5981c750a4 | |||
| 6cfab21eaa | |||
| 4a0ed57fc0 | |||
|
|
52c85724a7 | ||
|
|
69c9550bb2 | ||
| 7047167dc8 | |||
| 3cd942ffa5 | |||
| b8794c2587 | |||
| 70aa15e758 | |||
| 5e26e53187 | |||
| 02b906badb | |||
|
|
32419fb1f7 |
@@ -1,8 +1,8 @@
|
||||
.git
|
||||
*.md
|
||||
!README.md
|
||||
chatd
|
||||
chat-cli
|
||||
neoircd
|
||||
neoirc-cli
|
||||
data.db
|
||||
data.db-wal
|
||||
data.db-shm
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,7 +21,8 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Build artifacts
|
||||
/chatd
|
||||
web/dist/
|
||||
/neoircd
|
||||
/bin/
|
||||
*.exe
|
||||
*.dll
|
||||
@@ -34,5 +35,5 @@ vendor/
|
||||
# Project
|
||||
data.db
|
||||
debug.log
|
||||
/chat-cli
|
||||
/neoirc-cli
|
||||
web/node_modules/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
||||
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
||||
3. **Test**: `go test -race ./...` — all passing
|
||||
4. **Build**: `go build ./cmd/chatd` — compiles clean
|
||||
4. **Build**: `go build ./cmd/neoircd` — compiles clean
|
||||
|
||||
No commit lands on main with lint errors, test failures, or formatting issues.
|
||||
|
||||
|
||||
47
Dockerfile
47
Dockerfile
@@ -1,32 +1,59 @@
|
||||
# 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
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git build-base make
|
||||
|
||||
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
|
||||
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=web-builder /web/dist/ web/dist/
|
||||
|
||||
# Run all checks — build fails if branch is not green
|
||||
RUN make check
|
||||
RUN make test
|
||||
|
||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-26
|
||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S chat && adduser -S chat -G chat
|
||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||
&& 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 chat
|
||||
USER neoirc
|
||||
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
|
||||
|
||||
BINARY := chatd
|
||||
BINARY := neoircd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
all: check build
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
|
||||
lint:
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
@@ -27,7 +27,7 @@ test:
|
||||
# Used by CI and Docker build — fails if anything is wrong
|
||||
check: test lint fmt-check
|
||||
@echo "==> Building..."
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
||||
@echo "==> All checks passed!"
|
||||
|
||||
run: build
|
||||
@@ -37,10 +37,10 @@ debug: build
|
||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||
|
||||
clean:
|
||||
rm -rf bin/ chatd data.db
|
||||
rm -rf bin/ neoircd
|
||||
|
||||
docker:
|
||||
docker build -t chat .
|
||||
docker build -t neoirc .
|
||||
|
||||
hooks:
|
||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-02-22
|
||||
last_modified: 2026-03-09
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -98,6 +98,13 @@ style conventions are in separate documents:
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
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 force-push to `main`.
|
||||
@@ -144,8 +151,14 @@ style conventions are in separate documents:
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||
Post-1.0.0: add new migration files.
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
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
|
||||
settings.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
8
cmd/neoirc-cli/main.go
Normal file
8
cmd/neoirc-cli/main.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// 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()
|
||||
}
|
||||
43
cmd/neoircd/main.go
Normal file
43
cmd/neoircd/main.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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()
|
||||
}
|
||||
17
go.mod
17
go.mod
@@ -1,17 +1,21 @@
|
||||
module git.eeqj.de/sneak/chat
|
||||
module git.eeqj.de/sneak/neoirc
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
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/go-chi/chi v1.5.5
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
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/spf13/viper v1.21.0
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
@@ -21,9 +25,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // 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/google/uuid v1.6.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -33,7 +35,6 @@ require (
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // 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/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
@@ -47,9 +48,9 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
34
go.sum
34
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/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
@@ -113,12 +113,14 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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-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/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -126,8 +128,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-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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -135,30 +137,26 @@ 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.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-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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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.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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
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/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
)
|
||||
|
||||
func TestNewBroker(t *testing.T) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package chatapi provides a client for the chat server API.
|
||||
package chatapi
|
||||
// Package neoircapi provides a client for the neoirc server API.
|
||||
package neoircapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,7 +25,7 @@ const (
|
||||
|
||||
var errHTTP = errors.New("HTTP error")
|
||||
|
||||
// Client wraps HTTP calls to the chat server API.
|
||||
// Client wraps HTTP calls to the neoirc server API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
@@ -41,13 +43,30 @@ func NewClient(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// 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},
|
||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,7 +187,7 @@ func (client *Client) PollMessages(
|
||||
func (client *Client) JoinChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: "JOIN", To: channel,
|
||||
Command: irc.CmdJoin, To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -177,7 +196,7 @@ func (client *Client) JoinChannel(channel string) error {
|
||||
func (client *Client) PartChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: "PART", To: channel,
|
||||
Command: irc.CmdPart, To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
79
internal/cli/api/hashcash.go
Normal file
79
internal/cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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,10 +1,11 @@
|
||||
package chatapi
|
||||
package neoircapi
|
||||
|
||||
import "time"
|
||||
|
||||
// SessionRequest is the body for POST /api/v1/session.
|
||||
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.
|
||||
@@ -21,7 +22,7 @@ type StateResponse struct {
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// Message represents a chat message envelope.
|
||||
// Message represents a neoirc message envelope.
|
||||
type Message struct {
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
@@ -63,9 +64,10 @@ type Channel struct {
|
||||
|
||||
// ServerInfo is the response from GET /api/v1/server.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// MessagesResponse wraps polling results.
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package main is the entry point for the chat-cli client.
|
||||
package main
|
||||
// Package cli implements the neoirc-cli terminal client.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,7 +32,8 @@ type App struct {
|
||||
stopPoll chan struct{}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Run creates and runs the CLI application.
|
||||
func Run() {
|
||||
app := &App{ //nolint:exhaustruct
|
||||
ui: NewUI(),
|
||||
nick: "guest",
|
||||
@@ -41,7 +43,7 @@ func main() {
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to chat-cli — an IRC-style client",
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
@@ -86,7 +88,7 @@ func (a *App) handleInput(text string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "PRIVMSG",
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -138,16 +140,29 @@ func (a *App) dispatchCommand(cmd, args string) {
|
||||
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.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,
|
||||
@@ -228,7 +243,7 @@ func (a *App) cmdNick(nick string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "NICK",
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -363,7 +378,7 @@ func (a *App) cmdMsg(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "PRIVMSG",
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -421,7 +436,7 @@ func (a *App) cmdTopic(args string) {
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "TOPIC",
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -434,7 +449,7 @@ func (a *App) cmdTopic(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "TOPIC",
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
@@ -510,6 +525,96 @@ func (a *App) cmdList() {
|
||||
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(
|
||||
@@ -550,7 +655,7 @@ func (a *App) cmdQuit() {
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
@@ -564,7 +669,7 @@ func (a *App) cmdQuit() {
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** chat-cli commands:",
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
@@ -574,6 +679,9 @@ func (a *App) cmdHelp() {
|
||||
" /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",
|
||||
@@ -632,19 +740,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case "PRIVMSG":
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case "JOIN":
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case "PART":
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case "QUIT":
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case "NICK":
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case "NOTICE":
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case "TOPIC":
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -5,14 +5,22 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "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.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -23,21 +31,23 @@ type Params struct {
|
||||
|
||||
// Config holds all application configuration values.
|
||||
type Config struct {
|
||||
DBURL string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MaxHistory int
|
||||
SessionTimeout int
|
||||
MaxMessageSize int
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
DBURL string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
MessageMaxAge string
|
||||
MaxMessageSize int
|
||||
QueueMaxAge string
|
||||
MOTD string
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Config by reading from files and environment variables.
|
||||
@@ -56,16 +66,18 @@ func New(
|
||||
viper.SetDefault("DEBUG", "false")
|
||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||
viper.SetDefault("PORT", "8080")
|
||||
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("SESSION_TIMEOUT", "86400")
|
||||
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("MOTD", "")
|
||||
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
||||
viper.SetDefault("MOTD", defaultMOTD)
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@@ -77,21 +89,23 @@ func New(
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
DBURL: viper.GetString("DBURL"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
Port: viper.GetInt("PORT"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
|
||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||
MOTD: viper.GetString("MOTD"),
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
DBURL: viper.GetString("DBURL"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
Port: viper.GetInt("PORT"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
||||
MOTD: viper.GetString("MOTD"),
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
|
||||
165
internal/db/auth.go
Normal file
165
internal/db/auth.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
178
internal/db/auth_test.go
Normal file
178
internal/db/auth_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // .env
|
||||
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
||||
func (database *Database) connect(ctx context.Context) error {
|
||||
dbURL := database.params.Config.DBURL
|
||||
if dbURL == "" {
|
||||
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||
}
|
||||
|
||||
database.log.Info(
|
||||
|
||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -27,70 +27,91 @@ func setupTestDB(t *testing.T) *db.Database {
|
||||
return database
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
func TestCreateSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
id, token, err := database.CreateUser(ctx, "alice")
|
||||
sessionID, _, token, err := database.CreateSession(
|
||||
ctx, "alice",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if id == 0 || token == "" {
|
||||
if sessionID == 0 || token == "" {
|
||||
t.Fatal("expected valid id and token")
|
||||
}
|
||||
|
||||
_, _, err = database.CreateUser(ctx, "alice")
|
||||
if err == nil {
|
||||
_, _, dupToken, dupErr := database.CreateSession(
|
||||
ctx, "alice",
|
||||
)
|
||||
if dupErr == nil {
|
||||
t.Fatal("expected error for duplicate nick")
|
||||
}
|
||||
|
||||
_ = dupToken
|
||||
}
|
||||
|
||||
func TestGetUserByToken(t *testing.T) {
|
||||
func TestGetSessionByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, token, err := database.CreateUser(ctx, "bob")
|
||||
_, _, token, err := database.CreateSession(ctx, "bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, nick, err := database.GetUserByToken(ctx, token)
|
||||
sessionID, clientID, nick, err :=
|
||||
database.GetSessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if nick != "bob" || id == 0 {
|
||||
if nick != "bob" || sessionID == 0 || clientID == 0 {
|
||||
t.Fatalf("expected bob, got %s", nick)
|
||||
}
|
||||
|
||||
_, _, err = database.GetUserByToken(ctx, "badtoken")
|
||||
if err == nil {
|
||||
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 TestGetUserByNick(t *testing.T) {
|
||||
func TestGetSessionByNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, err := database.CreateUser(ctx, "charlie")
|
||||
charlieID, charlieClientID, charlieToken, err :=
|
||||
database.CreateSession(ctx, "charlie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := database.GetUserByNick(ctx, "charlie")
|
||||
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.GetUserByNick(ctx, "nobody")
|
||||
_, err = database.GetSessionByNick(ctx, "nobody")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown nick")
|
||||
}
|
||||
@@ -129,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "user1")
|
||||
sid, _, _, err := database.CreateSession(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,22 +160,22 @@ func TestJoinAndPart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
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] != uid {
|
||||
t.Fatal("expected user in channel")
|
||||
if err != nil || len(ids) != 1 || ids[0] != sid {
|
||||
t.Fatal("expected session in channel")
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.PartChannel(ctx, chID, uid)
|
||||
err = database.PartChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -178,17 +199,17 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "temp")
|
||||
sid, _, _, err := database.CreateSession(ctx, "temp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.PartChannel(ctx, chID, uid)
|
||||
err = database.PartChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -204,7 +225,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func createUserWithChannels(
|
||||
func createSessionWithChannels(
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
nick, ch1Name, ch2Name string,
|
||||
@@ -213,7 +234,7 @@ func createUserWithChannels(
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, nick)
|
||||
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,29 +253,29 @@ func createUserWithChannels(
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, ch1, uid)
|
||||
err = database.JoinChannel(ctx, ch1, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, ch2, uid)
|
||||
err = database.JoinChannel(ctx, ch2, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return uid, ch1, ch2
|
||||
return sid, ch1, ch2
|
||||
}
|
||||
|
||||
func TestListChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
uid, _, _ := createUserWithChannels(
|
||||
sid, _, _ := createSessionWithChannels(
|
||||
t, database, "lister", "#a", "#b",
|
||||
)
|
||||
|
||||
channels, err := database.ListChannels(
|
||||
t.Context(), uid,
|
||||
t.Context(), sid,
|
||||
)
|
||||
if err != nil || len(channels) != 2 {
|
||||
t.Fatalf(
|
||||
@@ -295,17 +316,21 @@ func TestChangeNick(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, token, err := database.CreateUser(ctx, "old")
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "old",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.ChangeNick(ctx, uid, "new")
|
||||
err = database.ChangeNick(ctx, sid, "new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, nick, err := database.GetUserByToken(ctx, token)
|
||||
_, _, nick, err := database.GetSessionByToken(
|
||||
ctx, token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -358,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, msgUUID, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -375,7 +400,16 @@ func TestPollMessages(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "poller")
|
||||
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)
|
||||
}
|
||||
@@ -383,13 +417,13 @@ func TestPollMessages(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.EnqueueMessage(ctx, uid, dbID)
|
||||
err = database.EnqueueToSession(ctx, sid, dbID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -397,7 +431,7 @@ func TestPollMessages(t *testing.T) {
|
||||
const batchSize = 10
|
||||
|
||||
msgs, lastQID, err := database.PollMessages(
|
||||
ctx, uid, 0, batchSize,
|
||||
ctx, clientID, 0, batchSize,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -420,7 +454,7 @@ func TestPollMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
msgs, _, _ = database.PollMessages(
|
||||
ctx, uid, lastQID, batchSize,
|
||||
ctx, clientID, lastQID, batchSize,
|
||||
)
|
||||
|
||||
if len(msgs) != 0 {
|
||||
@@ -441,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
||||
for range msgCount {
|
||||
_, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "user", "#hist",
|
||||
json.RawMessage(`["msg"]`), nil,
|
||||
nil, json.RawMessage(`["msg"]`), nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -467,13 +501,15 @@ func TestGetHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "deleteme")
|
||||
sid, _, _, err := database.CreateSession(
|
||||
ctx, "deleteme",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -485,19 +521,19 @@ func TestDeleteUser(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.DeleteUser(ctx, uid)
|
||||
err = database.DeleteSession(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = database.GetUserByNick(ctx, "deleteme")
|
||||
_, err = database.GetSessionByNick(ctx, "deleteme")
|
||||
if err == nil {
|
||||
t.Fatal("user should be deleted")
|
||||
t.Fatal("session should be deleted")
|
||||
}
|
||||
|
||||
ids, _ := database.GetChannelMemberIDs(ctx, chID)
|
||||
@@ -512,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid1, _, err := database.CreateUser(ctx, "m1")
|
||||
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid2, _, err := database.CreateUser(ctx, "m2")
|
||||
sid2, _, _, err := database.CreateSession(ctx, "m2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -529,12 +565,12 @@ func TestChannelMembers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid1)
|
||||
err = database.JoinChannel(ctx, chID, sid1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid2)
|
||||
err = database.JoinChannel(ctx, chID, sid2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -548,17 +584,17 @@ func TestChannelMembers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllChannelMembershipsForUser(t *testing.T) {
|
||||
func TestGetSessionChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
uid, _, _ := createUserWithChannels(
|
||||
sid, _, _ := createSessionWithChannels(
|
||||
t, database, "multi", "#m1", "#m2",
|
||||
)
|
||||
|
||||
channels, err :=
|
||||
database.GetAllChannelMembershipsForUser(
|
||||
t.Context(), uid,
|
||||
database.GetSessionChannels(
|
||||
t.Context(), sid,
|
||||
)
|
||||
if err != nil || len(channels) != 2 {
|
||||
t.Fatalf(
|
||||
@@ -567,3 +603,51 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,21 +1,38 @@
|
||||
-- Chat server schema (pre-1.0 consolidated)
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Users: IRC-style sessions (no passwords, just nick + token)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
-- 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_users_token ON users(token);
|
||||
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
|
||||
);
|
||||
@@ -24,9 +41,9 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(channel_id, user_id)
|
||||
UNIQUE(channel_id, session_id)
|
||||
);
|
||||
|
||||
-- Messages: IRC envelope format
|
||||
@@ -36,6 +53,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
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
|
||||
@@ -46,9 +64,9 @@ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||
-- Per-client message queues for fan-out delivery
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
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(user_id, message_id)
|
||||
UNIQUE(client_id, message_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package globals
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -15,16 +17,18 @@ var (
|
||||
|
||||
// Globals holds application-wide metadata.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Appname string
|
||||
Version string
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from the global state.
|
||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
result := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
return n, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,19 +12,21 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"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"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
@@ -84,10 +86,12 @@ func newTestServer(
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
cfg.HashcashBits = 0
|
||||
|
||||
return cfg, nil
|
||||
},
|
||||
newTestDB,
|
||||
stats.New,
|
||||
newTestHealthcheck,
|
||||
newTestMiddleware,
|
||||
newTestHandlers,
|
||||
@@ -115,8 +119,9 @@ func newTestServer(
|
||||
|
||||
func newTestGlobals() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
Appname: "chat-test",
|
||||
Version: "test",
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +146,14 @@ func newTestHealthcheck(
|
||||
cfg *config.Config,
|
||||
log *logger.Logger,
|
||||
database *db.Database,
|
||||
tracker *stats.Tracker,
|
||||
) (*healthcheck.Healthcheck, error) {
|
||||
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
|
||||
Globals: globs,
|
||||
Config: cfg,
|
||||
Logger: log,
|
||||
Database: database,
|
||||
Stats: tracker,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("test healthcheck: %w", err)
|
||||
@@ -180,6 +187,7 @@ func newTestHandlers(
|
||||
cfg *config.Config,
|
||||
database *db.Database,
|
||||
hcheck *healthcheck.Healthcheck,
|
||||
tracker *stats.Tracker,
|
||||
) (*handlers.Handlers, error) {
|
||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||
Logger: log,
|
||||
@@ -187,6 +195,7 @@ func newTestHandlers(
|
||||
Config: cfg,
|
||||
Database: database,
|
||||
Healthcheck: hcheck,
|
||||
Stats: tracker,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("test handlers: %w", err)
|
||||
@@ -462,6 +471,22 @@ func findMessage(
|
||||
return false
|
||||
}
|
||||
|
||||
func findNumeric(
|
||||
msgs []map[string]any,
|
||||
numeric string,
|
||||
) bool {
|
||||
want, _ := strconv.Atoi(numeric)
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestCreateSessionValid(t *testing.T) {
|
||||
@@ -473,6 +498,47 @@ func TestCreateSessionValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeNumeric(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("welcomer")
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, 0)
|
||||
|
||||
if !findNumeric(msgs, "001") {
|
||||
t.Fatalf(
|
||||
"expected RPL_WELCOME (001), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinNumerics(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("jnumtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#numtest",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "353") {
|
||||
t.Fatalf(
|
||||
"expected RPL_NAMREPLY (353), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
if !findNumeric(msgs, "366") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ENDOFNAMES (366), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSessionDuplicate(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
tserver.createSession("alice")
|
||||
@@ -668,11 +734,23 @@ func TestJoinMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("joiner3")
|
||||
|
||||
// Drain initial MOTD/welcome numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: joinCmd},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,10 +760,10 @@ func TestChannelMessage(t *testing.T) {
|
||||
bobToken := tserver.createSession("bob_msg")
|
||||
|
||||
tserver.sendCommand(aliceToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
})
|
||||
tserver.sendCommand(bobToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
})
|
||||
|
||||
_, _ = tserver.pollMessages(aliceToken, 0)
|
||||
@@ -695,13 +773,13 @@ func TestChannelMessage(t *testing.T) {
|
||||
aliceToken,
|
||||
map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#chat",
|
||||
toKey: "#test",
|
||||
bodyKey: []string{"hello world"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -725,14 +803,25 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
token := tserver.createSession("nobody")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#chat",
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd, toKey: "#chat",
|
||||
commandKey: privmsgCmd, toKey: "#test",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "412") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTEXTTOSEND (412), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,12 +829,23 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("noto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "411") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NORECIPIENT (411), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +859,8 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#private",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(aliceToken, 0)
|
||||
|
||||
// Alice tries to send without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
aliceToken,
|
||||
@@ -768,8 +870,17 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
bodyKey: []string{"sneaky"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "404") {
|
||||
t.Fatalf(
|
||||
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +897,9 @@ func TestDirectMessage(t *testing.T) {
|
||||
bodyKey: []string{"hey bob"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -818,13 +929,24 @@ func TestDMToNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("dmsender")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "nobody",
|
||||
bodyKey: []string{"hello?"},
|
||||
})
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "401") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOSUCHNICK (401), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,12 +993,23 @@ func TestNickCollision(t *testing.T) {
|
||||
|
||||
tserver.createSession("taken_nick")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"taken_nick"},
|
||||
})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "433") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NICKNAMEINUSE (433), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,12 +1017,23 @@ func TestNickInvalid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nickval")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"bad nick!"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "432") {
|
||||
t.Fatalf(
|
||||
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,11 +1041,22 @@ func TestNickEmptyBody(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nicknobody")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "NICK"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,12 +1093,23 @@ func TestTopicMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("topicnoto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
bodyKey: []string{"topic"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,11 +1121,58 @@ func TestTopicMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#topictest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC", toKey: "#topictest",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicNonMember(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
aliceToken := tserver.createSession("alice_topic")
|
||||
bobToken := tserver.createSession("bob_topic")
|
||||
|
||||
// Only alice joins the channel.
|
||||
tserver.sendCommand(aliceToken, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#topicpriv",
|
||||
})
|
||||
|
||||
// Drain bob's initial messages.
|
||||
_, lastID := tserver.pollMessages(bobToken, 0)
|
||||
|
||||
// Bob tries to set topic without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
bobToken,
|
||||
map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
toKey: "#topicpriv",
|
||||
bodyKey: []string{"Hijacked topic"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(bobToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,11 +1240,22 @@ func TestUnknownCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("cmdtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "BOGUS"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "421") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,12 +1502,18 @@ func TestLongPollTimeout(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lp_timeout")
|
||||
|
||||
// Drain initial welcome/MOTD numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url(apiMessages+"?timeout=1"),
|
||||
tserver.url(fmt.Sprintf(
|
||||
"%s?timeout=1&after=%d",
|
||||
apiMessages, lastID,
|
||||
)),
|
||||
token,
|
||||
nil,
|
||||
)
|
||||
@@ -1469,6 +1699,437 @@ func TestHealthcheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url("/.well-known/healthcheck.json"),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
if decErr != nil {
|
||||
t.Fatalf("decode healthcheck: %v", decErr)
|
||||
}
|
||||
|
||||
requiredFields := []string{
|
||||
"sessions", "clients", "queuedLines",
|
||||
"channels", "connectionsSinceBoot",
|
||||
"sessionsSinceBoot", "messagesSinceBoot",
|
||||
}
|
||||
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf(
|
||||
"missing field %q in healthcheck", field,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
token := tserver.createSession("statsuser")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#statschan",
|
||||
})
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "#statschan",
|
||||
bodyKey: []string{"hello stats"},
|
||||
})
|
||||
|
||||
result := tserver.fetchHealthcheck(t)
|
||||
|
||||
assertFieldGTE(t, result, "sessions", 1)
|
||||
assertFieldGTE(t, result, "clients", 1)
|
||||
assertFieldGTE(t, result, "channels", 1)
|
||||
assertFieldGTE(t, result, "queuedLines", 0)
|
||||
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
|
||||
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
|
||||
assertFieldGTE(t, result, "messagesSinceBoot", 1)
|
||||
}
|
||||
|
||||
func (tserver *testServer) fetchHealthcheck(
|
||||
t *testing.T,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url("/.well-known/healthcheck.json"),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 200, got %d", resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
||||
if decErr != nil {
|
||||
t.Fatalf("decode healthcheck: %v", decErr)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func assertFieldGTE(
|
||||
t *testing.T,
|
||||
result map[string]any,
|
||||
field string,
|
||||
minimum float64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
val, ok := result[field].(float64)
|
||||
if !ok {
|
||||
t.Errorf(
|
||||
"field %q: not a number (got %T)",
|
||||
field, result[field],
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if val < minimum {
|
||||
t.Errorf(
|
||||
"expected %s >= %v, got %v",
|
||||
field, minimum, val,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"nick": "reguser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %s",
|
||||
resp.StatusCode, respBody,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["token"] == nil || result["token"] == "" {
|
||||
t.Fatal("expected token in response")
|
||||
}
|
||||
|
||||
if result["nick"] != "reguser" {
|
||||
t.Fatalf(
|
||||
"expected reguser, got %v", result["nick"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicate(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"nick": "dupuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSONExpectStatus(
|
||||
t *testing.T,
|
||||
tserver *testServer,
|
||||
path string,
|
||||
payload map[string]string,
|
||||
expectedStatus int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url(path),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != expectedStatus {
|
||||
t.Fatalf(
|
||||
"expected %d, got %d",
|
||||
expectedStatus, resp.StatusCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortPassword(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/register",
|
||||
map[string]string{
|
||||
"nick": "shortpw", "password": "short",
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
func TestRegisterInvalidNick(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/register",
|
||||
map[string]string{
|
||||
"nick": "bad nick!",
|
||||
"password": "password123",
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
func TestLoginValid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Register first.
|
||||
regBody, err := json.Marshal(map[string]string{
|
||||
"nick": "loginuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(regBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Login.
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "loginuser", "password": "password123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf(
|
||||
"expected 200, got %d: %s",
|
||||
resp2.StatusCode, respBody,
|
||||
)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
|
||||
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
|
||||
if result["token"] == nil || result["token"] == "" {
|
||||
t.Fatal("expected token in response")
|
||||
}
|
||||
|
||||
// Verify token works.
|
||||
token, ok := result["token"].(string)
|
||||
if !ok {
|
||||
t.Fatal("token not a string")
|
||||
}
|
||||
|
||||
status, state := tserver.getState(token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if state["nick"] != "loginuser" {
|
||||
t.Fatalf(
|
||||
"expected loginuser, got %v",
|
||||
state["nick"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
regBody, err := json.Marshal(map[string]string{
|
||||
"nick": "wrongpwuser", "password": "correctpass1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/register"),
|
||||
bytes.NewReader(regBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"nick": "wrongpwuser", "password": "wrongpass12",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp2, err := doRequest(
|
||||
t,
|
||||
http.MethodPost,
|
||||
tserver.url("/api/v1/login"),
|
||||
bytes.NewReader(loginBody),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf(
|
||||
"expected 401, got %d", resp2.StatusCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
postJSONExpectStatus(
|
||||
t, tserver, "/api/v1/login",
|
||||
map[string]string{
|
||||
"nick": "ghostuser",
|
||||
"password": "password123",
|
||||
},
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
}
|
||||
|
||||
func TestSessionStillWorks(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
// Verify anonymous session creation still works.
|
||||
token := tserver.createSession("anon_user")
|
||||
if token == "" {
|
||||
t.Fatal("expected token for anonymous session")
|
||||
}
|
||||
|
||||
status, state := tserver.getState(token)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if state["nick"] != "anon_user" {
|
||||
t.Fatalf(
|
||||
"expected anon_user, got %v",
|
||||
state["nick"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNickBroadcastToChannels(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
aliceToken := tserver.createSession("nick_a")
|
||||
|
||||
203
internal/handlers/auth.go
Normal file
203
internal/handlers/auth.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
)
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
// 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,
|
||||
) {
|
||||
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,4 +1,4 @@
|
||||
// Package handlers provides HTTP request handlers for the chat server.
|
||||
// Package handlers provides HTTP request handlers for the neoirc server.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -7,13 +7,16 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/broker"
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/db"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"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/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -28,14 +31,20 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||
|
||||
// Handlers manages HTTP request handling.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
@@ -43,18 +52,29 @@ func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
hdlr := &Handlers{
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
resource := params.Config.ServerName
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(ctx context.Context) error {
|
||||
hdlr.startCleanup(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
hdlr.stopCleanup()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
@@ -96,3 +116,173 @@ func (hdlr *Handlers) respondError(
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
resp := hdlr.hc.Healthcheck()
|
||||
resp := hdlr.hc.Healthcheck(request.Context())
|
||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
261
internal/hashcash/hashcash_test.go
Normal file
261
internal/hashcash/hashcash_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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,10 +6,11 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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/logger"
|
||||
"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/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,7 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
// Healthcheck tracks server uptime and provides health status.
|
||||
@@ -64,11 +66,22 @@ type Response struct {
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
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.
|
||||
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
return &Response{
|
||||
func (hcheck *Healthcheck) Healthcheck(
|
||||
ctx context.Context,
|
||||
) *Response {
|
||||
resp := &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||
@@ -76,6 +89,64 @@ func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
Appname: hcheck.params.Globals.Appname,
|
||||
Version: hcheck.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
|
||||
}
|
||||
|
||||
// populateDBStats fills in database-derived counters.
|
||||
func (hcheck *Healthcheck) populateDBStats(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package middleware provides HTTP middleware for the chat server.
|
||||
// Package middleware provides HTTP middleware for the neoirc server.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Auth returns middleware that performs authentication.
|
||||
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
mware.log.Info("AUTH: before request")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns middleware that records HTTP metrics.
|
||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||
@@ -180,3 +166,36 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/web"
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
|
||||
}
|
||||
|
||||
srv.router.Use(srv.mw.CORS())
|
||||
srv.router.Use(srv.mw.CSP())
|
||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||
|
||||
if srv.sentryEnabled {
|
||||
@@ -59,48 +60,63 @@ func (srv *Server) SetupRoutes() {
|
||||
}
|
||||
|
||||
// API v1.
|
||||
srv.router.Route(
|
||||
"/api/v1",
|
||||
func(router chi.Router) {
|
||||
router.Get(
|
||||
"/server",
|
||||
srv.handlers.HandleServerInfo(),
|
||||
)
|
||||
router.Post(
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Get(
|
||||
"/state",
|
||||
srv.handlers.HandleState(),
|
||||
)
|
||||
router.Get(
|
||||
"/messages",
|
||||
srv.handlers.HandleGetMessages(),
|
||||
)
|
||||
router.Post(
|
||||
"/messages",
|
||||
srv.handlers.HandleSendCommand(),
|
||||
)
|
||||
router.Get(
|
||||
"/history",
|
||||
srv.handlers.HandleGetHistory(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels",
|
||||
srv.handlers.HandleListAllChannels(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels/{channel}/members",
|
||||
srv.handlers.HandleChannelMembers(),
|
||||
)
|
||||
},
|
||||
)
|
||||
srv.router.Route("/api/v1", srv.setupAPIv1)
|
||||
|
||||
// Serve embedded SPA.
|
||||
srv.setupSPA()
|
||||
}
|
||||
|
||||
func (srv *Server) setupAPIv1(router chi.Router) {
|
||||
router.Get(
|
||||
"/server",
|
||||
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() {
|
||||
distFS, err := fs.Sub(web.Dist, "dist")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package server implements the main HTTP server for the chat application.
|
||||
// Package server implements the main HTTP server for the neoirc application.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -12,15 +12,15 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/chat/internal/config"
|
||||
"git.eeqj.de/sneak/chat/internal/globals"
|
||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||
"git.eeqj.de/sneak/chat/internal/logger"
|
||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
)
|
||||
|
||||
52
internal/stats/stats.go
Normal file
52
internal/stats/stats.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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()
|
||||
}
|
||||
117
internal/stats/stats_test.go
Normal file
117
internal/stats/stats_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
22
pkg/irc/commands.go
Normal file
22
pkg/irc/commands.go
Normal file
@@ -0,0 +1,22 @@
|
||||
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"
|
||||
)
|
||||
391
pkg/irc/numerics.go
Normal file
391
pkg/irc/numerics.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// 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]
|
||||
}
|
||||
163
pkg/irc/numerics_test.go
Normal file
163
pkg/irc/numerics_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
|
||||
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
||||
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||
over HTTP.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
||||
"title": "JOIN",
|
||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
||||
"title": "KICK",
|
||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
||||
"title": "MODE",
|
||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
||||
"title": "NICK",
|
||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
||||
"title": "NOTICE",
|
||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
||||
"title": "PART",
|
||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
||||
"title": "PING",
|
||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
||||
"title": "PONG",
|
||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
||||
"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.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
||||
"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.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
||||
"title": "QUIT",
|
||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
||||
"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.",
|
||||
"$ref": "../message.json",
|
||||
@@ -17,6 +17,6 @@
|
||||
},
|
||||
"required": ["command", "to"],
|
||||
"examples": [
|
||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
|
||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
||||
"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.).",
|
||||
"type": "object",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
||||
"title": "001 RPL_WELCOME",
|
||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
||||
"title": "002 RPL_YOURHOST",
|
||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
@@ -29,7 +29,7 @@
|
||||
"command": "002",
|
||||
"to": "alice",
|
||||
"body": [
|
||||
"Your host is chat.example.com, running version 0.1.0"
|
||||
"Your host is neoirc.example.com, running version 0.1.0"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
||||
"title": "003 RPL_CREATED",
|
||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
||||
"title": "004 RPL_MYINFO",
|
||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
@@ -29,7 +29,7 @@
|
||||
"command": "004",
|
||||
"to": "alice",
|
||||
"params": [
|
||||
"chat.example.com",
|
||||
"neoirc.example.com",
|
||||
"0.1.0",
|
||||
"o",
|
||||
"imnst+ov"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
||||
"title": "322 RPL_LIST",
|
||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
||||
"title": "323 RPL_LISTEND",
|
||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
||||
"title": "332 RPL_TOPIC",
|
||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
@@ -40,7 +40,7 @@
|
||||
"#general"
|
||||
],
|
||||
"body": [
|
||||
"Welcome to the chat"
|
||||
"Welcome to the channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
||||
"title": "353 RPL_NAMREPLY",
|
||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
||||
"title": "366 RPL_ENDOFNAMES",
|
||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
||||
"title": "372 RPL_MOTD",
|
||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
||||
"title": "375 RPL_MOTDSTART",
|
||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
||||
"title": "376 RPL_ENDOFMOTD",
|
||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
||||
"title": "401 ERR_NOSUCHNICK",
|
||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
||||
"title": "403 ERR_NOSUCHCHANNEL",
|
||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
||||
"title": "433 ERR_NICKNAMEINUSE",
|
||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
||||
"title": "442 ERR_NOTONCHANNEL",
|
||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||
"$ref": "../message.json",
|
||||
|
||||
12
web/build.sh
12
web/build.sh
@@ -16,19 +16,11 @@ fi
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
# 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 || \
|
||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||
${NPX:+$NPX} esbuild src/app.jsx \
|
||||
--bundle \
|
||||
--minify \
|
||||
--format=esm \
|
||||
--jsx-factory=h \
|
||||
--jsx-fragment=Fragment \
|
||||
--define:process.env.NODE_ENV=\"production\" \
|
||||
|
||||
464
web/dist/app.js
vendored
464
web/dist/app.js
vendored
@@ -1,464 +0,0 @@
|
||||
(()=>{
|
||||
// Minimal Preact-like runtime using raw DOM for simplicity and zero build step.
|
||||
// This replaces the previous Preact SPA with a vanilla JS implementation.
|
||||
|
||||
const API = '/api/v1';
|
||||
let token = localStorage.getItem('chat_token');
|
||||
let myNick = '';
|
||||
let myUID = 0;
|
||||
let lastQueueID = 0;
|
||||
let pollController = null;
|
||||
let channels = []; // [{name, topic}]
|
||||
let activeTab = null; // '#channel' or 'nick' or 'server'
|
||||
let messages = {}; // target -> [{command,from,to,body,ts,system}]
|
||||
let unread = {}; // target -> count
|
||||
let members = {}; // '#channel' -> [{nick}]
|
||||
|
||||
function $(sel, parent) { return (parent||document).querySelector(sel); }
|
||||
function $$(sel, parent) { return [...(parent||document).querySelectorAll(sel)]; }
|
||||
function el(tag, attrs, ...children) {
|
||||
const e = document.createElement(tag);
|
||||
if (attrs) Object.entries(attrs).forEach(([k,v]) => {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
|
||||
else e.setAttribute(k, v);
|
||||
});
|
||||
children.flat(Infinity).forEach(c => {
|
||||
if (c == null) return;
|
||||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const headers = {'Content-Type': 'application/json', ...(opts.headers||{})};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const resp = await fetch(API + path, {...opts, headers, signal: opts.signal});
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!resp.ok) throw {status: resp.status, data};
|
||||
return data;
|
||||
}
|
||||
|
||||
function nickColor(nick) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h);
|
||||
return `hsl(${Math.abs(h) % 360}, 70%, 65%)`;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
}
|
||||
|
||||
function addMessage(target, msg) {
|
||||
if (!messages[target]) messages[target] = [];
|
||||
messages[target].push(msg);
|
||||
if (messages[target].length > 500) messages[target] = messages[target].slice(-400);
|
||||
if (target !== activeTab) {
|
||||
unread[target] = (unread[target] || 0) + 1;
|
||||
renderTabs();
|
||||
}
|
||||
if (target === activeTab) renderMessages();
|
||||
}
|
||||
|
||||
function addSystemMessage(target, text) {
|
||||
addMessage(target, {command: 'SYSTEM', from: '*', body: [text], ts: new Date().toISOString(), system: true});
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
function renderApp() {
|
||||
const root = $('#root');
|
||||
root.innerHTML = '';
|
||||
root.appendChild(el('div', {class:'app'},
|
||||
el('div', {class:'tab-bar', id:'tabs'}),
|
||||
el('div', {class:'content'},
|
||||
el('div', {class:'messages-pane'},
|
||||
el('div', {class:'messages', id:'msg-list'}),
|
||||
el('div', {class:'input-bar', id:'input-bar'},
|
||||
el('input', {id:'msg-input', placeholder:'Message...', onKeydown: e => { if(e.key==='Enter') sendInput(); }}),
|
||||
el('button', {onClick: sendInput}, 'Send')
|
||||
)
|
||||
),
|
||||
el('div', {class:'user-list', id:'user-list'})
|
||||
)
|
||||
));
|
||||
renderTabs();
|
||||
renderMessages();
|
||||
renderMembers();
|
||||
$('#msg-input')?.focus();
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const container = $('#tabs');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Server tab
|
||||
const serverTab = el('div', {class: `tab ${activeTab === 'server' ? 'active' : ''}`, onClick: () => switchTab('server')}, 'Server');
|
||||
container.appendChild(serverTab);
|
||||
|
||||
// Channel tabs
|
||||
channels.forEach(ch => {
|
||||
const badge = unread[ch.name] ? ` (${unread[ch.name]})` : '';
|
||||
const tab = el('div', {class: `tab ${activeTab === ch.name ? 'active' : ''}`},
|
||||
el('span', {onClick: () => switchTab(ch.name)}, ch.name + badge),
|
||||
el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); partChannel(ch.name); }}, '×')
|
||||
);
|
||||
container.appendChild(tab);
|
||||
});
|
||||
|
||||
// DM tabs
|
||||
Object.keys(messages).filter(k => !k.startsWith('#') && k !== 'server').forEach(nick => {
|
||||
const badge = unread[nick] ? ` (${unread[nick]})` : '';
|
||||
const tab = el('div', {class: `tab ${activeTab === nick ? 'active' : ''}`},
|
||||
el('span', {onClick: () => switchTab(nick)}, '→' + nick + badge),
|
||||
el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); delete messages[nick]; delete unread[nick]; if(activeTab===nick) switchTab('server'); else renderTabs(); }}, '×')
|
||||
);
|
||||
container.appendChild(tab);
|
||||
});
|
||||
|
||||
// Join input
|
||||
const joinDiv = el('div', {class:'join-dialog'},
|
||||
el('input', {id:'join-input', placeholder:'#channel', onKeydown: e => { if(e.key==='Enter') joinFromInput(); }}),
|
||||
el('button', {onClick: joinFromInput}, 'Join')
|
||||
);
|
||||
container.appendChild(joinDiv);
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
const container = $('#msg-list');
|
||||
if (!container) return;
|
||||
const msgs = messages[activeTab] || [];
|
||||
container.innerHTML = '';
|
||||
msgs.forEach(m => {
|
||||
const isSystem = m.system || ['JOIN','PART','QUIT','NICK','TOPIC'].includes(m.command);
|
||||
const bodyText = Array.isArray(m.body) ? m.body.join('\n') : (m.body || '');
|
||||
|
||||
let displayText = bodyText;
|
||||
if (m.command === 'JOIN') displayText = `${m.from} has joined ${m.to}`;
|
||||
else if (m.command === 'PART') displayText = `${m.from} has left ${m.to}` + (bodyText ? ` (${bodyText})` : '');
|
||||
else if (m.command === 'QUIT') displayText = `${m.from} has quit` + (bodyText ? ` (${bodyText})` : '');
|
||||
else if (m.command === 'NICK') displayText = `${m.from} is now known as ${bodyText}`;
|
||||
else if (m.command === 'TOPIC') displayText = `${m.from} set topic: ${bodyText}`;
|
||||
|
||||
const msgEl = el('div', {class: `message ${isSystem ? 'system' : ''}`},
|
||||
el('span', {class:'timestamp'}, m.ts ? formatTime(m.ts) : ''),
|
||||
isSystem
|
||||
? el('span', {class:'nick'}, '*')
|
||||
: el('span', {class:'nick', style:{color: nickColor(m.from)}}, m.from),
|
||||
el('span', {class:'content'}, displayText)
|
||||
);
|
||||
container.appendChild(msgEl);
|
||||
});
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
const container = $('#user-list');
|
||||
if (!container) return;
|
||||
if (!activeTab || !activeTab.startsWith('#')) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const mems = members[activeTab] || [];
|
||||
container.innerHTML = '';
|
||||
container.appendChild(el('h3', null, `Users (${mems.length})`));
|
||||
mems.forEach(m => {
|
||||
container.appendChild(el('div', {class:'user', style:{color: nickColor(m.nick)}, onClick: () => openDM(m.nick)}, m.nick));
|
||||
});
|
||||
}
|
||||
|
||||
function switchTab(target) {
|
||||
activeTab = target;
|
||||
unread[target] = 0;
|
||||
renderTabs();
|
||||
renderMessages();
|
||||
renderMembers();
|
||||
if (activeTab?.startsWith('#')) fetchMembers(activeTab);
|
||||
$('#msg-input')?.focus();
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function joinFromInput() {
|
||||
const input = $('#join-input');
|
||||
if (!input) return;
|
||||
let name = input.value.trim();
|
||||
if (!name) return;
|
||||
if (!name.startsWith('#')) name = '#' + name;
|
||||
input.value = '';
|
||||
try {
|
||||
await api('/messages', {method:'POST', body: JSON.stringify({command:'JOIN', to: name})});
|
||||
} catch(e) {
|
||||
addSystemMessage('server', `Failed to join ${name}: ${e.data?.error || 'error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function partChannel(name) {
|
||||
try {
|
||||
await api('/messages', {method:'POST', body: JSON.stringify({command:'PART', to: name})});
|
||||
} catch(e) {}
|
||||
channels = channels.filter(c => c.name !== name);
|
||||
delete members[name];
|
||||
if (activeTab === name) switchTab('server');
|
||||
else renderTabs();
|
||||
}
|
||||
|
||||
function openDM(nick) {
|
||||
if (nick === myNick) return;
|
||||
if (!messages[nick]) messages[nick] = [];
|
||||
switchTab(nick);
|
||||
}
|
||||
|
||||
async function sendInput() {
|
||||
const input = $('#msg-input');
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
const parts = text.split(' ');
|
||||
const cmd = parts[0].toLowerCase();
|
||||
if (cmd === '/join' && parts[1]) { $('#join-input').value = parts[1]; joinFromInput(); return; }
|
||||
if (cmd === '/part') { if(activeTab?.startsWith('#')) partChannel(activeTab); return; }
|
||||
if (cmd === '/nick' && parts[1]) {
|
||||
try {
|
||||
await api('/messages', {method:'POST', body: JSON.stringify({command:'NICK', body:[parts[1]]})});
|
||||
} catch(e) {
|
||||
addSystemMessage(activeTab||'server', `Nick change failed: ${e.data?.error || 'error'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) {
|
||||
const target = parts[1];
|
||||
const msg = parts.slice(2).join(' ');
|
||||
try {
|
||||
await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: target, body:[msg]})});
|
||||
openDM(target);
|
||||
} catch(e) {
|
||||
addSystemMessage(activeTab||'server', `DM failed: ${e.data?.error || 'error'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === '/quit') {
|
||||
try { await api('/messages', {method:'POST', body: JSON.stringify({command:'QUIT'})}); } catch(e) {}
|
||||
localStorage.removeItem('chat_token');
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
addSystemMessage(activeTab||'server', `Unknown command: ${cmd}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeTab || activeTab === 'server') {
|
||||
addSystemMessage('server', 'Select a channel or user to send messages');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: activeTab, body:[text]})});
|
||||
} catch(e) {
|
||||
addSystemMessage(activeTab, `Send failed: ${e.data?.error || 'error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMembers(channel) {
|
||||
try {
|
||||
const name = channel.replace('#','');
|
||||
const data = await api(`/channels/${name}/members`);
|
||||
members[channel] = data;
|
||||
renderMembers();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// --- Polling ---
|
||||
|
||||
async function pollLoop() {
|
||||
while (true) {
|
||||
try {
|
||||
if (pollController) pollController.abort();
|
||||
pollController = new AbortController();
|
||||
const data = await api(`/messages?after=${lastQueueID}&timeout=15`, {signal: pollController.signal});
|
||||
if (data.last_id) lastQueueID = data.last_id;
|
||||
|
||||
for (const msg of (data.messages || [])) {
|
||||
handleMessage(msg);
|
||||
}
|
||||
} catch(e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') continue;
|
||||
if (e.status === 401) {
|
||||
localStorage.removeItem('chat_token');
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
const body = Array.isArray(msg.body) ? msg.body : [];
|
||||
const bodyText = body.join('\n');
|
||||
|
||||
switch (msg.command) {
|
||||
case 'PRIVMSG':
|
||||
case 'NOTICE': {
|
||||
let target = msg.to;
|
||||
// DM: if it's to me, show under sender's nick tab
|
||||
if (!target.startsWith('#')) {
|
||||
target = msg.from === myNick ? msg.to : msg.from;
|
||||
if (!messages[target]) messages[target] = [];
|
||||
}
|
||||
addMessage(target, msg);
|
||||
break;
|
||||
}
|
||||
case 'JOIN': {
|
||||
addMessage(msg.to, msg);
|
||||
if (msg.from === myNick) {
|
||||
// We joined a channel
|
||||
if (!channels.find(c => c.name === msg.to)) {
|
||||
channels.push({name: msg.to, topic: ''});
|
||||
}
|
||||
switchTab(msg.to);
|
||||
fetchMembers(msg.to);
|
||||
} else if (activeTab === msg.to) {
|
||||
fetchMembers(msg.to);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'PART': {
|
||||
addMessage(msg.to, msg);
|
||||
if (msg.from === myNick) {
|
||||
channels = channels.filter(c => c.name !== msg.to);
|
||||
if (activeTab === msg.to) switchTab('server');
|
||||
else renderTabs();
|
||||
} else if (activeTab === msg.to) {
|
||||
fetchMembers(msg.to);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'QUIT': {
|
||||
// Show in all channels where this user might be
|
||||
channels.forEach(ch => {
|
||||
addMessage(ch.name, msg);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'NICK': {
|
||||
const newNick = body[0] || '';
|
||||
if (msg.from === myNick) {
|
||||
myNick = newNick;
|
||||
addSystemMessage(activeTab || 'server', `You are now known as ${newNick}`);
|
||||
} else {
|
||||
channels.forEach(ch => {
|
||||
addMessage(ch.name, msg);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'TOPIC': {
|
||||
addMessage(msg.to, msg);
|
||||
const ch = channels.find(c => c.name === msg.to);
|
||||
if (ch) ch.topic = bodyText;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
addSystemMessage('server', `[${msg.command}] ${bodyText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
|
||||
function renderLogin() {
|
||||
const root = $('#root');
|
||||
root.innerHTML = '';
|
||||
|
||||
let serverName = 'Chat';
|
||||
let motd = '';
|
||||
|
||||
api('/server').then(data => {
|
||||
if (data.name) { serverName = data.name; $('h1', root).textContent = serverName; }
|
||||
if (data.motd) { motd = data.motd; const m = $('.motd', root); if(m) m.textContent = motd; }
|
||||
}).catch(() => {});
|
||||
|
||||
const form = el('form', {class:'login-screen', onSubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
const nick = $('input', form).value.trim();
|
||||
if (!nick) return;
|
||||
const errEl = $('.error', form);
|
||||
if (errEl) errEl.textContent = '';
|
||||
try {
|
||||
const data = await api('/session', {method:'POST', body: JSON.stringify({nick})});
|
||||
token = data.token;
|
||||
myNick = data.nick;
|
||||
myUID = data.id;
|
||||
localStorage.setItem('chat_token', token);
|
||||
startApp();
|
||||
} catch(err) {
|
||||
const errEl = $('.error', form) || form.appendChild(el('div', {class:'error'}));
|
||||
errEl.textContent = err.data?.error || 'Connection failed';
|
||||
}
|
||||
}},
|
||||
el('h1', null, serverName),
|
||||
motd ? el('div', {class:'motd'}, motd) : null,
|
||||
el('input', {type:'text', placeholder:'Choose a nickname...', maxLength:'32', autofocus:'true'}),
|
||||
el('button', {type:'submit'}, 'Connect'),
|
||||
el('div', {class:'error'})
|
||||
);
|
||||
root.appendChild(form);
|
||||
$('input', form)?.focus();
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
messages = {server: []};
|
||||
unread = {};
|
||||
channels = [];
|
||||
activeTab = 'server';
|
||||
lastQueueID = 0;
|
||||
|
||||
addSystemMessage('server', `Connected as ${myNick}`);
|
||||
|
||||
// Fetch server info
|
||||
try {
|
||||
const info = await api('/server');
|
||||
if (info.motd) addSystemMessage('server', `MOTD: ${info.motd}`);
|
||||
} catch(e) {}
|
||||
|
||||
// Fetch current state (channels we're already in)
|
||||
try {
|
||||
const state = await api('/state');
|
||||
myNick = state.nick;
|
||||
myUID = state.id;
|
||||
if (state.channels) {
|
||||
state.channels.forEach(ch => {
|
||||
channels.push({name: ch.name, topic: ch.topic});
|
||||
if (!messages[ch.name]) messages[ch.name] = [];
|
||||
});
|
||||
if (channels.length > 0) switchTab(channels[0].name);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
renderApp();
|
||||
pollLoop();
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
if (token) {
|
||||
// Try to resume session
|
||||
api('/state').then(data => {
|
||||
myNick = data.nick;
|
||||
myUID = data.id;
|
||||
startApp();
|
||||
}).catch(() => {
|
||||
localStorage.removeItem('chat_token');
|
||||
token = null;
|
||||
renderLogin();
|
||||
});
|
||||
} else {
|
||||
renderLogin();
|
||||
}
|
||||
|
||||
})();
|
||||
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
||||
<!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
274
web/dist/style.css
vendored
@@ -1,274 +0,0 @@
|
||||
* { 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; }
|
||||
}
|
||||
1432
web/src/app.jsx
1432
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat</title>
|
||||
<title>NeoIRC</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/app.js"></script>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,274 +1,466 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* {
|
||||
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;
|
||||
--bg: #0a0e14;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-input: #0d1117;
|
||||
--bg-tab: #161b22;
|
||||
--bg-tab-active: #0d1117;
|
||||
--bg-topic: #0d1117;
|
||||
--text: #c9d1d9;
|
||||
--text-dim: #6e7681;
|
||||
--text-bright: #e6edf3;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb;
|
||||
--border: #21262d;
|
||||
--system: #7d8590;
|
||||
--action: #d2a8ff;
|
||||
--warn: #d29922;
|
||||
--error: #f85149;
|
||||
--unread: #f0883e;
|
||||
--nick-brackets: #6e7681;
|
||||
--timestamp: #484f58;
|
||||
--input-bg: #161b22;
|
||||
--prompt: #3fb950;
|
||||
--tab-indicator: #58a6ff;
|
||||
--user-list-bg: #0d1117;
|
||||
--user-list-header: #484f58;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Login screen */
|
||||
/* ============================================
|
||||
Login Screen
|
||||
============================================ */
|
||||
|
||||
.login-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.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;
|
||||
.login-box {
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
.app {
|
||||
.login-box h1 {
|
||||
color: var(--accent);
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-box .motd {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.login-box form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
.login-box label {
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab .close-btn:hover {
|
||||
color: var(--accent);
|
||||
.login-box input {
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
.login-box input:focus {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box button {
|
||||
padding: 8px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--accent-dim);
|
||||
border: none;
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-box button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.login-box .error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRC App Layout
|
||||
============================================ */
|
||||
|
||||
.irc-app {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-pane {
|
||||
/* ============================================
|
||||
Tab Bar
|
||||
============================================ */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-tab);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-tab-active);
|
||||
border-bottom: 2px solid var(--tab-indicator);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab.has-unread .tab-label {
|
||||
color: var(--unread);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab .unread-count {
|
||||
color: var(--unread);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-nick {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--warn);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Topic Bar
|
||||
============================================ */
|
||||
|
||||
.topic-bar {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-topic);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.topic-label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.topic-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Main Content Area
|
||||
============================================ */
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Messages Panel
|
||||
============================================ */
|
||||
|
||||
.messages-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
.messages-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Message Lines
|
||||
============================================ */
|
||||
|
||||
.message {
|
||||
padding: 2px 0;
|
||||
padding: 1px 0;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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 .content {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message.system {
|
||||
color: var(--text-muted);
|
||||
/* System messages (joins, parts, quits, etc.) */
|
||||
.system-message {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
.system-message .system-text {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
/* /me action messages */
|
||||
.action-message .action-text {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User List (Right Panel)
|
||||
============================================ */
|
||||
|
||||
.user-list {
|
||||
width: 160px;
|
||||
background: var(--user-list-bg);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-list-header {
|
||||
padding: 6px 10px;
|
||||
color: var(--user-list-header);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-entries {
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.nick-entry {
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nick-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nick-prefix {
|
||||
color: var(--text-dim);
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
text-align: right;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.nick-name {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Input Line (Bottom)
|
||||
============================================ */
|
||||
|
||||
.input-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--input-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
color: var(--prompt);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-line input {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-bright);
|
||||
outline: none;
|
||||
caret-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-line input::placeholder {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message.system .nick {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
|
||||
.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; }
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user