16 Commits

Author SHA1 Message Date
clawbot
e0a93dea5f fix: suppress gosec false positives for trusted URL construction
Add nolint:gosec annotations for:
- Client.Do calls using URLs built from trusted BaseURL + hardcoded paths
- Test helper HTTP calls using test server URLs
- Safe integer-to-rune conversion in bounded loop (0-19)
2026-02-20 02:06:31 -08:00
clawbot
1aac9cf480 build: Dockerfile non-root user, healthcheck, .dockerignore 2026-02-11 00:50:13 -08:00
clawbot
eefb81ed8d fix: resolve all golangci-lint issues
- Refactor test helpers (sendCommand, getJSON) to return (int, map[string]any)
  instead of (*http.Response, map[string]any) to fix bodyclose warnings
- Add doReq/doReqAuth helpers using NewRequestWithContext to fix noctx
- Check all error returns (errcheck, errchkjson)
- Use integer range syntax (intrange) for Go 1.22+
- Use http.Method* constants (usestdlibvars)
- Replace fmt.Sprintf with string concatenation where possible (perfsprint)
- Reorder UI methods: exported before unexported (funcorder)
- Add lint target to Makefile
- Disable overly pedantic linters in .golangci.yml (paralleltest, dupl,
  noinlineerr, wsl_v5, nlreturn, lll, tagliatelle, goconst, funlen)
2026-02-10 18:52:17 -08:00
clawbot
d0656b069d fix: golangci-lint v2 config and lint-clean production code
- Fix .golangci.yml for v2 format (linters-settings -> linters.settings)
- All production code now passes golangci-lint with zero issues
- Line length 88, funlen 80/50, cyclop 15, dupl 100
- Extract shared helpers in db (scanChannels, scanInt64s, scanMessages)
- Split runMigrations into applyMigration/execMigration
- Fix fanOut return signature (remove unused int64)
- Add fanOutSilent helper to avoid dogsled
- Rewrite CLI code for lint compliance (nlreturn, wsl_v5, noctx, etc)
- Rename CLI api package to chatapi to avoid revive var-naming
- Fix all noinlineerr, mnd, perfsprint, funcorder issues
- Fix db tests: extract helpers, add t.Parallel, proper error checks
- Broker tests already clean
- Handler integration tests still have lint issues (next commit)
2026-02-10 18:50:24 -08:00
clawbot
deda5d81ae fix: CLI client types mismatched server response format
- SessionResponse: use 'id' (int64) not 'session_id'/'client_id'
- StateResponse: match actual server response shape
- GetMembers: strip '#' from channel name for URL path
- These bugs prevented the CLI from working correctly with the server
2026-02-10 18:23:19 -08:00
clawbot
b0358471ae chore: deduplicate broker tests, clean up test imports 2026-02-10 18:22:38 -08:00
clawbot
5c95d7cdf6 fix: CLI poll loop used UUID instead of queue cursor (last_id)
The poll loop was storing msg.ID (UUID string) as afterID, but the server
expects the integer queue cursor from last_id. This caused the CLI to
re-fetch ALL messages on every poll cycle.

- Change PollMessages to accept int64 afterID and return PollResult with LastID
- Track lastQID (queue cursor) instead of lastMsgID (UUID)
- Parse the wrapped MessagesResponse properly
2026-02-10 18:22:08 -08:00
clawbot
ae1c67050e build: update Dockerfile with tests and multi-stage build, add Makefile
- Dockerfile runs go test before building
- Multi-stage: build+test stage, minimal alpine final image
- Add gcc/musl-dev for CGO (sqlite)
- Trim binaries with -s -w ldflags
- Makefile with build, test, clean, docker targets
- Version injection via ldflags
2026-02-10 18:21:07 -08:00
clawbot
736d179513 test: add comprehensive test suite
- Integration tests for all API endpoints (session, state, channels, messages)
- Tests for all commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING
- Edge cases: duplicate nick, empty/invalid inputs, malformed JSON, bad auth
- Long-poll tests: delivery on notify and timeout behavior
- DM tests: delivery to recipient, echo to sender, nonexistent user
- Ephemeral channel cleanup test
- Concurrent session creation test
- Nick broadcast to channel members test
- DB unit tests: all CRUD operations, message queue, history
- Broker unit tests: wait/notify, remove, concurrent access
2026-02-10 18:20:45 -08:00
clawbot
9c8e40d014 Comprehensive README: full protocol spec, API reference, architecture, security model
Expanded from ~700 lines to ~2200 lines covering:
- Complete protocol specification (every command, field, behavior)
- Full API reference with request/response examples for all endpoints
- Architecture deep-dive (session model, queue system, broker, message flow)
- Sequence diagrams for channel messages, DMs, and JOIN flows
- All design decisions with rationale (no accounts, JSON, opaque tokens, etc.)
- Canonicalization and signing spec (JCS, Ed25519, TOFU)
- Security model (threat model, authentication, key management)
- Federation design (link establishment, relay, state sync, S2S commands)
- Storage schema with all tables and columns documented
- Configuration reference with all environment variables
- Deployment guide (Docker, binary, reverse proxy, SQLite considerations)
- Client development guide with curl examples and Python/JS code
- Hashcash proof-of-work spec (challenge/response flow, adaptive difficulty)
- Detailed roadmap (MVP, post-MVP, future)
- Project structure with every directory explained
2026-02-10 18:18:29 -08:00
clawbot
c4d97b2cad refactor: clean up handlers, add input validation, remove raw SQL from handlers
- Merge fanOut/fanOutDirect into single fanOut method
- Move channel lookup to db.GetChannelByName
- Add regex validation for nicks and channel names
- Split HandleSendCommand into per-command helper methods
- Add charset to Content-Type header
- Add sentinel error for unauthorized
- Cap history limit to 500
- Skip NICK change if new == old
- Add empty command check
2026-02-10 18:16:23 -08:00
clawbot
647a7ce313 Revert: exclude chat-cli from final Docker image (server-only)
CLI is built during Docker build to verify compilation, but only chatd
is included in the final image. CLI distributed separately.
2026-02-10 18:10:45 -08:00
clawbot
cee2506e8d Document hashcash proof-of-work plan for session rate limiting 2026-02-10 18:10:32 -08:00
clawbot
63957c6a46 Include chat-cli in final Docker image 2026-02-10 18:10:05 -08:00
clawbot
0b84872178 Update Dockerfile for Go 1.24, no Node build step needed
SPA is vanilla JS shipped as static files in web/dist/,
no npm build step required.
2026-02-10 18:09:24 -08:00
clawbot
1f54b281fd MVP: IRC envelope format, long-polling, per-client queues, SPA rewrite
Major changes:
- Consolidated schema into single migration with IRC envelope format
- Messages table stores command/from/to/body(JSON)/meta(JSON) per spec
- Per-client delivery queues (client_queues table) with fan-out
- In-memory broker for long-poll notifications (no busy polling)
- GET /messages supports ?after=<queue_id>&timeout=15 long-polling
- All commands (JOIN/PART/NICK/TOPIC/QUIT/PING) broadcast events
- Channels are ephemeral (deleted when last member leaves)
- PRIVMSG to nicks (DMs) fan out to both sender and recipient
- SPA rewritten in vanilla JS (no build step needed):
  - Long-poll via recursive fetch (not setInterval)
  - IRC envelope parsing with system message display
  - /nick, /join, /part, /msg, /quit commands
  - Unread indicators on inactive tabs
  - DM tabs from user list clicks
- Removed unused models package (was for UUID-based schema)
- Removed conflicting UUID-based db methods
- Increased HTTP write timeout to 60s for long-poll support
2026-02-10 18:09:10 -08:00
95 changed files with 3693 additions and 20638 deletions

View File

@@ -1,8 +1,8 @@
.git .git
*.md *.md
!README.md !README.md
neoircd chatd
neoirc-cli chat-cli
data.db data.db
data.db-wal data.db-wal
data.db-shm data.db-shm

View File

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

View File

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

33
.gitignore vendored
View File

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

View File

@@ -7,7 +7,24 @@ run:
linters: linters:
default: all default: all
disable: disable:
- wsl # Deprecated in v2, replaced by wsl_v5 - exhaustruct
- depguard
- godot
- wsl
- wsl_v5
- wrapcheck
- varnamelen
- noinlineerr
- dupl
- paralleltest
- nlreturn
- tagliatelle
- goconst
- funlen
- maintidx
- cyclop
- gocognit
- lll
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -18,19 +35,7 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
gosec:
excludes:
- G704
depguard:
rules:
all:
deny:
- pkg: "io/ioutil"
desc: "Deprecated; use io and os packages."
- pkg: "math/rand$"
desc: "Use crypto/rand for security-sensitive code."
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

@@ -5,7 +5,7 @@
1. **Format**: `gofmt -s -w .` and `goimports -w .` 1. **Format**: `gofmt -s -w .` and `goimports -w .`
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues 2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
3. **Test**: `go test -race ./...` — all passing 3. **Test**: `go test -race ./...` — all passing
4. **Build**: `go build ./cmd/neoircd` — compiles clean 4. **Build**: `go build ./cmd/chatd` — compiles clean
No commit lands on main with lint errors, test failures, or formatting issues. No commit lands on main with lint errors, test failures, or formatting issues.

View File

@@ -1,59 +1,29 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
# without depending on the web-builder stage (lint should fail fast)
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
RUN make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.24-alpine, 2026-02-26 FROM golang:1.24-alpine AS builder
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src WORKDIR /src
RUN apk add --no-cache git build-base make RUN apk add --no-cache make gcc musl-dev
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test # Run tests
ENV DBURL="file::memory:?cache=shared"
RUN go test ./...
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) # Build binaries
ARG VERSION=dev RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/ RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
# Runtime stage # Final stage — server only
# alpine:3.21, 2026-02-26 FROM alpine:3.21
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \ RUN apk add --no-cache ca-certificates \
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \ && addgroup -S chat && adduser -S chat -G chat
&& mkdir -p /var/lib/neoirc \ COPY --from=builder /chatd /usr/local/bin/chatd
&& chown neoirc:neoirc /var/lib/neoirc
COPY --from=builder /neoircd /usr/local/bin/neoircd
USER neoirc USER chat
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1 CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["neoircd"] ENTRYPOINT ["chatd"]

21
LICENSE
View File

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

View File

@@ -1,60 +1,20 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH) LDFLAGS := -ldflags "-X main.Version=$(VERSION)"
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build .PHONY: build test clean docker lint
# ensure-web-dist creates placeholder files so //go:embed dist/* in build:
# web/embed.go resolves without a full Node.js build. The real SPA is go build $(LDFLAGS) -o chatd ./cmd/chatd/
# built by the web-builder Docker stage; these placeholders let go build $(LDFLAGS) -o chat-cli ./cmd/chat-cli/
# "make test" and "make build" work outside Docker.
ensure-web-dist:
@if [ ! -d web/dist ]; then \
mkdir -p web/dist && \
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
echo "==> Created placeholder web/dist/ for go:embed"; \
fi
build: ensure-web-dist test:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd DBURL="file::memory:?cache=shared" go test ./...
lint: ensure-web-dist
golangci-lint run --config .golangci.yml ./...
fmt:
gofmt -s -w .
goimports -w .
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
# check runs all validation without making changes
# Used by CI and Docker build — fails if anything is wrong
check: test lint fmt-check
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
@echo "==> All checks passed!"
run: build
./bin/$(BINARY)
debug: build
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
clean: clean:
rm -rf bin/ neoircd rm -f chatd chat-cli
lint:
GOFLAGS=-buildvcs=false golangci-lint run ./...
docker: docker:
docker build -t neoirc . docker build -t chat:$(VERSION) .
hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

1395
README.md

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,4 @@
// Package neoircapi provides a client for the neoirc server API. package chatapi
package neoircapi
import ( import (
"bytes" "bytes"
@@ -9,13 +8,10 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
) )
const ( const (
@@ -26,51 +22,29 @@ const (
var errHTTP = errors.New("HTTP error") var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API. // Client wraps HTTP calls to the chat server API.
type Client struct { type Client struct {
BaseURL string BaseURL string
Token string
HTTPClient *http.Client HTTPClient *http.Client
} }
// NewClient creates a new API client with a cookie jar // NewClient creates a new API client.
// for automatic auth cookie management.
func NewClient(baseURL string) *Client { func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{ return &Client{
BaseURL: baseURL, BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine HTTPClient: &http.Client{Timeout: httpTimeout},
Timeout: httpTimeout,
Jar: jar,
},
} }
} }
// CreateSession creates a new session on the server. // CreateSession creates a new session on the server.
// If the server requires hashcash proof-of-work, it func (c *Client) CreateSession(
// automatically fetches the difficulty and computes a
// valid stamp.
func (client *Client) CreateSession(
nick string, nick string,
) (*SessionResponse, error) { ) (*SessionResponse, error) {
// Fetch server info to check for hashcash requirement. data, err := c.do(
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, http.MethodPost,
"/api/v1/session", "/api/v1/session",
&SessionRequest{Nick: nick, Hashcash: hashcashStamp}, &SessionRequest{Nick: nick},
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -83,12 +57,14 @@ func (client *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err) return nil, fmt.Errorf("decode session: %w", err)
} }
c.Token = resp.Token
return &resp, nil return &resp, nil
} }
// GetState returns the current user state. // GetState returns the current user state.
func (client *Client) GetState() (*StateResponse, error) { func (c *Client) GetState() (*StateResponse, error) {
data, err := client.do( data, err := c.do(
http.MethodGet, "/api/v1/state", nil, http.MethodGet, "/api/v1/state", nil,
) )
if err != nil { if err != nil {
@@ -106,8 +82,8 @@ func (client *Client) GetState() (*StateResponse, error) {
} }
// SendMessage sends a message (any IRC command). // SendMessage sends a message (any IRC command).
func (client *Client) SendMessage(msg *Message) error { func (c *Client) SendMessage(msg *Message) error {
_, err := client.do( _, err := c.do(
http.MethodPost, "/api/v1/messages", msg, http.MethodPost, "/api/v1/messages", msg,
) )
@@ -115,15 +91,14 @@ func (client *Client) SendMessage(msg *Message) error {
} }
// PollMessages long-polls for new messages. // PollMessages long-polls for new messages.
func (client *Client) PollMessages( func (c *Client) PollMessages(
afterID int64, afterID int64,
timeout int, timeout int,
) (*PollResult, error) { ) (*PollResult, error) {
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine client := &http.Client{
Timeout: time.Duration( Timeout: time.Duration(
timeout+pollExtraTime, timeout+pollExtraTime,
) * time.Second, ) * time.Second,
Jar: client.HTTPClient.Jar,
} }
params := url.Values{} params := url.Values{}
@@ -138,26 +113,28 @@ func (client *Client) PollMessages(
path := "/api/v1/messages?" + params.Encode() path := "/api/v1/messages?" + params.Encode()
request, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
client.BaseURL+path, c.BaseURL+path,
nil, nil,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("new request: %w", err) return nil, err
} }
resp, err := pollClient.Do(request) req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := client.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
if err != nil { if err != nil {
return nil, fmt.Errorf("poll request: %w", err) return nil, err
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("read poll body: %w", err) return nil, err
} }
if resp.StatusCode >= httpErrThreshold { if resp.StatusCode >= httpErrThreshold {
@@ -183,28 +160,22 @@ func (client *Client) PollMessages(
} }
// JoinChannel joins a channel. // JoinChannel joins a channel.
func (client *Client) JoinChannel(channel string) error { func (c *Client) JoinChannel(channel string) error {
return client.SendMessage( return c.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{Command: "JOIN", To: channel},
Command: irc.CmdJoin, To: channel,
},
) )
} }
// PartChannel leaves a channel. // PartChannel leaves a channel.
func (client *Client) PartChannel(channel string) error { func (c *Client) PartChannel(channel string) error {
return client.SendMessage( return c.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{Command: "PART", To: channel},
Command: irc.CmdPart, To: channel,
},
) )
} }
// ListChannels returns all channels on the server. // ListChannels returns all channels on the server.
func (client *Client) ListChannels() ( func (c *Client) ListChannels() ([]Channel, error) {
[]Channel, error, data, err := c.do(
) {
data, err := client.do(
http.MethodGet, "/api/v1/channels", nil, http.MethodGet, "/api/v1/channels", nil,
) )
if err != nil { if err != nil {
@@ -215,21 +186,19 @@ func (client *Client) ListChannels() (
err = json.Unmarshal(data, &channels) err = json.Unmarshal(data, &channels)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, err
"decode channels: %w", err,
)
} }
return channels, nil return channels, nil
} }
// GetMembers returns members of a channel. // GetMembers returns members of a channel.
func (client *Client) GetMembers( func (c *Client) GetMembers(
channel string, channel string,
) ([]string, error) { ) ([]string, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := c.do(
http.MethodGet, http.MethodGet,
"/api/v1/channels/"+url.PathEscape(name)+ "/api/v1/channels/"+url.PathEscape(name)+
"/members", "/members",
@@ -252,10 +221,8 @@ func (client *Client) GetMembers(
} }
// GetServerInfo returns server info. // GetServerInfo returns server info.
func (client *Client) GetServerInfo() ( func (c *Client) GetServerInfo() (*ServerInfo, error) {
*ServerInfo, error, data, err := c.do(
) {
data, err := client.do(
http.MethodGet, "/api/v1/server", nil, http.MethodGet, "/api/v1/server", nil,
) )
if err != nil { if err != nil {
@@ -266,15 +233,13 @@ func (client *Client) GetServerInfo() (
err = json.Unmarshal(data, &info) err = json.Unmarshal(data, &info)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, err
"decode server info: %w", err,
)
} }
return &info, nil return &info, nil
} }
func (client *Client) do( func (c *Client) do(
method, path string, method, path string,
body any, body any,
) ([]byte, error) { ) ([]byte, error) {
@@ -289,21 +254,25 @@ func (client *Client) do(
bodyReader = bytes.NewReader(data) bodyReader = bytes.NewReader(data)
} }
request, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
context.Background(), context.Background(),
method, method,
client.BaseURL+path, c.BaseURL+path,
bodyReader, bodyReader,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("request: %w", err) return nil, fmt.Errorf("request: %w", err)
} }
request.Header.Set( req.Header.Set("Content-Type", "application/json")
"Content-Type", "application/json",
)
resp, err := client.HTTPClient.Do(request) if c.Token != "" {
req.Header.Set(
"Authorization", "Bearer "+c.Token,
)
}
resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL built from trusted BaseURL + hardcoded path
if err != nil { if err != nil {
return nil, fmt.Errorf("http: %w", err) return nil, fmt.Errorf("http: %w", err)
} }

View File

@@ -1,17 +1,18 @@
package neoircapi // Package chatapi provides API types and client for chat-cli.
package chatapi
import "time" import "time"
// SessionRequest is the body for POST /api/v1/session. // SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct { type SessionRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
} }
// SessionResponse is the response from session creation. // SessionResponse is the response from session creation.
type SessionResponse struct { type SessionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Token string `json:"token"`
} }
// StateResponse is the response from GET /api/v1/state. // StateResponse is the response from GET /api/v1/state.
@@ -21,7 +22,7 @@ type StateResponse struct {
Channels []string `json:"channels"` Channels []string `json:"channels"`
} }
// Message represents a neoirc message envelope. // Message represents a chat message envelope.
type Message struct { type Message struct {
Command string `json:"command"` Command string `json:"command"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
@@ -35,19 +36,19 @@ type Message struct {
// BodyLines returns the body as a string slice. // BodyLines returns the body as a string slice.
func (m *Message) BodyLines() []string { func (m *Message) BodyLines() []string {
switch bodyVal := m.Body.(type) { switch v := m.Body.(type) {
case []any: case []any:
lines := make([]string, 0, len(bodyVal)) lines := make([]string, 0, len(v))
for _, item := range bodyVal { for _, item := range v {
if str, ok := item.(string); ok { if s, ok := item.(string); ok {
lines = append(lines, str) lines = append(lines, s)
} }
} }
return lines return lines
case []string: case []string:
return bodyVal return v
default: default:
return nil return nil
} }
@@ -66,7 +67,6 @@ type ServerInfo struct {
Name string `json:"name"` Name string `json:"name"`
MOTD string `json:"motd"` MOTD string `json:"motd"`
Version string `json:"version"` Version string `json:"version"`
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
} }
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.

View File

@@ -1,5 +1,5 @@
// Package cli implements the neoirc-cli terminal client. // Package main is the entry point for the chat-cli client.
package cli package main
import ( import (
"fmt" "fmt"
@@ -8,8 +8,7 @@ import (
"sync" "sync"
"time" "time"
api "git.eeqj.de/sneak/neoirc/internal/cli/api" api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
"git.eeqj.de/sneak/neoirc/pkg/irc"
) )
const ( const (
@@ -32,9 +31,8 @@ type App struct {
stopPoll chan struct{} stopPoll chan struct{}
} }
// Run creates and runs the CLI application. func main() {
func Run() { app := &App{
app := &App{ //nolint:exhaustruct
ui: NewUI(), ui: NewUI(),
nick: "guest", nick: "guest",
} }
@@ -43,7 +41,7 @@ func Run() {
app.ui.SetStatus(app.nick, "", "disconnected") app.ui.SetStatus(app.nick, "", "disconnected")
app.ui.AddStatus( app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client", "Welcome to chat-cli — an IRC-style client",
) )
app.ui.AddStatus( app.ui.AddStatus(
"Type [yellow]/connect <server-url>" + "Type [yellow]/connect <server-url>" +
@@ -87,8 +85,8 @@ func (a *App) handleInput(text string) {
return return
} }
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct err := a.client.SendMessage(&api.Message{
Command: irc.CmdPrivmsg, Command: "PRIVMSG",
To: target, To: target,
Body: []string{text}, Body: []string{text},
}) })
@@ -100,7 +98,7 @@ func (a *App) handleInput(text string) {
return return
} }
timestamp := time.Now().Format(timeFormat) ts := time.Now().Format(timeFormat)
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
@@ -108,7 +106,7 @@ func (a *App) handleInput(text string) {
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, nick, text, ts, nick, text,
)) ))
} }
@@ -140,29 +138,16 @@ func (a *App) dispatchCommand(cmd, args string) {
a.cmdQuery(args) a.cmdQuery(args)
case "/topic": case "/topic":
a.cmdTopic(args) a.cmdTopic(args)
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/window", "/w": case "/window", "/w":
a.cmdWindow(args) a.cmdWindow(args)
case "/quit": case "/quit":
a.cmdQuit() a.cmdQuit()
case "/help": case "/help":
a.cmdHelp() 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: default:
a.ui.AddStatus( a.ui.AddStatus(
"[red]Unknown command: " + cmd, "[red]Unknown command: " + cmd,
@@ -242,8 +227,8 @@ func (a *App) cmdNick(nick string) {
return return
} }
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct err := a.client.SendMessage(&api.Message{
Command: irc.CmdNick, Command: "NICK",
Body: []string{nick}, Body: []string{nick},
}) })
if err != nil { if err != nil {
@@ -377,8 +362,8 @@ func (a *App) cmdMsg(args string) {
return return
} }
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct err := a.client.SendMessage(&api.Message{
Command: irc.CmdPrivmsg, Command: "PRIVMSG",
To: target, To: target,
Body: []string{text}, Body: []string{text},
}) })
@@ -390,11 +375,11 @@ func (a *App) cmdMsg(args string) {
return return
} }
timestamp := time.Now().Format(timeFormat) ts := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, nick, text, ts, nick, text,
)) ))
} }
@@ -435,8 +420,8 @@ func (a *App) cmdTopic(args string) {
} }
if args == "" { if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct err := a.client.SendMessage(&api.Message{
Command: irc.CmdTopic, Command: "TOPIC",
To: target, To: target,
}) })
if err != nil { if err != nil {
@@ -448,8 +433,8 @@ func (a *App) cmdTopic(args string) {
return return
} }
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct err := a.client.SendMessage(&api.Message{
Command: irc.CmdTopic, Command: "TOPIC",
To: target, To: target,
Body: []string{args}, Body: []string{args},
}) })
@@ -525,96 +510,6 @@ func (a *App) cmdList() {
a.ui.AddStatus("[cyan]*** End of channel list") 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) { func (a *App) cmdWindow(args string) {
if args == "" { if args == "" {
a.ui.AddStatus( a.ui.AddStatus(
@@ -624,18 +519,18 @@ func (a *App) cmdWindow(args string) {
return return
} }
var bufIndex int var n int
_, _ = fmt.Sscanf(args, "%d", &bufIndex) _, _ = fmt.Sscanf(args, "%d", &n)
a.ui.SwitchBuffer(bufIndex) a.ui.SwitchBuffer(n)
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
a.mu.Unlock() a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { if n >= 0 && n < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex] buf := a.ui.buffers[n]
if buf.Name != "(status)" { if buf.Name != "(status)" {
a.mu.Lock() a.mu.Lock()
a.target = buf.Name a.target = buf.Name
@@ -655,7 +550,7 @@ func (a *App) cmdQuit() {
if a.connected && a.client != nil { if a.connected && a.client != nil {
_ = a.client.SendMessage( _ = a.client.SendMessage(
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct &api.Message{Command: "QUIT"},
) )
} }
@@ -669,7 +564,7 @@ func (a *App) cmdQuit() {
func (a *App) cmdHelp() { func (a *App) cmdHelp() {
help := []string{ help := []string{
"[cyan]*** neoirc-cli commands:", "[cyan]*** chat-cli commands:",
" /connect <url> — Connect to server", " /connect <url> — Connect to server",
" /nick <name> — Change nickname", " /nick <name> — Change nickname",
" /join #channel — Join channel", " /join #channel — Join channel",
@@ -679,9 +574,6 @@ func (a *App) cmdHelp() {
" /topic [text] — View/set topic", " /topic [text] — View/set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /who [#channel] — List users in channel",
" /whois <nick> — Show user info",
" /motd — Show message of the day",
" /window <n> — Switch buffer", " /window <n> — Switch buffer",
" /quit — Disconnect and exit", " /quit — Disconnect and exit",
" /help — This help", " /help — This help",
@@ -733,29 +625,29 @@ func (a *App) pollLoop() {
} }
func (a *App) handleServerMessage(msg *api.Message) { func (a *App) handleServerMessage(msg *api.Message) {
timestamp := a.formatTS(msg) ts := a.formatTS(msg)
a.mu.Lock() a.mu.Lock()
myNick := a.nick myNick := a.nick
a.mu.Unlock() a.mu.Unlock()
switch msg.Command { switch msg.Command {
case irc.CmdPrivmsg: case "PRIVMSG":
a.handlePrivmsgEvent(msg, timestamp, myNick) a.handlePrivmsgEvent(msg, ts, myNick)
case irc.CmdJoin: case "JOIN":
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, ts)
case irc.CmdPart: case "PART":
a.handlePartEvent(msg, timestamp) a.handlePartEvent(msg, ts)
case irc.CmdQuit: case "QUIT":
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, ts)
case irc.CmdNick: case "NICK":
a.handleNickEvent(msg, timestamp, myNick) a.handleNickEvent(msg, ts, myNick)
case irc.CmdNotice: case "NOTICE":
a.handleNoticeEvent(msg, timestamp) a.handleNoticeEvent(msg, ts)
case irc.CmdTopic: case "TOPIC":
a.handleTopicEvent(msg, timestamp) a.handleTopicEvent(msg, ts)
default: default:
a.handleDefaultEvent(msg, timestamp) a.handleDefaultEvent(msg, ts)
} }
} }
@@ -768,7 +660,7 @@ func (a *App) formatTS(msg *api.Message) string {
} }
func (a *App) handlePrivmsgEvent( func (a *App) handlePrivmsgEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, ts, myNick string,
) { ) {
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
@@ -784,12 +676,12 @@ func (a *App) handlePrivmsgEvent(
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, msg.From, text, ts, msg.From, text,
)) ))
} }
func (a *App) handleJoinEvent( func (a *App) handleJoinEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
if msg.To == "" { if msg.To == "" {
return return
@@ -797,12 +689,12 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s", "[gray]%s [yellow]*** %s has joined %s",
timestamp, msg.From, msg.To, ts, msg.From, msg.To,
)) ))
} }
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
if msg.To == "" { if msg.To == "" {
return return
@@ -814,18 +706,18 @@ func (a *App) handlePartEvent(
if reason != "" { if reason != "" {
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)", "[gray]%s [yellow]*** %s has left %s (%s)",
timestamp, msg.From, msg.To, reason, ts, msg.From, msg.To, reason,
)) ))
} else { } else {
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s", "[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To, ts, msg.From, msg.To,
)) ))
} }
} }
func (a *App) handleQuitEvent( func (a *App) handleQuitEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
lines := msg.BodyLines() lines := msg.BodyLines()
reason := strings.Join(lines, " ") reason := strings.Join(lines, " ")
@@ -833,18 +725,18 @@ func (a *App) handleQuitEvent(
if reason != "" { if reason != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)", "[gray]%s [yellow]*** %s has quit (%s)",
timestamp, msg.From, reason, ts, msg.From, reason,
)) ))
} else { } else {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit", "[gray]%s [yellow]*** %s has quit",
timestamp, msg.From, ts, msg.From,
)) ))
} }
} }
func (a *App) handleNickEvent( func (a *App) handleNickEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, ts, myNick string,
) { ) {
lines := msg.BodyLines() lines := msg.BodyLines()
@@ -865,24 +757,24 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s", "[gray]%s [yellow]*** %s is now known as %s",
timestamp, msg.From, newNick, ts, msg.From, newNick,
)) ))
} }
func (a *App) handleNoticeEvent( func (a *App) handleNoticeEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s", "[gray]%s [magenta]--%s-- %s",
timestamp, msg.From, text, ts, msg.From, text,
)) ))
} }
func (a *App) handleTopicEvent( func (a *App) handleTopicEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
if msg.To == "" { if msg.To == "" {
return return
@@ -893,12 +785,12 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s", "[gray]%s [cyan]*** %s set topic: %s",
timestamp, msg.From, text, ts, msg.From, text,
)) ))
} }
func (a *App) handleDefaultEvent( func (a *App) handleDefaultEvent(
msg *api.Message, timestamp string, msg *api.Message, ts string,
) { ) {
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
@@ -906,7 +798,7 @@ func (a *App) handleDefaultEvent(
if text != "" { if text != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white][%s] %s", "[gray]%s [white][%s] %s",
timestamp, msg.Command, text, ts, msg.Command, text,
)) ))
} }
} }

View File

@@ -1,4 +1,4 @@
package cli package main
import ( import (
"fmt" "fmt"
@@ -32,10 +32,10 @@ type UI struct {
// NewUI creates the tview-based IRC-like UI. // NewUI creates the tview-based IRC-like UI.
func NewUI() *UI { func NewUI() *UI {
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic ui := &UI{
app: tview.NewApplication(), app: tview.NewApplication(),
buffers: []*Buffer{ buffers: []*Buffer{
{Name: "(status)", Lines: nil, Unread: 0}, {Name: "(status)", Lines: nil},
}, },
} }
@@ -58,12 +58,7 @@ func NewUI() *UI {
// Run starts the UI event loop (blocks). // Run starts the UI event loop (blocks).
func (ui *UI) Run() error { func (ui *UI) Run() error {
err := ui.app.Run() return ui.app.Run()
if err != nil {
return fmt.Errorf("run ui: %w", err)
}
return nil
} }
// Stop stops the UI. // Stop stops the UI.
@@ -85,7 +80,6 @@ func (ui *UI) AddLine(bufferName, line string) {
cur := ui.buffers[ui.currentBuffer] cur := ui.buffers[ui.currentBuffer]
if cur != buf { if cur != buf {
buf.Unread++ buf.Unread++
ui.refreshStatusBar() ui.refreshStatusBar()
} }
@@ -105,15 +99,15 @@ func (ui *UI) AddStatus(line string) {
} }
// SwitchBuffer switches to the buffer at index n. // SwitchBuffer switches to the buffer at index n.
func (ui *UI) SwitchBuffer(bufIndex int) { func (ui *UI) SwitchBuffer(n int) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) { if n < 0 || n >= len(ui.buffers) {
return return
} }
ui.currentBuffer = bufIndex ui.currentBuffer = n
buf := ui.buffers[bufIndex] buf := ui.buffers[n]
buf.Unread = 0 buf.Unread = 0
ui.messages.Clear() ui.messages.Clear()
@@ -287,7 +281,7 @@ func (ui *UI) getOrCreateBuffer(name string) *Buffer {
} }
} }
buf := &Buffer{Name: name, Lines: nil, Unread: 0} buf := &Buffer{Name: name}
ui.buffers = append(ui.buffers, buf) ui.buffers = append(ui.buffers, buf)
return buf return buf

41
cmd/chatd/main.go Normal file
View File

@@ -0,0 +1,41 @@
// Package main is the entry point for the chatd server.
package main
import (
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server"
"go.uber.org/fx"
)
var (
// Appname is the application name, set at build time.
Appname = "chat" //nolint:gochecknoglobals
// Version is the application version, set at build time.
Version string //nolint:gochecknoglobals
)
func main() {
globals.Appname = Appname
globals.Version = Version
fx.New(
fx.Provide(
config.New,
db.New,
globals.New,
handlers.New,
logger.New,
server.New,
middleware.New,
healthcheck.New,
),
fx.Invoke(func(*server.Server) {}),
).Run()
}

View File

@@ -1,8 +0,0 @@
// Package main is the entry point for the neoirc-cli client.
package main
import "git.eeqj.de/sneak/neoirc/internal/cli"
func main() {
cli.Run()
}

View File

@@ -1,43 +0,0 @@
// Package main is the entry point for the neoircd server.
package main
import (
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
var (
// Appname is the application name, set at build time.
Appname = "neoirc" //nolint:gochecknoglobals
// Version is the application version, set at build time.
Version string //nolint:gochecknoglobals
)
func main() {
globals.Appname = Appname
globals.Version = Version
fx.New(
fx.Provide(
config.New,
db.New,
globals.New,
handlers.New,
logger.New,
server.New,
middleware.New,
healthcheck.New,
stats.New,
),
fx.Invoke(func(*server.Server) {}),
).Run()
}

18
go.mod
View File

@@ -1,22 +1,17 @@
module git.eeqj.de/sneak/neoirc module git.eeqj.de/sneak/chat
go 1.24.0 go 1.24.0
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0 github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/rivo/tview v0.42.0
github.com/slok/go-http-metrics v0.13.0 github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.48.0
golang.org/x/time v0.6.0
modernc.org/sqlite v1.45.0 modernc.org/sqlite v1.45.0
) )
@@ -26,7 +21,9 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -36,6 +33,7 @@ require (
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/tview v0.42.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -49,9 +47,9 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.40.0 // indirect golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

36
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -113,14 +113,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -128,8 +126,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -137,28 +135,30 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=

View File

@@ -8,28 +8,25 @@ import (
// Broker notifies waiting clients when new messages are available. // Broker notifies waiting clients when new messages are available.
type Broker struct { type Broker struct {
mu sync.Mutex mu sync.Mutex
listeners map[int64][]chan struct{} listeners map[int64][]chan struct{} // userID -> list of waiting channels
} }
// New creates a new Broker. // New creates a new Broker.
func New() *Broker { func New() *Broker {
return &Broker{ //nolint:exhaustruct // mu has zero-value default return &Broker{
listeners: make(map[int64][]chan struct{}), listeners: make(map[int64][]chan struct{}),
} }
} }
// Wait returns a channel that will be closed when a message // Wait returns a channel that will be closed when a message is available for the user.
// is available for the user.
func (b *Broker) Wait(userID int64) chan struct{} { func (b *Broker) Wait(userID int64) chan struct{} {
waitCh := make(chan struct{}, 1) ch := make(chan struct{}, 1)
b.mu.Lock() b.mu.Lock()
b.listeners[userID] = append( b.listeners[userID] = append(b.listeners[userID], ch)
b.listeners[userID], waitCh,
)
b.mu.Unlock() b.mu.Unlock()
return waitCh return ch
} }
// Notify wakes up all waiting clients for a user. // Notify wakes up all waiting clients for a user.
@@ -39,29 +36,24 @@ func (b *Broker) Notify(userID int64) {
delete(b.listeners, userID) delete(b.listeners, userID)
b.mu.Unlock() b.mu.Unlock()
for _, waiter := range waiters { for _, ch := range waiters {
select { select {
case waiter <- struct{}{}: case ch <- struct{}{}:
default: default:
} }
} }
} }
// Remove removes a specific wait channel (for cleanup on timeout). // Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove( func (b *Broker) Remove(userID int64, ch chan struct{}) {
userID int64,
waitCh chan struct{},
) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
waiters := b.listeners[userID] waiters := b.listeners[userID]
for i, waiter := range waiters { for i, w := range waiters {
if waiter == waitCh { if w == ch {
b.listeners[userID] = append( b.listeners[userID] = append(waiters[:i], waiters[i+1:]...)
waiters[:i], waiters[i+1:]...,
)
break break
} }

View File

@@ -5,14 +5,14 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker" "git.eeqj.de/sneak/chat/internal/broker"
) )
func TestNewBroker(t *testing.T) { func TestNewBroker(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
if brk == nil { if b == nil {
t.Fatal("expected non-nil broker") t.Fatal("expected non-nil broker")
} }
} }
@@ -20,16 +20,16 @@ func TestNewBroker(t *testing.T) {
func TestWaitAndNotify(t *testing.T) { func TestWaitAndNotify(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
waitCh := brk.Wait(1) ch := b.Wait(1)
go func() { go func() {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
brk.Notify(1) b.Notify(1)
}() }()
select { select {
case <-waitCh: case <-ch:
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("timeout") t.Fatal("timeout")
} }
@@ -38,22 +38,21 @@ func TestWaitAndNotify(t *testing.T) {
func TestNotifyWithoutWaiters(t *testing.T) { func TestNotifyWithoutWaiters(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
brk.Notify(42) // should not panic. b.Notify(42) // should not panic
} }
func TestRemove(t *testing.T) { func TestRemove(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
waitCh := brk.Wait(1) ch := b.Wait(1)
b.Remove(1, ch)
brk.Remove(1, waitCh) b.Notify(1)
brk.Notify(1)
select { select {
case <-waitCh: case <-ch:
t.Fatal("should not receive after remove") t.Fatal("should not receive after remove")
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
} }
@@ -62,20 +61,20 @@ func TestRemove(t *testing.T) {
func TestMultipleWaiters(t *testing.T) { func TestMultipleWaiters(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
waitCh1 := brk.Wait(1) ch1 := b.Wait(1)
waitCh2 := brk.Wait(1) ch2 := b.Wait(1)
brk.Notify(1) b.Notify(1)
select { select {
case <-waitCh1: case <-ch1:
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("ch1 timeout") t.Fatal("ch1 timeout")
} }
select { select {
case <-waitCh2: case <-ch2:
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("ch2 timeout") t.Fatal("ch2 timeout")
} }
@@ -84,38 +83,36 @@ func TestMultipleWaiters(t *testing.T) {
func TestConcurrentWaitNotify(t *testing.T) { func TestConcurrentWaitNotify(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
var waitGroup sync.WaitGroup var wg sync.WaitGroup
const concurrency = 100 const concurrency = 100
for idx := range concurrency { for i := range concurrency {
waitGroup.Add(1) wg.Add(1)
go func(uid int64) { go func(uid int64) {
defer waitGroup.Done() defer wg.Done()
waitCh := brk.Wait(uid) ch := b.Wait(uid)
b.Notify(uid)
brk.Notify(uid)
select { select {
case <-waitCh: case <-ch:
case <-time.After(time.Second): case <-time.After(time.Second):
t.Error("timeout") t.Error("timeout")
} }
}(int64(idx % 10)) }(int64(i % 10))
} }
waitGroup.Wait() wg.Wait()
} }
func TestRemoveNonexistent(t *testing.T) { func TestRemoveNonexistent(t *testing.T) {
t.Parallel() t.Parallel()
brk := broker.New() b := broker.New()
waitCh := make(chan struct{}, 1) ch := make(chan struct{}, 1)
b.Remove(999, ch) // should not panic
brk.Remove(999, waitCh) // should not panic.
} }

View File

@@ -1,98 +0,0 @@
package neoircapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
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
}
}
}
// MintChannelHashcash computes a hashcash stamp bound to
// a specific channel and message body. The stamp format
// is 1:bits:YYMMDD:channel:bodyhash:counter where
// bodyhash is the hex-encoded SHA-256 of the message
// body bytes. Delegates to the internal/hashcash package.
func MintChannelHashcash(
bits int,
channel string,
body []byte,
) string {
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(
bits, channel, bodyHash,
)
}
// 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())
}

View File

@@ -5,22 +5,14 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -38,26 +30,18 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MessageMaxAge string MaxHistory int
SessionTimeout int
MaxMessageSize int MaxMessageSize int
QueueMaxAge string
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
SessionIdleTimeout string
HashcashBits int
OperName string
OperPassword string
LoginRateLimit float64
LoginRateBurst int
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
func New( func New(_ fx.Lifecycle, params Params) (*Config, error) {
_ fx.Lifecycle, params Params,
) (*Config, error) {
log := params.Logger.Get() log := params.Logger.Get()
name := params.Globals.Appname name := params.Globals.Appname
@@ -70,22 +54,16 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL") viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("SESSION_TIMEOUT", "86400")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "720h") viper.SetDefault("MOTD", "")
viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
viper.SetDefault("NEOIRC_OPER_NAME", "")
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
viper.SetDefault("LOGIN_RATE_BURST", "5")
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -96,7 +74,7 @@ func New(
} }
} }
cfg := &Config{ s := &Config{
DBURL: viper.GetString("DBURL"), DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"), Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"), Port: viper.GetInt("PORT"),
@@ -104,26 +82,20 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MaxHistory: viper.GetInt("MAX_HISTORY"),
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
log: log, log: log,
params: &params, params: &params,
} }
if cfg.Debug { if s.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get() s.log = params.Logger.Get()
} }
return cfg, nil return s, nil
} }

View File

@@ -1,119 +0,0 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
var bcryptCost = bcrypt.DefaultCost
// SetBcryptCost overrides the bcrypt cost.
// Use bcrypt.MinCost in tests to avoid slow hashing.
func SetBcryptCost(cost int) { bcryptCost = cost }
var errNoPassword = errors.New(
"account has no password set",
)
// SetPassword sets a bcrypt-hashed password on a session,
// enabling multi-client login via POST /api/v1/login.
func (database *Database) SetPassword(
ctx context.Context,
sessionID int64,
password string,
) error {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
_, err = database.conn.ExecContext(ctx,
"UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
if err != nil {
return fmt.Errorf("set password: %w", err)
}
return nil
}
// LoginUser verifies a nick/password and creates a new
// client token.
func (database *Database) LoginUser(
ctx context.Context,
nick, password, remoteIP, hostname 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, ip, hostname,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash,
remoteIP, hostname, 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
}

View File

@@ -1,156 +0,0 @@
package db_test
import (
"testing"
_ "modernc.org/sqlite"
)
func TestSetPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "passuser", "", "", "")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "password123",
)
if err != nil {
t.Fatal(err)
}
// Verify we can now log in with the password.
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "passuser", "password123", "", "")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || loginToken == "" {
t.Fatal("expected valid ids and token")
}
}
func TestSetPasswordThenWrongLogin(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "wrongpw", "", "", "")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "correctpass",
)
if err != nil {
t.Fatal(err)
}
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "loginuser", "", "", "")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "mypassword",
)
if err != nil {
t.Fatal(err)
}
loginSID, loginCID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 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 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
}

View File

@@ -12,8 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // .env _ "github.com/joho/godotenv/autoload" // .env
@@ -37,93 +37,84 @@ type Params struct {
// Database manages the SQLite connection and migrations. // Database manages the SQLite connection and migrations.
type Database struct { type Database struct {
conn *sql.DB db *sql.DB
log *slog.Logger log *slog.Logger
params *Params params *Params
} }
// New creates a new Database and registers lifecycle hooks. // New creates a new Database and registers lifecycle hooks.
func New( func New(
lifecycle fx.Lifecycle, lc fx.Lifecycle,
params Params, params Params,
) (*Database, error) { ) (*Database, error) {
database := &Database{ //nolint:exhaustruct // conn set in OnStart s := new(Database)
params: &params, s.params = &params
log: params.Logger.Get(), s.log = params.Logger.Get()
}
database.log.Info("Database instantiated") s.log.Info("Database instantiated")
lifecycle.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
database.log.Info("Database OnStart Hook") s.log.Info("Database OnStart Hook")
return database.connect(ctx) return s.connect(ctx)
}, },
OnStop: func(_ context.Context) error { OnStop: func(_ context.Context) error {
database.log.Info("Database OnStop Hook") s.log.Info("Database OnStop Hook")
if database.conn != nil { if s.db != nil {
closeErr := database.conn.Close() return s.db.Close()
if closeErr != nil {
return fmt.Errorf(
"close db: %w", closeErr,
)
}
} }
return nil return nil
}, },
}) })
return database, nil return s, nil
} }
// GetDB returns the underlying sql.DB connection. // GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB { func (s *Database) GetDB() *sql.DB {
return database.conn return s.db
} }
func (database *Database) connect(ctx context.Context) error { func (s *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := s.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000" dbURL = "file:./data.db?_journal_mode=WAL"
} }
database.log.Info( s.log.Info("connecting to database", "url", dbURL)
"connecting to database", "url", dbURL,
d, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error(
"failed to open database", "error", err,
) )
conn, err := sql.Open("sqlite", dbURL) return err
if err != nil {
return fmt.Errorf("open database: %w", err)
} }
err = conn.PingContext(ctx) err = d.PingContext(ctx)
if err != nil { if err != nil {
return fmt.Errorf("ping database: %w", err) s.log.Error(
"failed to ping database", "error", err,
)
return err
} }
conn.SetMaxOpenConns(1) s.db = d
s.log.Info("database connected")
database.conn = conn _, err = s.db.ExecContext(
database.log.Info("database connected")
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON", ctx, "PRAGMA foreign_keys = ON",
) )
if err != nil { if err != nil {
return fmt.Errorf("enable foreign keys: %w", err) return fmt.Errorf("enable foreign keys: %w", err)
} }
_, err = database.conn.ExecContext( return s.runMigrations(ctx)
ctx, "PRAGMA busy_timeout = 5000",
)
if err != nil {
return fmt.Errorf("set busy timeout: %w", err)
}
return database.runMigrations(ctx)
} }
type migration struct { type migration struct {
@@ -132,10 +123,10 @@ type migration struct {
sql string sql string
} }
func (database *Database) runMigrations( func (s *Database) runMigrations(
ctx context.Context, ctx context.Context,
) error { ) error {
_, err := database.conn.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations ( `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`) applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
@@ -145,37 +136,37 @@ func (database *Database) runMigrations(
) )
} }
migrations, err := database.loadMigrations() migrations, err := s.loadMigrations()
if err != nil { if err != nil {
return err return err
} }
for _, mig := range migrations { for _, m := range migrations {
err = database.applyMigration(ctx, mig) err = s.applyMigration(ctx, m)
if err != nil { if err != nil {
return err return err
} }
} }
database.log.Info("database migrations complete") s.log.Info("database migrations complete")
return nil return nil
} }
func (database *Database) applyMigration( func (s *Database) applyMigration(
ctx context.Context, ctx context.Context,
mig migration, m migration,
) error { ) error {
var exists int var exists int
err := database.conn.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations `SELECT COUNT(*) FROM schema_migrations
WHERE version = ?`, WHERE version = ?`,
mig.version, m.version,
).Scan(&exists) ).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"check migration %d: %w", mig.version, err, "check migration %d: %w", m.version, err,
) )
} }
@@ -183,63 +174,55 @@ func (database *Database) applyMigration(
return nil return nil
} }
database.log.Info( s.log.Info(
"applying migration", "applying migration",
"version", mig.version, "version", m.version,
"name", mig.name, "name", m.name,
) )
return database.execMigration(ctx, mig) return s.execMigration(ctx, m)
} }
func (database *Database) execMigration( func (s *Database) execMigration(
ctx context.Context, ctx context.Context,
mig migration, m migration,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"begin tx for migration %d: %w", "begin tx for migration %d: %w",
mig.version, err, m.version, err,
) )
} }
_, err = transaction.ExecContext(ctx, mig.sql) _, err = tx.ExecContext(ctx, m.sql)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = tx.Rollback()
return fmt.Errorf( return fmt.Errorf(
"apply migration %d (%s): %w", "apply migration %d (%s): %w",
mig.version, mig.name, err, m.version, m.name, err,
) )
} }
_, err = transaction.ExecContext(ctx, _, err = tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version) `INSERT INTO schema_migrations (version)
VALUES (?)`, VALUES (?)`,
mig.version, m.version,
) )
if err != nil { if err != nil {
_ = transaction.Rollback() _ = tx.Rollback()
return fmt.Errorf( return fmt.Errorf(
"record migration %d: %w", "record migration %d: %w",
mig.version, err, m.version, err,
) )
} }
err = transaction.Commit() return tx.Commit()
if err != nil {
return fmt.Errorf(
"commit migration %d: %w",
mig.version, err,
)
}
return nil
} }
func (database *Database) loadMigrations() ( func (s *Database) loadMigrations() (
[]migration, []migration,
error, error,
) { ) {
@@ -250,7 +233,7 @@ func (database *Database) loadMigrations() (
) )
} }
migrations := make([]migration, 0, len(entries)) var migrations []migration
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || if entry.IsDir() ||

View File

@@ -1,20 +0,0 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -13,48 +13,35 @@ var testDBCounter atomic.Int64
// NewTestDatabase creates an in-memory database for testing. // NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) { func NewTestDatabase() (*Database, error) {
counter := testDBCounter.Add(1) n := testDBCounter.Add(1)
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+ "file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)", "&cache=shared&_pragma=foreign_keys(1)",
counter, n,
) )
conn, err := sql.Open("sqlite", dsn) d, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open test db: %w", err) return nil, err
} }
database := &Database{ //nolint:exhaustruct // test helper, params not needed database := &Database{db: d, log: slog.Default()}
conn: conn,
log: slog.Default(),
}
err = database.runMigrations(context.Background()) err = database.runMigrations(context.Background())
if err != nil { if err != nil {
closeErr := conn.Close() closeErr := d.Close()
if closeErr != nil { if closeErr != nil {
return nil, fmt.Errorf( return nil, closeErr
"close after migration failure: %w",
closeErr,
)
} }
return nil, fmt.Errorf( return nil, err
"run test migrations: %w", err,
)
} }
return database, nil return database, nil
} }
// Close closes the underlying database connection. // Close closes the underlying database connection.
func (database *Database) Close() error { func (s *Database) Close() error {
err := database.conn.Close() return s.db.Close()
if err != nil {
return fmt.Errorf("close database: %w", err)
}
return nil
} }

View File

@@ -1,14 +0,0 @@
package db_test
import (
"os"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +1,32 @@
-- Chat server schema (pre-1.0 consolidated) -- Chat server schema (pre-1.0 consolidated)
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
-- Sessions: each session is a user identity (nick + optional password + signing key) -- Users: IRC-style sessions (no passwords, just nick + token)
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
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, token TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_clients_token ON clients(token); CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
CREATE INDEX IF NOT EXISTS idx_clients_session ON clients(session_id);
-- Channels -- Channels
CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '', topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
is_moderated INTEGER NOT NULL DEFAULT 0,
is_topic_locked INTEGER NOT NULL DEFAULT 1,
is_invite_only INTEGER NOT NULL DEFAULT 0,
is_secret INTEGER NOT NULL DEFAULT 0,
channel_key TEXT NOT NULL DEFAULT '',
user_limit INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- Channel bans
CREATE TABLE IF NOT EXISTS channel_bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
mask TEXT NOT NULL,
set_by TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, mask)
);
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
-- Channel invites (in-memory would be simpler but DB survives restarts)
CREATE TABLE IF NOT EXISTS channel_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
invited_by TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
-- Channel members -- Channel members
CREATE TABLE IF NOT EXISTS channel_members ( CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_operator INTEGER NOT NULL DEFAULT 0,
is_voiced INTEGER NOT NULL DEFAULT 0,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id) UNIQUE(channel_id, user_id)
); );
-- Messages: IRC envelope format -- Messages: IRC envelope format
@@ -91,7 +36,6 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -99,20 +43,12 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL)
CREATE TABLE IF NOT EXISTS spent_hashcash (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_hash TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
-- Per-client message queues for fan-out delivery -- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues ( CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, message_id) UNIQUE(user_id, message_id)
); );
CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id); CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);

View File

@@ -2,8 +2,6 @@
package globals package globals
import ( import (
"time"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -19,16 +17,14 @@ var (
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
StartTime time.Time
} }
// New creates a new Globals instance from the global state. // New creates a new Globals instance from the global state.
func New(_ fx.Lifecycle) (*Globals, error) { func New(_ fx.Lifecycle) (*Globals, error) {
result := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Version: Version, Version: Version,
StartTime: time.Now(),
} }
return result, nil return n, nil
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,182 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const minPasswordLength = 8
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleLogin(writer, request)
}
}
func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter,
request *http.Request,
) {
ip := clientIP(request)
if !hdlr.loginLimiter.Allow(ip) {
writer.Header().Set(
"Retry-After", "1",
)
hdlr.respondError(
writer, request,
"too many login attempts, try again later",
http.StatusTooManyRequests,
)
return
}
type loginRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if payload.Nick == "" || payload.Password == "" {
hdlr.respondError(
writer, request,
"nick and password required",
http.StatusBadRequest,
)
return
}
hdlr.executeLogin(
writer, request, payload.Nick, payload.Password,
)
}
func (hdlr *Handlers) executeLogin(
writer http.ResponseWriter,
request *http.Request,
nick, password string,
) {
remoteIP := clientIP(request)
hostname := resolveHostname(
request.Context(), remoteIP,
)
sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
nick, password,
remoteIP, hostname,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid credentials",
http.StatusUnauthorized,
)
return
}
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
)
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
request, clientID, sessionID, nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
}, http.StatusOK)
}
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -1,4 +1,4 @@
// Package handlers provides HTTP request handlers for the neoirc server. // Package handlers provides HTTP request handlers for the chat server.
package handlers package handlers
import ( import (
@@ -7,17 +7,13 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker" "git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -32,297 +28,52 @@ type Params struct {
Config *config.Config Config *config.Config
Database *db.Database Database *db.Database
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
} }
const defaultIdleTimeout = 30 * 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
loginLimiter *ratelimit.Limiter
stats *stats.Tracker
cancelCleanup context.CancelFunc
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
func New( func New(
lifecycle fx.Lifecycle, lc fx.Lifecycle,
params Params, params Params,
) (*Handlers, error) { ) (*Handlers, error) {
resource := params.Config.ServerName s := new(Handlers)
if resource == "" { s.params = &params
resource = "neoirc" s.log = params.Logger.Get()
} s.hc = params.Healthcheck
s.broker = broker.New()
loginRate := params.Config.LoginRateLimit
if loginRate <= 0 {
loginRate = ratelimit.DefaultRate
}
loginBurst := params.Config.LoginRateBurst
if loginBurst <= 0 {
loginBurst = ratelimit.DefaultBurst
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
hdlr.startCleanup(ctx)
return nil
},
OnStop: func(_ context.Context) error {
hdlr.stopCleanup()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
return nil return nil
}, },
}) })
return hdlr, nil return s, nil
} }
func (hdlr *Handlers) respondJSON( func (s *Handlers) respondJSON(
writer http.ResponseWriter, w http.ResponseWriter,
_ *http.Request, _ *http.Request,
data any, data any,
status int, status int,
) { ) {
writer.Header().Set( w.Header().Set(
"Content-Type", "Content-Type",
"application/json; charset=utf-8", "application/json; charset=utf-8",
) )
writer.WriteHeader(status) w.WriteHeader(status)
if data != nil { if data != nil {
err := json.NewEncoder(writer).Encode(data) err := json.NewEncoder(w).Encode(data)
if err != nil { if err != nil {
hdlr.log.Error( s.log.Error("json encode error", "error", err)
"json encode error", "error", err,
)
} }
} }
} }
func (hdlr *Handlers) respondError(
writer http.ResponseWriter,
request *http.Request,
msg string,
status int,
) {
hdlr.respondJSON(
writer, request,
map[string]string{"error": msg},
status,
)
}
func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
return defaultIdleTimeout
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid SESSION_IDLE_TIMEOUT, using default",
"value", raw, "error", err,
)
return defaultIdleTimeout
}
return dur
}
// startCleanup launches the idle-user cleanup goroutine.
// We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
}
func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup()
}
if hdlr.loginLimiter != nil {
hdlr.loginLimiter.Stop()
}
}
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
timeout := hdlr.idleTimeout()
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hdlr.runCleanup(ctx, timeout)
case <-ctx.Done():
return
}
}
}
func (hdlr *Handlers) runCleanup(
ctx context.Context,
timeout time.Duration,
) {
cutoff := time.Now().Add(-timeout)
// Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"user cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale users",
"deleted", deleted,
)
}
hdlr.pruneQueuesAndMessages(ctx)
}
// parseDurationConfig parses a Go duration string,
// returning zero on empty input and logging on error.
func (hdlr *Handlers) parseDurationConfig(
name, raw string,
) time.Duration {
if raw == "" {
return 0
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid duration config, skipping",
"name", name, "value", raw, "error", err,
)
return 0
}
return dur
}
// pruneQueuesAndMessages removes old client output queue
// entries per QUEUE_MAX_AGE and old messages per
// MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context,
) {
queueMaxAge := hdlr.parseDurationConfig(
"QUEUE_MAX_AGE",
hdlr.params.Config.QueueMaxAge,
)
if queueMaxAge > 0 {
queueCutoff := time.Now().Add(-queueMaxAge)
pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff)
if err != nil {
hdlr.log.Error(
"client output queue pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old client output queue entries",
"deleted", pruned,
)
}
}
messageMaxAge := hdlr.parseDurationConfig(
"MESSAGE_MAX_AGE",
hdlr.params.Config.MessageMaxAge,
)
if messageMaxAge > 0 {
msgCutoff := time.Now().Add(-messageMaxAge)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil {
hdlr.log.Error(
"message pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old messages",
"deleted", pruned,
)
}
}
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
}

View File

@@ -7,12 +7,9 @@ import (
const httpStatusOK = 200 const httpStatusOK = 200
// HandleHealthCheck returns an HTTP handler for the health check endpoint. // HandleHealthCheck returns an HTTP handler for the health check endpoint.
func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc { func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func( return func(w http.ResponseWriter, req *http.Request) {
writer http.ResponseWriter, resp := s.hc.Healthcheck()
request *http.Request, s.respondJSON(w, req, resp, httpStatusOK)
) {
resp := hdlr.hc.Healthcheck(request.Context())
hdlr.respondJSON(writer, request, resp, httpStatusOK)
} }
} }

View File

@@ -1,727 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
targetNick := strings.TrimSpace(lines[0])
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
hdlr.executeKillUser(
request, targetSID, targetNick, nick, reason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// executeKillUser forcibly disconnects a user: broadcasts
// QUIT to their channels, parts all channels, and deletes
// the session.
func (hdlr *Handlers) executeKillUser(
request *http.Request,
targetSID int64,
targetNick, killerNick, reason string,
) {
ctx := request.Context()
quitMsg := "Killed (" + killerNick + " (" + reason + "))"
quitBody, err := json.Marshal([]string{quitMsg})
if err != nil {
hdlr.log.Error(
"marshal kill quit body", "error", err,
)
return
}
channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, targetSID)
notified := map[int64]bool{}
var dbID int64
if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage(
ctx, irc.CmdQuit, targetNick, "",
nil, json.RawMessage(quitBody), nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(ctx, chanInfo.ID)
for _, mid := range memberIDs {
if mid != targetSID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
ctx, mid, dbID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
ctx, chanInfo.ID, targetSID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
ctx, chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(
ctx, targetSID,
)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w).
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
hdlr.applyUserModeChange(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — build the current mode string.
modeStr := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserModeString constructs the mode string for a
// user (e.g., "+ow" for oper+wallops).
func (hdlr *Handlers) buildUserModeString(
ctx context.Context,
sessionID int64,
) string {
modes := "+"
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := hdlr.params.Database.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// applyUserModeChange applies a user mode change string
// (e.g., "+w", "-w").
func (hdlr *Handlers) applyUserModeChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return
}
adding := modeStr[0] == '+'
modeChar := modeStr[1:]
applied, err := hdlr.applyModeChar(
ctx, writer, request,
sessionID, clientID, nick,
modeChar, adding,
)
if err != nil || !applied {
return
}
newModes := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// applyModeChar applies a single user mode character.
// Returns (applied, error).
func (hdlr *Handlers) applyModeChar(
ctx context.Context,
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeChar string,
adding bool,
) (bool, error) {
switch modeChar {
case "w":
err := hdlr.params.Database.SetSessionWallops(
ctx, sessionID, adding,
)
if err != nil {
hdlr.log.Error(
"set wallops mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"set wallops: %w", err,
)
}
case "o":
// +o cannot be set via MODE, only via OPER.
if adding {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, false,
)
if err != nil {
hdlr.log.Error(
"clear oper mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"clear oper: %w", err,
)
}
default:
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
return true, nil
}

View File

@@ -1,982 +0,0 @@
// Tests for Tier 3 utility IRC commands: USERHOST,
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
//
//nolint:paralleltest
package handlers_test
import (
"strings"
"testing"
)
// --- USERHOST ---
func TestUserhostSingleNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"alice"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 302 RPL_USERHOST.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
// Body should contain "alice" with the
// nick=+user@host format.
body := getNumericBody(msg)
if !strings.Contains(body, "alice") {
t.Fatalf(
"expected body to contain 'alice', got %q",
body,
)
}
// '+' means not away.
if !strings.Contains(body, "=+") {
t.Fatalf(
"expected not-away prefix '=+', got %q",
body,
)
}
}
func TestUserhostMultipleNicks(t *testing.T) {
tserver := newTestServer(t)
token1 := tserver.createSession("bob")
token2 := tserver.createSession("carol")
_ = token2
_, lastID := tserver.pollMessages(token1, 0)
tserver.sendCommand(token1, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"bob", "carol"},
})
msgs, _ := tserver.pollMessages(token1, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "bob") {
t.Fatalf(
"expected body to contain 'bob', got %q",
body,
)
}
if !strings.Contains(body, "carol") {
t.Fatalf(
"expected body to contain 'carol', got %q",
body,
)
}
}
func TestUserhostNonexistentNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dave")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"nobody"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should still get 302 but with empty body.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
}
func TestUserhostNoParams(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("eve")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestUserhostShowsOper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opernick")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '*' for oper.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"opernick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "opernick*=") {
t.Fatalf(
"expected oper '*' in reply, got %q",
body,
)
}
}
func TestUserhostShowsAway(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("awaynick")
_, lastID := tserver.pollMessages(token, 0)
// Set away.
tserver.sendCommand(token, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"gone fishing"},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '-' for away.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"awaynick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "=-") {
t.Fatalf(
"expected away prefix '=-' in reply, got %q",
body,
)
}
}
// --- VERSION ---
func TestVersion(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "VERSION",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 351 RPL_VERSION.
msg := findNumericWithParams(msgs, "351")
if msg == nil {
t.Fatalf(
"expected RPL_VERSION (351), got %v",
msgs,
)
}
params := getNumericParams(msg)
if len(params) == 0 {
t.Fatal("expected VERSION params, got none")
}
// First param should contain version string.
if !strings.Contains(params[0], "test") {
t.Fatalf(
"expected version to contain 'test', got %q",
params[0],
)
}
}
// --- ADMIN ---
func TestAdmin(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("grace")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "ADMIN",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 256 RPL_ADMINME.
if !findNumeric(msgs, "256") {
t.Fatalf(
"expected RPL_ADMINME (256), got %v",
msgs,
)
}
// Expect 257 RPL_ADMINLOC1.
if !findNumeric(msgs, "257") {
t.Fatalf(
"expected RPL_ADMINLOC1 (257), got %v",
msgs,
)
}
// Expect 258 RPL_ADMINLOC2.
if !findNumeric(msgs, "258") {
t.Fatalf(
"expected RPL_ADMINLOC2 (258), got %v",
msgs,
)
}
// Expect 259 RPL_ADMINEMAIL.
if !findNumeric(msgs, "259") {
t.Fatalf(
"expected RPL_ADMINEMAIL (259), got %v",
msgs,
)
}
}
// --- INFO ---
func TestInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "INFO",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 371 RPL_INFO (at least one).
if !findNumeric(msgs, "371") {
t.Fatalf(
"expected RPL_INFO (371), got %v",
msgs,
)
}
// Expect 374 RPL_ENDOFINFO.
if !findNumeric(msgs, "374") {
t.Fatalf(
"expected RPL_ENDOFINFO (374), got %v",
msgs,
)
}
}
// --- TIME ---
func TestTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("iris")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "TIME",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 391 RPL_TIME.
msg := findNumericWithParams(msgs, "391")
if msg == nil {
t.Fatalf(
"expected RPL_TIME (391), got %v",
msgs,
)
}
}
// --- KILL ---
func TestKillSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create the victim first.
victimToken := tserver.createSession("victim")
_ = victimToken
// Create oper user.
operToken := tserver.createSession("killer")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(operToken, lastID)
// Kill the victim.
status, result := tserver.sendCommand(
operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"victim", "go away"},
},
)
if status != 200 {
t.Fatalf("expected 200, got %d: %v", status, result)
}
resultStatus, _ := result[statusKey].(string)
if resultStatus != "ok" {
t.Fatalf(
"expected status ok, got %v",
result,
)
}
// Verify the victim's session is gone by trying
// to WHOIS them.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "victim",
})
msgs, _ := tserver.pollMessages(operToken, lastID)
// Should get 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected victim to be gone (401), got %v",
msgs,
)
}
}
func TestKillNotOper(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("target")
token := tserver.createSession("notoper")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{"target", "no reason"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestKillNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opertest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
// sendOperKillCommand is a helper that creates an oper
// session, authenticates, then sends KILL with the given
// target nick, and returns the resulting messages.
func sendOperKillCommand(
t *testing.T,
tserver *testServer,
operNick, targetNick string,
) []map[string]any {
t.Helper()
token := tserver.createSession(operNick)
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{targetNick},
})
msgs, _ := tserver.pollMessages(token, lastID)
return msgs
}
func TestKillNonexistentUser(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "opertest2", "ghost",
)
// Expect 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestKillSelf(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "selfkiller", "selfkiller",
)
// Expect 483 ERR_CANTKILLSERVER.
if !findNumeric(msgs, "483") {
t.Fatalf(
"expected ERR_CANTKILLSERVER (483), got %v",
msgs,
)
}
}
func TestKillBroadcastsQuit(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create victim and join a channel.
victimToken := tserver.createSession("vuser")
tserver.sendCommand(victimToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
// Create observer and join same channel.
observerToken := tserver.createSession("observer")
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
_, lastObs := tserver.pollMessages(observerToken, 0)
// Create oper.
operToken := tserver.createSession("theoper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Kill the victim.
tserver.sendCommand(operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"vuser", "testing kill"},
})
// Observer should see a QUIT message.
msgs, _ := tserver.pollMessages(observerToken, lastObs)
foundQuit := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "QUIT" {
from, _ := msg["from"].(string)
if from == "vuser" {
foundQuit = true
break
}
}
}
if !foundQuit {
t.Fatalf(
"expected QUIT from vuser, got %v",
msgs,
)
}
}
// --- WALLOPS ---
func TestWallopsSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver with +w.
receiverToken := tserver.createSession("receiver")
tserver.sendCommand(receiverToken, map[string]any{
commandKey: "MODE",
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Also set +w on oper so they receive it too.
tserver.sendCommand(operToken, map[string]any{
commandKey: "MODE",
toKey: "walloper",
bodyKey: []string{"+w"},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"server going down"},
})
// Receiver should get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
foundWallops := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
foundWallops = true
break
}
}
if !foundWallops {
t.Fatalf(
"expected WALLOPS message, got %v",
msgs,
)
}
}
func TestWallopsNotOper(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("notoper2")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"hello"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestWallopsNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operempty")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestWallopsNotReceivedWithoutW(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver WITHOUT +w.
receiverToken := tserver.createSession("nowallops")
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"secret message"},
})
// Receiver should NOT get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
t.Fatalf(
"did not expect WALLOPS for user "+
"without +w, got %v",
msgs,
)
}
}
}
// --- User Mode +w ---
func TestUserModeSetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wmoder")
_, lastID := tserver.pollMessages(token, 0)
// Set +w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
bodyKey: []string{"+w"},
})
msgs, lastID := tserver.pollMessages(token, lastID)
// Expect 221 RPL_UMODEIS with "+w".
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode string to contain 'w', got %q",
body,
)
}
// Now query mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
})
msgs, _ = tserver.pollMessages(token, lastID)
msg = findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221) on query, got %v",
msgs,
)
}
body = getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode '+w' in query, got %q",
body,
)
}
}
func TestUserModeUnsetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wunsetter")
_, lastID := tserver.pollMessages(token, 0)
// Set +w first.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"+w"},
})
_, lastID = tserver.pollMessages(token, lastID)
// Unset -w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"-w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "w") {
t.Fatalf(
"expected 'w' to be removed, got %q",
body,
)
}
}
func TestUserModeUnknownFlag(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("badmode")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "badmode",
bodyKey: []string{"+z"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeCannotSetO(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("tryoper")
_, lastID := tserver.pollMessages(token, 0)
// Try to set +o via MODE (should fail).
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "tryoper",
bodyKey: []string{"+o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeDeoper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("deoper")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// Use MODE -o to de-oper.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "deoper",
bodyKey: []string{"-o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "o") {
t.Fatalf(
"expected 'o' to be removed, got %q",
body,
)
}
}
func TestUserModeCannotChangeOtherUser(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("other")
token := tserver.createSession("changer")
_, lastID := tserver.pollMessages(token, 0)
// Try to change another user's mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "other",
bodyKey: []string{"+w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 502 ERR_USERSDONTMATCH.
if !findNumeric(msgs, "502") {
t.Fatalf(
"expected ERR_USERSDONTMATCH (502), got %v",
msgs,
)
}
}
// getNumericBody extracts the body text from a numeric
// message. The body is stored as a JSON array; this
// returns the first element.
func getNumericBody(msg map[string]any) string {
raw, exists := msg["body"]
if !exists || raw == nil {
return ""
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
return ""
}
str, isStr := arr[0].(string)
if !isStr {
return ""
}
return str
}

View File

@@ -1,186 +0,0 @@
package hashcash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errBodyHashMismatch = errors.New(
"body hash mismatch",
)
errBodyHashMissing = errors.New(
"body hash missing",
)
)
// ChannelValidator checks hashcash stamps for
// per-channel PRIVMSG validation. It verifies that
// stamps are bound to a specific channel and message
// body. Replay prevention is handled externally via
// the database spent_hashcash table for persistence
// across server restarts (1-year TTL).
type ChannelValidator struct{}
// NewChannelValidator creates a ChannelValidator.
func NewChannelValidator() *ChannelValidator {
return &ChannelValidator{}
}
// BodyHash computes the hex-encoded SHA-256 hash of a
// message body for use in hashcash stamp validation.
func BodyHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// ValidateStamp checks a channel hashcash stamp. It
// verifies the stamp format, difficulty, date, channel
// binding, body hash binding, and proof-of-work. Replay
// detection is NOT performed here — callers must check
// the spent_hashcash table separately.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func (cv *ChannelValidator) ValidateStamp(
stamp string,
requiredBits int,
channel string,
bodyHash string,
) 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]
stampBodyHash := parts[4]
headerErr := validateChannelHeader(
version, bitsStr, resource,
requiredBits, channel,
)
if headerErr != nil {
return headerErr
}
stampTime, parseErr := parseStampDate(dateStr)
if parseErr != nil {
return parseErr
}
timeErr := validateTime(stampTime)
if timeErr != nil {
return timeErr
}
bodyErr := validateBodyHash(
stampBodyHash, bodyHash,
)
if bodyErr != nil {
return bodyErr
}
return validateProof(stamp, requiredBits)
}
// StampHash returns a deterministic hash of a stamp
// string for use as a spent-token key.
func StampHash(stamp string) string {
hash := sha256.Sum256([]byte(stamp))
return hex.EncodeToString(hash[:])
}
func validateChannelHeader(
version, bitsStr, resource string,
requiredBits int,
channel string,
) 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 != channel {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, channel,
)
}
return nil
}
func validateBodyHash(
stampBodyHash, expectedBodyHash string,
) error {
if stampBodyHash == "" {
return errBodyHashMissing
}
if stampBodyHash != expectedBodyHash {
return fmt.Errorf(
"%w: got %q, want %q",
errBodyHashMismatch,
stampBodyHash, expectedBodyHash,
)
}
return nil
}
// MintChannelStamp computes a channel hashcash stamp
// with the given difficulty, channel name, and body hash.
// This is intended for clients to generate stamps before
// sending PRIVMSG to hashcash-protected channels.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func MintChannelStamp(
bits int,
channel string,
bodyHash string,
) string {
date := time.Now().UTC().Format(dateFormatShort)
prefix := fmt.Sprintf(
"1:%d:%s:%s:%s:",
bits, date, channel, bodyHash,
)
counter := uint64(0)
for {
stamp := prefix + strconv.FormatUint(counter, 16)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
counter++
}
}

View File

@@ -1,244 +0,0 @@
package hashcash_test
import (
"crypto/sha256"
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
testChannel = "#general"
testBodyText = `["hello world"]`
)
func testBodyHash() string {
hash := sha256.Sum256([]byte(testBodyText))
return hex.EncodeToString(hash[:])
}
func TestChannelValidateHappyPath(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("valid channel stamp rejected: %v", err)
}
}
func TestChannelValidateWrongChannel(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, "#other", bodyHash,
)
if err == nil {
t.Fatal("expected channel mismatch error")
}
}
func TestChannelValidateWrongBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
wrongHash := sha256.Sum256([]byte("different body"))
wrongBodyHash := hex.EncodeToString(wrongHash[:])
err := validator.ValidateStamp(
stamp, testBits, testChannel, wrongBodyHash,
)
if err == nil {
t.Fatal("expected body hash mismatch error")
}
}
func TestChannelValidateInsufficientBits(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with 2 bits but require 4.
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, 4, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestChannelValidateZeroBitsSkips(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"garbage", 0, "#ch", "abc",
)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestChannelValidateBadFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"not:valid", testBits, testChannel, "abc",
)
if err == nil {
t.Fatal("expected bad format error")
}
}
func TestChannelValidateBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestChannelValidateExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with a very old date by manually constructing.
stamp := mintStampWithDate(
t, testBits, testChannel, "200101",
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestChannelValidateMissingBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Construct a stamp with empty body hash field.
stamp := mintStampWithDate(
t, testBits, testChannel, todayDate(),
)
// This uses the session-style stamp which has empty
// ext field — body hash is missing.
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected missing body hash error")
}
}
func TestBodyHash(t *testing.T) {
t.Parallel()
body := []byte(`["hello world"]`)
bodyHash := hashcash.BodyHash(body)
if len(bodyHash) != 64 {
t.Fatalf(
"expected 64-char hex hash, got %d",
len(bodyHash),
)
}
// Same input should produce same hash.
bodyHash2 := hashcash.BodyHash(body)
if bodyHash != bodyHash2 {
t.Fatal("body hash not deterministic")
}
// Different input should produce different hash.
bodyHash3 := hashcash.BodyHash([]byte("different"))
if bodyHash == bodyHash3 {
t.Fatal("different inputs produced same hash")
}
}
func TestStampHash(t *testing.T) {
t.Parallel()
hash1 := hashcash.StampHash("stamp1")
hash2 := hashcash.StampHash("stamp2")
if hash1 == hash2 {
t.Fatal("different stamps produced same hash")
}
// Same input should be deterministic.
hash1b := hashcash.StampHash("stamp1")
if hash1 != hash1b {
t.Fatal("stamp hash not deterministic")
}
}
func TestMintChannelStamp(t *testing.T) {
t.Parallel()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
if stamp == "" {
t.Fatal("expected non-empty stamp")
}
// Validate the minted stamp.
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("minted stamp failed validation: %v", err)
}
}

View File

@@ -1,277 +0,0 @@
// Package hashcash implements SHA-256-based hashcash
// proof-of-work validation for abuse prevention.
//
// Stamp format: 1:bits:YYMMDD:resource::counter.
//
// The SHA-256 hash of the entire stamp string must have
// at least `bits` leading zero bits.
package hashcash
import (
"crypto/sha256"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
const (
// stampVersion is the only supported hashcash version.
stampVersion = "1"
// stampFields is the number of fields in a stamp.
stampFields = 6
// maxStampAge is how old a stamp can be before
// rejection.
maxStampAge = 48 * time.Hour
// maxFutureSkew allows stamps slightly in the future.
maxFutureSkew = 1 * time.Hour
// pruneInterval controls how often expired stamps are
// removed from the spent set.
pruneInterval = 10 * time.Minute
// dateFormatShort is the YYMMDD date layout.
dateFormatShort = "060102"
// dateFormatLong is the YYMMDDHHMMSS date layout.
dateFormatLong = "060102150405"
// dateShortLen is the length of YYMMDD.
dateShortLen = 6
// dateLongLen is the length of YYMMDDHHMMSS.
dateLongLen = 12
// bitsPerByte is the number of bits in a byte.
bitsPerByte = 8
// fullByteMask is 0xFF, a mask for all bits in a byte.
fullByteMask = 0xFF
)
var (
errInvalidFields = errors.New("invalid stamp field count")
errBadVersion = errors.New("unsupported stamp version")
errInsufficientBits = errors.New("insufficient difficulty")
errWrongResource = errors.New("wrong resource")
errStampExpired = errors.New("stamp expired")
errStampFuture = errors.New("stamp date in future")
errProofFailed = errors.New("proof-of-work failed")
errStampReused = errors.New("stamp already used")
errBadDateFormat = errors.New("unrecognized date format")
)
// Validator checks hashcash stamps for validity and
// prevents replay attacks via an in-memory spent set.
type Validator struct {
resource string
mu sync.Mutex
spent map[string]time.Time
}
// NewValidator creates a Validator for the given resource.
func NewValidator(resource string) *Validator {
validator := &Validator{
resource: resource,
mu: sync.Mutex{},
spent: make(map[string]time.Time),
}
go validator.pruneLoop()
return validator
}
// Validate checks a hashcash stamp. It returns nil if the
// stamp is valid and has not been seen before.
func (v *Validator) Validate(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
parts := strings.Split(stamp, ":")
if len(parts) != stampFields {
return fmt.Errorf(
"%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts),
)
}
version := parts[0]
bitsStr := parts[1]
dateStr := parts[2]
resource := parts[3]
if err := v.validateHeader(
version, bitsStr, resource, requiredBits,
); err != nil {
return err
}
stampTime, err := parseStampDate(dateStr)
if err != nil {
return err
}
if err := validateTime(stampTime); err != nil {
return err
}
if err := validateProof(
stamp, requiredBits,
); err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
func (v *Validator) validateHeader(
version, bitsStr, resource string,
requiredBits int,
) error {
if version != stampVersion {
return fmt.Errorf(
"%w: %s", errBadVersion, version,
)
}
claimedBits, err := strconv.Atoi(bitsStr)
if err != nil || claimedBits < requiredBits {
return fmt.Errorf(
"%w: need %d bits",
errInsufficientBits, requiredBits,
)
}
if resource != v.resource {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, v.resource,
)
}
return nil
}
func validateTime(stampTime time.Time) error {
now := time.Now()
if now.Sub(stampTime) > maxStampAge {
return errStampExpired
}
if stampTime.Sub(now) > maxFutureSkew {
return errStampFuture
}
return nil
}
func validateProof(stamp string, requiredBits int) error {
hash := sha256.Sum256([]byte(stamp))
if !hasLeadingZeroBits(hash[:], requiredBits) {
return fmt.Errorf(
"%w: need %d leading zero bits",
errProofFailed, requiredBits,
)
}
return nil
}
func (v *Validator) checkAndRecordStamp(
stamp string,
stampTime time.Time,
) error {
v.mu.Lock()
defer v.mu.Unlock()
if _, ok := v.spent[stamp]; ok {
return errStampReused
}
v.spent[stamp] = stampTime
return nil
}
// hasLeadingZeroBits checks if the hash has at least n
// leading zero bits.
func hasLeadingZeroBits(hash []byte, numBits int) bool {
fullBytes := numBits / bitsPerByte
remainBits := numBits % bitsPerByte
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(
fullByteMask << (bitsPerByte - remainBits),
)
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
// parseStampDate parses a hashcash date stamp.
// Supports YYMMDD and YYMMDDHHMMSS formats.
func parseStampDate(dateStr string) (time.Time, error) {
switch len(dateStr) {
case dateShortLen:
parsed, err := time.Parse(
dateFormatShort, dateStr,
)
if err != nil {
return time.Time{}, fmt.Errorf(
"parse date: %w", err,
)
}
return parsed, nil
case dateLongLen:
parsed, err := time.Parse(
dateFormatLong, dateStr,
)
if err != nil {
return time.Time{}, fmt.Errorf(
"parse date: %w", err,
)
}
return parsed, nil
default:
return time.Time{}, fmt.Errorf(
"%w: %q", errBadDateFormat, dateStr,
)
}
}
// pruneLoop periodically removes expired stamps from the
// spent set.
func (v *Validator) pruneLoop() {
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for range ticker.C {
v.prune()
}
}
func (v *Validator) prune() {
cutoff := time.Now().Add(-maxStampAge)
v.mu.Lock()
defer v.mu.Unlock()
for stamp, stampTime := range v.spent {
if stampTime.Before(cutoff) {
delete(v.spent, stamp)
}
}
}

View File

@@ -1,261 +0,0 @@
package hashcash_test
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const testBits = 2
// mintStampWithDate creates a valid hashcash stamp using
// the given date string.
func mintStampWithDate(
tb testing.TB,
bits int,
resource string,
date string,
) string {
tb.Helper()
prefix := fmt.Sprintf(
"1:%d:%s:%s::", bits, date, resource,
)
for {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(1<<48),
)
if err != nil {
tb.Fatalf("random counter: %v", err)
}
stamp := prefix + hex.EncodeToString(
counterVal.Bytes(),
)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
}
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits. Duplicated here for test minting.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / 8
remainBits := numBits % 8
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(0xFF << (8 - remainBits))
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
func todayDate() string {
return time.Now().UTC().Format("060102")
}
func TestMintAndValidate(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
func TestReplayDetection(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("first use failed: %v", err)
}
err = validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
}
func TestResourceMismatch(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("correct-resource")
stamp := mintStampWithDate(
t, testBits, "wrong-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected resource mismatch error")
}
}
func TestInvalidStampFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate(
"not:a:valid:stamp", testBits,
)
if err == nil {
t.Fatal("expected error for bad format")
}
}
func TestBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"2:%d:%s:%s::abc123",
testBits, todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestInsufficientDifficulty(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
// Claimed bits=1, but we require testBits=2.
stamp := fmt.Sprintf(
"1:1:%s:%s::counter",
todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
oldDate := time.Now().Add(-72 * time.Hour).
UTC().Format("060102")
stamp := mintStampWithDate(
t, testBits, "test-resource", oldDate,
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestZeroBitsSkipsValidation(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestLongDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
longDate := time.Now().UTC().Format("060102150405")
stamp := mintStampWithDate(
t, testBits, "test-resource", longDate,
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("long date stamp rejected: %v", err)
}
}
func TestBadDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"1:%d:BADDATE:%s::counter",
testBits, "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad date error")
}
}
func TestMultipleUniqueStamps(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
for range 5 {
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("unique stamp rejected: %v", err)
}
}
}
func TestHigherBitsStillValid(t *testing.T) {
t.Parallel()
// Mint with bits=4 but validate requiring only 2.
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, 4, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf(
"higher-difficulty stamp rejected: %v",
err,
)
}
}

View File

@@ -6,11 +6,10 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -22,7 +21,6 @@ type Params struct {
Config *config.Config Config *config.Config
Logger *logger.Logger Logger *logger.Logger
Database *db.Database Database *db.Database
Stats *stats.Tracker
} }
// Healthcheck tracks server uptime and provides health status. // Healthcheck tracks server uptime and provides health status.
@@ -35,17 +33,14 @@ type Healthcheck struct {
} }
// New creates a new Healthcheck instance. // New creates a new Healthcheck instance.
func New( func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
lifecycle fx.Lifecycle, params Params, s := new(Healthcheck)
) (*Healthcheck, error) { s.params = &params
hcheck := &Healthcheck{ //nolint:exhaustruct // StartupTime set in OnStart s.log = params.Logger.Get()
params: &params,
log: params.Logger.Get(),
}
lifecycle.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
hcheck.StartupTime = time.Now() s.StartupTime = time.Now()
return nil return nil
}, },
@@ -54,7 +49,7 @@ func New(
}, },
}) })
return hcheck, nil return s, nil
} }
// Response is the JSON response returned by the health endpoint. // Response is the JSON response returned by the health endpoint.
@@ -66,90 +61,22 @@ type Response struct {
Version string `json:"version"` Version string `json:"version"`
Appname string `json:"appname"` Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"` Maintenance bool `json:"maintenanceMode"`
// Runtime statistics.
Sessions int64 `json:"sessions"`
Clients int64 `json:"clients"`
QueuedLines int64 `json:"queuedLines"`
Channels int64 `json:"channels"`
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
} }
// Healthcheck returns the current health status of the server. // Healthcheck returns the current health status of the server.
func (hcheck *Healthcheck) Healthcheck( func (s *Healthcheck) Healthcheck() *Response {
ctx context.Context,
) *Response {
resp := &Response{ resp := &Response{
Status: "ok", Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano), Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()), UptimeSeconds: int64(s.uptime().Seconds()),
UptimeHuman: hcheck.uptime().String(), UptimeHuman: s.uptime().String(),
Appname: hcheck.params.Globals.Appname, Appname: s.params.Globals.Appname,
Version: hcheck.params.Globals.Version, Version: s.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode,
Sessions: 0,
Clients: 0,
QueuedLines: 0,
Channels: 0,
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
} }
hcheck.populateDBStats(ctx, resp)
return resp return resp
} }
// populateDBStats fills in database-derived counters. func (s *Healthcheck) uptime() time.Duration {
func (hcheck *Healthcheck) populateDBStats( return time.Since(s.StartupTime)
ctx context.Context,
resp *Response,
) {
sessions, err := hcheck.params.Database.GetUserCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: session count failed",
"error", err,
)
} else {
resp.Sessions = sessions
}
clients, err := hcheck.params.Database.GetClientCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: client count failed",
"error", err,
)
} else {
resp.Clients = clients
}
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: queue entry count failed",
"error", err,
)
} else {
resp.QueuedLines = queued
}
channels, err := hcheck.params.Database.GetChannelCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: channel count failed",
"error", err,
)
} else {
resp.Channels = channels
}
}
func (hcheck *Healthcheck) uptime() time.Duration {
return time.Since(hcheck.StartupTime)
} }

View File

@@ -5,7 +5,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -23,56 +23,51 @@ type Logger struct {
params Params params Params
} }
// New creates a new Logger with appropriate handler // New creates a new Logger with appropriate handler based on terminal detection.
// based on terminal detection. func New(_ fx.Lifecycle, params Params) (*Logger, error) {
func New( l := new(Logger)
_ fx.Lifecycle, params Params, l.level = new(slog.LevelVar)
) (*Logger, error) { l.level.Set(slog.LevelInfo)
logger := new(Logger)
logger.level = new(slog.LevelVar)
logger.level.Set(slog.LevelInfo)
tty := false tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true tty = true
} }
opts := &slog.HandlerOptions{ //nolint:exhaustruct // ReplaceAttr optional
Level: logger.level,
AddSource: true,
}
var handler slog.Handler var handler slog.Handler
if tty { if tty {
handler = slog.NewTextHandler(os.Stdout, opts) handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: l.level,
AddSource: true,
})
} else { } else {
handler = slog.NewJSONHandler(os.Stdout, opts) handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: l.level,
AddSource: true,
})
} }
logger.log = slog.New(handler) l.log = slog.New(handler)
logger.params = params l.params = params
return logger, nil return l, nil
} }
// EnableDebugLogging switches the log level to debug. // EnableDebugLogging switches the log level to debug.
func (logger *Logger) EnableDebugLogging() { func (l *Logger) EnableDebugLogging() {
logger.level.Set(slog.LevelDebug) l.level.Set(slog.LevelDebug)
logger.log.Debug( l.log.Debug("debug logging enabled", "debug", true)
"debug logging enabled", "debug", true,
)
} }
// Get returns the underlying slog.Logger. // Get returns the underlying slog.Logger.
func (logger *Logger) Get() *slog.Logger { func (l *Logger) Get() *slog.Logger {
return logger.log return l.log
} }
// Identify logs the application name and version at startup. // Identify logs the application name and version at startup.
func (logger *Logger) Identify() { func (l *Logger) Identify() {
logger.log.Info("starting", l.log.Info("starting",
"appname", logger.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", logger.params.Globals.Version, "version", l.params.Globals.Version,
) )
} }

View File

@@ -1,4 +1,4 @@
// Package middleware provides HTTP middleware for the neoirc server. // Package middleware provides HTTP middleware for the chat server.
package middleware package middleware
import ( import (
@@ -7,11 +7,11 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
@@ -38,28 +38,25 @@ type Middleware struct {
} }
// New creates a new Middleware instance. // New creates a new Middleware instance.
func New( func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
_ fx.Lifecycle, params Params, s := new(Middleware)
) (*Middleware, error) { s.params = &params
mware := &Middleware{ s.log = params.Logger.Get()
params: &params,
log: params.Logger.Get(),
}
return mware, nil return s, nil
} }
func ipFromHostPort(hostPort string) string { func ipFromHostPort(hp string) string {
host, _, err := net.SplitHostPort(hostPort) h, _, err := net.SplitHostPort(hp)
if err != nil { if err != nil {
return "" return ""
} }
if len(host) > 0 && host[0] == '[' { if len(h) > 0 && h[0] == '[' {
return host[1 : len(host)-1] return h[1 : len(h)-1]
} }
return host return h
} }
type loggingResponseWriter struct { type loggingResponseWriter struct {
@@ -68,15 +65,9 @@ type loggingResponseWriter struct {
statusCode int statusCode int
} }
// newLoggingResponseWriter wraps a ResponseWriter // newLoggingResponseWriter wraps a ResponseWriter to capture the status code.
// to capture the status code. func newLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
func newLoggingResponseWriter( return &loggingResponseWriter{w, http.StatusOK}
writer http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
} }
func (lrw *loggingResponseWriter) WriteHeader(code int) { func (lrw *loggingResponseWriter) WriteHeader(code int) {
@@ -85,83 +76,72 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
} }
// Logging returns middleware that logs each HTTP request. // Logging returns middleware that logs each HTTP request.
func (mware *Middleware) Logging() func(http.Handler) http.Handler { func (s *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
func(
writer http.ResponseWriter,
request *http.Request,
) {
start := time.Now() start := time.Now()
lrw := newLoggingResponseWriter(writer) lrw := newLoggingResponseWriter(w)
ctx := request.Context() ctx := r.Context()
defer func() { defer func() {
latency := time.Since(start) latency := time.Since(start)
reqID, _ := ctx.Value( reqID, _ := ctx.Value(middleware.RequestIDKey).(string)
chimw.RequestIDKey,
).(string)
mware.log.InfoContext( s.log.InfoContext(ctx, "request",
ctx, "request",
"request_start", start, "request_start", start,
"method", request.Method, "method", r.Method,
"url", request.URL.String(), "url", r.URL.String(),
"useragent", request.UserAgent(), "useragent", r.UserAgent(),
"request_id", reqID, "request_id", reqID,
"referer", request.Referer(), "referer", r.Referer(),
"proto", request.Proto, "proto", r.Proto,
"remoteIP", "remoteIP", ipFromHostPort(r.RemoteAddr),
ipFromHostPort(request.RemoteAddr),
"status", lrw.statusCode, "status", lrw.statusCode,
"latency_ms", "latency_ms", latency.Milliseconds(),
latency.Milliseconds(),
) )
}() }()
next.ServeHTTP(lrw, request) next.ServeHTTP(lrw, r)
}) })
} }
} }
// CORS returns middleware that handles Cross-Origin Resource Sharing. // CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in func (s *Middleware) CORS() func(http.Handler) http.Handler {
// cross-origin API requests. return cors.Handler(cors.Options{
func (mware *Middleware) CORS() func(http.Handler) http.Handler { AllowedOrigins: []string{"*"},
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowOriginFunc: func( AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
_ *http.Request, _ string,
) bool {
return true
},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
}) })
} }
// Auth returns middleware that performs authentication.
func (s *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.log.Info("AUTH: before request")
next.ServeHTTP(w, r)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (s *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder( Recorder: metrics.NewRecorder(metrics.Config{}),
metrics.Config{}, //nolint:exhaustruct // defaults
),
}) })
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return std.Handler("", metricsMiddleware, next) return std.Handler("", mdlw, next)
} }
} }
// MetricsAuth returns middleware that protects metrics with basic auth. // MetricsAuth returns middleware that protects metrics with basic auth.
func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler { func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New( return basicauth.New(
"metrics", "metrics",
map[string][]string{ map[string][]string{
@@ -171,36 +151,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
}, },
) )
} }
// cspPolicy is the Content-Security-Policy header value applied to all
// responses. The embedded SPA loads scripts and styles from same-origin
// files only (no inline scripts or inline style attributes), so a strict
// policy works without 'unsafe-inline'.
const cspPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"connect-src 'self'; " +
"img-src 'self'; " +
"font-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// CSP returns middleware that sets the Content-Security-Policy header on
// every response for defense-in-depth against XSS.
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspPolicy,
)
next.ServeHTTP(writer, request)
})
}
}

View File

@@ -1,122 +0,0 @@
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
const (
// DefaultRate is the default number of allowed requests per second.
DefaultRate = 1.0
// DefaultBurst is the default maximum burst size.
DefaultBurst = 5
// DefaultSweepInterval controls how often stale entries are pruned.
DefaultSweepInterval = 10 * time.Minute
// DefaultEntryTTL is how long an unused entry lives before eviction.
DefaultEntryTTL = 15 * time.Minute
)
// entry tracks a per-IP rate limiter and when it was last used.
type entry struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Limiter manages per-key rate limiters with automatic cleanup
// of stale entries.
type Limiter struct {
mu sync.Mutex
entries map[string]*entry
rate rate.Limit
burst int
entryTTL time.Duration
stopCh chan struct{}
}
// New creates a new per-key rate Limiter.
// The ratePerSec parameter sets how many requests per second are
// allowed per key. The burst parameter sets the maximum number of
// requests that can be made in a single burst.
func New(ratePerSec float64, burst int) *Limiter {
limiter := &Limiter{
mu: sync.Mutex{},
entries: make(map[string]*entry),
rate: rate.Limit(ratePerSec),
burst: burst,
entryTTL: DefaultEntryTTL,
stopCh: make(chan struct{}),
}
go limiter.sweepLoop()
return limiter
}
// Allow reports whether a request from the given key should be
// allowed. It consumes one token from the key's rate limiter.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
ent, exists := l.entries[key]
if !exists {
ent = &entry{
limiter: rate.NewLimiter(l.rate, l.burst),
lastSeen: time.Now(),
}
l.entries[key] = ent
} else {
ent.lastSeen = time.Now()
}
l.mu.Unlock()
return ent.limiter.Allow()
}
// Stop terminates the background sweep goroutine.
func (l *Limiter) Stop() {
close(l.stopCh)
}
// Len returns the number of tracked keys (for testing).
func (l *Limiter) Len() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.entries)
}
// sweepLoop periodically removes entries that haven't been seen
// within the TTL.
func (l *Limiter) sweepLoop() {
ticker := time.NewTicker(DefaultSweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.sweep()
case <-l.stopCh:
return
}
}
}
// sweep removes stale entries.
func (l *Limiter) sweep() {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-l.entryTTL)
for key, ent := range l.entries {
if ent.lastSeen.Before(cutoff) {
delete(l.entries, key)
}
}
}

View File

@@ -1,106 +0,0 @@
package ratelimit_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
)
func TestNewCreatesLimiter(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter == nil {
t.Fatal("expected non-nil limiter")
}
}
func TestAllowWithinBurst(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 3)
defer limiter.Stop()
for i := range 3 {
if !limiter.Allow("192.168.1.1") {
t.Fatalf(
"request %d should be allowed within burst",
i+1,
)
}
}
}
func TestAllowExceedsBurst(t *testing.T) {
t.Parallel()
// Rate of 0 means no token replenishment, only burst.
limiter := ratelimit.New(0, 3)
defer limiter.Stop()
for range 3 {
limiter.Allow("10.0.0.1")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("fourth request should be denied after burst exhausted")
}
}
func TestAllowSeparateKeys(t *testing.T) {
t.Parallel()
// Rate of 0, burst of 1 — only one request per key.
limiter := ratelimit.New(0, 1)
defer limiter.Stop()
if !limiter.Allow("10.0.0.1") {
t.Fatal("first request for key A should be allowed")
}
if !limiter.Allow("10.0.0.2") {
t.Fatal("first request for key B should be allowed")
}
if limiter.Allow("10.0.0.1") {
t.Fatal("second request for key A should be denied")
}
if limiter.Allow("10.0.0.2") {
t.Fatal("second request for key B should be denied")
}
}
func TestLenTracksKeys(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
defer limiter.Stop()
if limiter.Len() != 0 {
t.Fatalf("expected 0 entries, got %d", limiter.Len())
}
limiter.Allow("10.0.0.1")
limiter.Allow("10.0.0.2")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
// Same key again should not increase count.
limiter.Allow("10.0.0.1")
if limiter.Len() != 2 {
t.Fatalf("expected 2 entries, got %d", limiter.Len())
}
}
func TestStopDoesNotPanic(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(1.0, 5)
limiter.Stop()
}

View File

@@ -5,11 +5,11 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/neoirc/web" "git.eeqj.de/sneak/chat/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -17,106 +17,67 @@ import (
const routeTimeout = 60 * time.Second const routeTimeout = 60 * time.Second
// SetupRoutes configures the HTTP routes and middleware. // SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
srv.router = chi.NewRouter() s.router = chi.NewRouter()
srv.router.Use(middleware.Recoverer) s.router.Use(middleware.Recoverer)
srv.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
srv.router.Use(srv.mw.Logging()) s.router.Use(s.mw.Logging())
if viper.GetString("METRICS_USERNAME") != "" { if viper.GetString("METRICS_USERNAME") != "" {
srv.router.Use(srv.mw.Metrics()) s.router.Use(s.mw.Metrics())
} }
srv.router.Use(srv.mw.CORS()) s.router.Use(s.mw.CORS())
srv.router.Use(srv.mw.CSP()) s.router.Use(middleware.Timeout(routeTimeout))
srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled { if s.sentryEnabled {
sentryHandler := sentryhttp.New( sentryHandler := sentryhttp.New(sentryhttp.Options{
sentryhttp.Options{ //nolint:exhaustruct // optional fields
Repanic: true, Repanic: true,
}, })
) s.router.Use(sentryHandler.Handle)
srv.router.Use(sentryHandler.Handle)
} }
// Health check. // Health check
srv.router.Get( s.router.Get(
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
srv.handlers.HandleHealthCheck(), s.h.HandleHealthCheck(),
) )
// Protected metrics endpoint. // Protected metrics endpoint
if viper.GetString("METRICS_USERNAME") != "" { if viper.GetString("METRICS_USERNAME") != "" {
srv.router.Group(func(router chi.Router) { s.router.Group(func(r chi.Router) {
router.Use(srv.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
router.Get("/metrics", r.Get("/metrics",
http.HandlerFunc( http.HandlerFunc(
promhttp.Handler().ServeHTTP, promhttp.Handler().ServeHTTP,
)) ))
}) })
} }
// API v1. // API v1
srv.router.Route("/api/v1", srv.setupAPIv1) s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/server", s.h.HandleServerInfo())
// Serve embedded SPA. r.Post("/session", s.h.HandleCreateSession())
srv.setupSPA() r.Get("/state", s.h.HandleState())
} r.Get("/messages", s.h.HandleGetMessages())
r.Post("/messages", s.h.HandleSendCommand())
func (srv *Server) setupAPIv1(router chi.Router) { r.Get("/history", s.h.HandleGetHistory())
router.Get( r.Get("/channels", s.h.HandleListAllChannels())
"/server", r.Get(
srv.handlers.HandleServerInfo(),
)
router.Post(
"/session",
srv.handlers.HandleCreateSession(),
)
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", "/channels/{channel}/members",
srv.handlers.HandleChannelMembers(), s.h.HandleChannelMembers(),
) )
})
// Serve embedded SPA
s.setupSPA()
} }
func (srv *Server) setupSPA() { func (s *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist") distFS, err := fs.Sub(web.Dist, "dist")
if err != nil { if err != nil {
srv.log.Error( s.log.Error(
"failed to get web dist filesystem", "failed to get web dist filesystem",
"error", err, "error", err,
) )
@@ -126,40 +87,38 @@ func (srv *Server) setupSPA() {
fileServer := http.FileServer(http.FS(distFS)) fileServer := http.FileServer(http.FS(distFS))
srv.router.Get("/*", func( s.router.Get("/*", func(
writer http.ResponseWriter, w http.ResponseWriter,
request *http.Request, r *http.Request,
) { ) {
readFS, ok := distFS.(fs.ReadFileFS) readFS, ok := distFS.(fs.ReadFileFS)
if !ok { if !ok {
fileServer.ServeHTTP(writer, request) fileServer.ServeHTTP(w, r)
return return
} }
fileData, readErr := readFS.ReadFile( f, readErr := readFS.ReadFile(r.URL.Path[1:])
request.URL.Path[1:], if readErr != nil || len(f) == 0 {
)
if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile( indexHTML, indexErr := readFS.ReadFile(
"index.html", "index.html",
) )
if indexErr != nil { if indexErr != nil {
http.NotFound(writer, request) http.NotFound(w, r)
return return
} }
writer.Header().Set( w.Header().Set(
"Content-Type", "Content-Type",
"text/html; charset=utf-8", "text/html; charset=utf-8",
) )
writer.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = writer.Write(indexHTML) _, _ = w.Write(indexHTML)
return return
} }
fileServer.ServeHTTP(writer, request) fileServer.ServeHTTP(w, r)
}) })
} }

View File

@@ -1,4 +1,4 @@
// Package server implements the main HTTP server for the neoirc application. // Package server implements the main HTTP server for the chat application.
package server package server
import ( import (
@@ -12,15 +12,15 @@ import (
"syscall" "syscall"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers" "git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/chat/internal/middleware"
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )
@@ -41,8 +41,7 @@ type Params struct {
Handlers *handlers.Handlers Handlers *handlers.Handlers
} }
// Server is the main HTTP server. // Server is the main HTTP server. It manages routing, middleware, and lifecycle.
// It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int exitCode int
@@ -54,24 +53,21 @@ type Server struct {
router *chi.Mux router *chi.Mux
params Params params Params
mw *middleware.Middleware mw *middleware.Middleware
handlers *handlers.Handlers h *handlers.Handlers
} }
// New creates a new Server and registers its lifecycle hooks. // New creates a new Server and registers its lifecycle hooks.
func New( func New(lc fx.Lifecycle, params Params) (*Server, error) {
lifecycle fx.Lifecycle, params Params, s := new(Server)
) (*Server, error) { s.params = params
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle s.mw = params.Middleware
params: params, s.h = params.Handlers
mw: params.Middleware, s.log = params.Logger.Get()
handlers: params.Handlers,
log: params.Logger.Get(),
}
lifecycle.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
srv.startupTime = time.Now() s.startupTime = time.Now()
go srv.Run() //nolint:contextcheck go s.Run() //nolint:contextcheck
return nil return nil
}, },
@@ -80,140 +76,122 @@ func New(
}, },
}) })
return srv, nil return s, nil
} }
// Run starts the server configuration, Sentry, and begins serving. // Run starts the server configuration, Sentry, and begins serving.
func (srv *Server) Run() { func (s *Server) Run() {
srv.configure() s.configure()
srv.enableSentry() s.enableSentry()
srv.serve() s.serve()
} }
// ServeHTTP delegates to the chi router. // ServeHTTP delegates to the chi router.
func (srv *Server) ServeHTTP( func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writer http.ResponseWriter, s.router.ServeHTTP(w, r)
request *http.Request,
) {
srv.router.ServeHTTP(writer, request)
} }
// MaintenanceMode reports whether the server is in maintenance mode. // MaintenanceMode reports whether the server is in maintenance mode.
func (srv *Server) MaintenanceMode() bool { func (s *Server) MaintenanceMode() bool {
return srv.params.Config.MaintenanceMode return s.params.Config.MaintenanceMode
} }
func (srv *Server) enableSentry() { func (s *Server) enableSentry() {
srv.sentryEnabled = false s.sentryEnabled = false
if srv.params.Config.SentryDSN == "" { if s.params.Config.SentryDSN == "" {
return return
} }
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields err := sentry.Init(sentry.ClientOptions{
Dsn: srv.params.Config.SentryDSN, Dsn: s.params.Config.SentryDSN,
Release: fmt.Sprintf( Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
"%s-%s",
srv.params.Globals.Appname,
srv.params.Globals.Version,
),
}) })
if err != nil { if err != nil {
srv.log.Error("sentry init failure", "error", err) s.log.Error("sentry init failure", "error", err)
os.Exit(1) os.Exit(1)
} }
srv.log.Info("sentry error reporting activated") s.log.Info("sentry error reporting activated")
srv.sentryEnabled = true s.sentryEnabled = true
} }
func (srv *Server) serve() int { func (s *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel( s.ctx, s.cancelFunc = context.WithCancel(context.Background())
context.Background(),
)
go func() { go func() {
sigCh := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE) signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
sig := <-sigCh if s.cancelFunc != nil {
s.cancelFunc()
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
} }
}() }()
go srv.serveUntilShutdown() go s.serveUntilShutdown()
<-srv.ctx.Done() <-s.ctx.Done()
srv.cleanShutdown() s.cleanShutdown()
return srv.exitCode return s.exitCode
} }
func (srv *Server) cleanupForExit() { func (s *Server) cleanupForExit() {
srv.log.Info("cleaning up") s.log.Info("cleaning up")
} }
func (srv *Server) cleanShutdown() { func (s *Server) cleanShutdown() {
srv.exitCode = 0 s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout, context.Background(), shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := s.httpServer.Shutdown(ctxShutdown)
if err != nil { if err != nil {
srv.log.Error( s.log.Error("server clean shutdown failed", "error", err)
"server clean shutdown failed", "error", err,
)
} }
if shutdownCancel != nil { if shutdownCancel != nil {
shutdownCancel() shutdownCancel()
} }
srv.cleanupForExit() s.cleanupForExit()
if srv.sentryEnabled { if s.sentryEnabled {
sentry.Flush(sentryFlushTime) sentry.Flush(sentryFlushTime)
} }
} }
func (srv *Server) configure() { func (s *Server) configure() {
// Server configuration placeholder. // server configuration placeholder
} }
func (srv *Server) serveUntilShutdown() { func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
":%d", srv.params.Config.Port, s.httpServer = &http.Server{
)
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout, WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
Handler: srv, Handler: s,
} }
srv.SetupRoutes() s.SetupRoutes()
srv.log.Info( s.log.Info("http begin listen", "listenaddr", listenAddr)
"http begin listen", "listenaddr", listenAddr,
)
err := srv.httpServer.ListenAndServe() err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
if srv.cancelFunc != nil { if s.cancelFunc != nil {
srv.cancelFunc() s.cancelFunc()
} }
} }
} }

View File

@@ -1,52 +0,0 @@
// Package stats tracks runtime statistics since server boot.
package stats
import (
"sync/atomic"
)
// Tracker holds atomic counters for runtime statistics
// that accumulate since the server started.
type Tracker struct {
connectionsSinceBoot atomic.Int64
sessionsSinceBoot atomic.Int64
messagesSinceBoot atomic.Int64
}
// New creates a new Tracker with all counters at zero.
func New() *Tracker {
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
}
// IncrConnections increments the total connection count.
func (t *Tracker) IncrConnections() {
t.connectionsSinceBoot.Add(1)
}
// IncrSessions increments the total session count.
func (t *Tracker) IncrSessions() {
t.sessionsSinceBoot.Add(1)
}
// IncrMessages increments the total PRIVMSG/NOTICE count.
func (t *Tracker) IncrMessages() {
t.messagesSinceBoot.Add(1)
}
// ConnectionsSinceBoot returns the total number of
// client connections since boot.
func (t *Tracker) ConnectionsSinceBoot() int64 {
return t.connectionsSinceBoot.Load()
}
// SessionsSinceBoot returns the total number of sessions
// created since boot.
func (t *Tracker) SessionsSinceBoot() int64 {
return t.sessionsSinceBoot.Load()
}
// MessagesSinceBoot returns the total number of
// PRIVMSG/NOTICE messages sent since boot.
func (t *Tracker) MessagesSinceBoot() int64 {
return t.messagesSinceBoot.Load()
}

View File

@@ -1,117 +0,0 @@
package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
)
func TestNew(t *testing.T) {
t.Parallel()
tracker := stats.New()
if tracker == nil {
t.Fatal("expected non-nil tracker")
}
if tracker.ConnectionsSinceBoot() != 0 {
t.Errorf(
"expected 0 connections, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 0 {
t.Errorf(
"expected 0 sessions, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 0 {
t.Errorf(
"expected 0 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}
func TestIncrConnections(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrConnections()
tracker.IncrConnections()
got := tracker.ConnectionsSinceBoot()
if got != 3 {
t.Errorf(
"expected 3 connections, got %d", got,
)
}
}
func TestIncrSessions(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrSessions()
tracker.IncrSessions()
got := tracker.SessionsSinceBoot()
if got != 2 {
t.Errorf(
"expected 2 sessions, got %d", got,
)
}
}
func TestIncrMessages(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrMessages()
got := tracker.MessagesSinceBoot()
if got != 1 {
t.Errorf(
"expected 1 message, got %d", got,
)
}
}
func TestCountersAreIndependent(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrSessions()
tracker.IncrMessages()
tracker.IncrMessages()
if tracker.ConnectionsSinceBoot() != 1 {
t.Errorf(
"expected 1 connection, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 1 {
t.Errorf(
"expected 1 session, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 2 {
t.Errorf(
"expected 2 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}

View File

@@ -1,33 +0,0 @@
package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAdmin = "ADMIN"
CmdAway = "AWAY"
CmdInfo = "INFO"
CmdInvite = "INVITE"
CmdJoin = "JOIN"
CmdKick = "KICK"
CmdKill = "KILL"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdOper = "OPER"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTime = "TIME"
CmdTopic = "TOPIC"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)

View File

@@ -1,393 +0,0 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
import (
"errors"
"fmt"
)
// IRCMessageType represents an IRC numeric reply or error code.
type IRCMessageType int //nolint:revive // Name requested by project owner.
// Name returns the standard IRC name for this numeric code
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
// Returns an empty string if the code is unknown.
func (t IRCMessageType) Name() string {
return names[t]
}
// String returns the name and numeric code in angle brackets
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
// If the code is unknown, returns "UNKNOWN <NNN>".
func (t IRCMessageType) String() string {
n := names[t]
if n == "" {
n = "UNKNOWN"
}
return fmt.Sprintf("%s <%03d>", n, int(t))
}
// Code returns the three-digit zero-padded string representation
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
func (t IRCMessageType) Code() string {
return fmt.Sprintf("%03d", int(t))
}
// Int returns the bare integer value of the numeric code.
func (t IRCMessageType) Int() int {
return int(t)
}
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
// FromInt converts an integer to an IRCMessageType, returning an error
// if the numeric code is not a known IRC reply or error code.
func FromInt(n int) (IRCMessageType, error) {
t := IRCMessageType(n)
if _, ok := names[t]; !ok {
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
}
return t, nil
}
// Connection registration replies (001-005).
const (
RplWelcome IRCMessageType = 1
RplYourHost IRCMessageType = 2
RplCreated IRCMessageType = 3
RplMyInfo IRCMessageType = 4
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
)
// Command responses (200-399).
const (
// RFC 2812 trace/stats/links replies (200-219).
RplTraceLink IRCMessageType = 200
RplTraceConnecting IRCMessageType = 201
RplTraceHandshake IRCMessageType = 202
RplTraceUnknown IRCMessageType = 203
RplTraceOperator IRCMessageType = 204
RplTraceUser IRCMessageType = 205
RplTraceServer IRCMessageType = 206
RplTraceService IRCMessageType = 207
RplTraceNewType IRCMessageType = 208
RplTraceClass IRCMessageType = 209
RplStatsLinkInfo IRCMessageType = 211
RplStatsCommands IRCMessageType = 212
RplStatsCLine IRCMessageType = 213
RplStatsNLine IRCMessageType = 214
RplStatsILine IRCMessageType = 215
RplStatsKLine IRCMessageType = 216
RplStatsQLine IRCMessageType = 217
RplStatsYLine IRCMessageType = 218
RplEndOfStats IRCMessageType = 219
RplUmodeIs IRCMessageType = 221
RplServList IRCMessageType = 234
RplServListEnd IRCMessageType = 235
RplStatsLLine IRCMessageType = 241
RplStatsUptime IRCMessageType = 242
RplStatsOLine IRCMessageType = 243
RplStatsHLine IRCMessageType = 244
RplLuserClient IRCMessageType = 251
RplLuserOp IRCMessageType = 252
RplLuserUnknown IRCMessageType = 253
RplLuserChannels IRCMessageType = 254
RplLuserMe IRCMessageType = 255
RplAdminMe IRCMessageType = 256
RplAdminLoc1 IRCMessageType = 257
RplAdminLoc2 IRCMessageType = 258
RplAdminEmail IRCMessageType = 259
RplTraceLog IRCMessageType = 261
RplTraceEnd IRCMessageType = 262
RplTryAgain IRCMessageType = 263
RplAway IRCMessageType = 301
RplUserHost IRCMessageType = 302
RplIson IRCMessageType = 303
RplUnaway IRCMessageType = 305
RplNowAway IRCMessageType = 306
RplWhoisUser IRCMessageType = 311
RplWhoisServer IRCMessageType = 312
RplWhoisOperator IRCMessageType = 313
RplWhoWasUser IRCMessageType = 314
RplEndOfWho IRCMessageType = 315
RplWhoisIdle IRCMessageType = 317
RplEndOfWhois IRCMessageType = 318
RplWhoisChannels IRCMessageType = 319
RplListStart IRCMessageType = 321
RplList IRCMessageType = 322
RplListEnd IRCMessageType = 323
RplChannelModeIs IRCMessageType = 324
RplUniqOpIs IRCMessageType = 325
RplCreationTime IRCMessageType = 329
RplNoTopic IRCMessageType = 331
RplTopic IRCMessageType = 332
RplTopicWhoTime IRCMessageType = 333
RplWhoisActually IRCMessageType = 338
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",
RplWhoisActually: "RPL_WHOISACTUALLY",
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]
}

View File

@@ -1,163 +0,0 @@
package irc_test
import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
func TestName(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME"},
{irc.RplBounce, "RPL_BOUNCE"},
{irc.RplLuserOp, "RPL_LUSEROP"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
}
for _, tc := range tests {
if got := tc.numeric.Name(); got != tc.want {
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestString(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME <001>"},
{irc.RplBounce, "RPL_BOUNCE <005>"},
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
}
for _, tc := range tests {
if got := tc.numeric.String(); got != tc.want {
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestCode(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "001"},
{irc.RplBounce, "005"},
{irc.RplLuserOp, "252"},
{irc.ErrCannotSendToChan, "404"},
}
for _, tc := range tests {
if got := tc.numeric.Code(); got != tc.want {
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestInt(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want int
}{
{irc.RplWelcome, 1},
{irc.RplBounce, 5},
{irc.RplLuserOp, 252},
{irc.ErrCannotSendToChan, 404},
}
for _, tc := range tests {
if got := tc.numeric.Int(); got != tc.want {
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
}
}
}
func TestFromInt_Known(t *testing.T) {
t.Parallel()
tests := []struct {
code int
want irc.IRCMessageType
}{
{1, irc.RplWelcome},
{5, irc.RplBounce},
{252, irc.RplLuserOp},
{404, irc.ErrCannotSendToChan},
{433, irc.ErrNicknameInUse},
}
for _, test := range tests {
got, err := irc.FromInt(test.code)
if err != nil {
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
continue
}
if got != test.want {
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
}
}
}
func TestFromInt_Unknown(t *testing.T) {
t.Parallel()
unknowns := []int{0, 999, 600, -1}
for _, code := range unknowns {
_, err := irc.FromInt(code)
if err == nil {
t.Errorf("FromInt(%d) expected error, got nil", code)
continue
}
if !errors.Is(err, irc.ErrUnknownNumeric) {
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
}
}
}
func TestUnknownNumeric_Name(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
if got := unknown.Name(); got != "" {
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
}
}
func TestUnknownNumeric_String(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
want := "UNKNOWN <999>"
if got := unknown.String(); got != want {
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
}
}
func TestDeprecatedNameFunc(t *testing.T) {
t.Parallel()
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
}
}

View File

@@ -1,6 +1,6 @@
# Message Schemas # Message Schemas
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON **IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
over HTTP. over HTTP.

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
"title": "JOIN", "title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
"title": "KICK", "title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.", "description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
"title": "MODE", "title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.", "description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
"title": "NICK", "title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
"title": "NOTICE", "title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
"title": "PART", "title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
"title": "PING", "title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
"title": "PONG", "title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
"title": "PRIVMSG", "title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
"title": "PUBKEY", "title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.", "description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
"title": "QUIT", "title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.", "description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json", "$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
"title": "TOPIC", "title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.", "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -17,6 +17,6 @@
}, },
"required": ["command", "to"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] } { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json", "$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
"title": "IRC Message Envelope", "title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object", "type": "object",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
"title": "001 RPL_WELCOME", "title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
"title": "002 RPL_YOURHOST", "title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -29,7 +29,7 @@
"command": "002", "command": "002",
"to": "alice", "to": "alice",
"body": [ "body": [
"Your host is neoirc.example.com, running version 0.1.0" "Your host is chat.example.com, running version 0.1.0"
] ]
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
"title": "003 RPL_CREATED", "title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.", "description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
"title": "004 RPL_MYINFO", "title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -29,7 +29,7 @@
"command": "004", "command": "004",
"to": "alice", "to": "alice",
"params": [ "params": [
"neoirc.example.com", "chat.example.com",
"0.1.0", "0.1.0",
"o", "o",
"imnst+ov" "imnst+ov"

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
"title": "322 RPL_LIST", "title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
"title": "323 RPL_LISTEND", "title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.", "description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
"title": "332 RPL_TOPIC", "title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
@@ -40,7 +40,7 @@
"#general" "#general"
], ],
"body": [ "body": [
"Welcome to the channel" "Welcome to the chat"
] ]
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY", "title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES", "title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.", "description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
"title": "372 RPL_MOTD", "title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART", "title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.", "description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD", "title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.", "description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK", "title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.", "description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.", "description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE", "title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.", "description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL", "title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.", "description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.", "description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -16,11 +16,19 @@ fi
mkdir -p dist mkdir -p dist
# Build JS bundle — preact must be bundled (no CDN/external loader) # Build JS bundle
${NPX:+$NPX} esbuild src/app.jsx \
--bundle \
--minify \
--jsx-factory=h \
--jsx-fragment=Fragment \
--define:process.env.NODE_ENV=\"production\" \
--external:preact \
--outfile=dist/app.js \
2>/dev/null || \
${NPX:+$NPX} esbuild src/app.jsx \ ${NPX:+$NPX} esbuild src/app.jsx \
--bundle \ --bundle \
--minify \ --minify \
--format=esm \
--jsx-factory=h \ --jsx-factory=h \
--jsx-fragment=Fragment \ --jsx-fragment=Fragment \
--define:process.env.NODE_ENV=\"production\" \ --define:process.env.NODE_ENV=\"production\" \

464
web/dist/app.js vendored Normal file
View File

@@ -0,0 +1,464 @@
(()=>{
// 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 Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>

274
web/dist/style.css vendored Normal file
View File

@@ -0,0 +1,274 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--bg-secondary: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-muted: #888;
--accent: #e94560;
--accent2: #0f3460;
--border: #2a2a4a;
--nick: #53a8b6;
--timestamp: #666;
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
}
html, body, #root {
height: 100%;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
background: var(--bg);
color: var(--text);
}
/* Login screen */
.login-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.login-screen h1 {
color: var(--accent);
font-size: 2em;
}
.login-screen input {
padding: 10px 16px;
font-size: 16px;
font-family: inherit;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
width: 280px;
}
.login-screen button {
padding: 10px 24px;
font-size: 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
.login-screen .error {
color: var(--accent);
}
.login-screen .motd {
color: var(--text-muted);
max-width: 400px;
text-align: center;
white-space: pre-wrap;
}
/* Main layout */
.app {
display: flex;
flex-direction: column;
height: 100%;
}
/* Tab bar */
.tab-bar {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
}
.tab {
padding: 8px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
color: var(--text-muted);
user-select: none;
}
.tab:hover {
background: var(--tab-hover);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--tab-active);
}
.tab .close-btn {
margin-left: 8px;
color: var(--text-muted);
font-size: 12px;
}
.tab .close-btn:hover {
color: var(--accent);
}
/* Content area */
.content {
display: flex;
flex: 1;
overflow: hidden;
}
/* Messages */
.messages-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
}
.message {
padding: 2px 0;
line-height: 1.4;
word-wrap: break-word;
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
margin-right: 8px;
}
.message .nick {
color: var(--nick);
font-weight: bold;
margin-right: 8px;
}
.message .nick::before { content: '<'; }
.message .nick::after { content: '>'; }
.message.system {
color: var(--text-muted);
font-style: italic;
}
.message.system .nick {
color: var(--text-muted);
}
.message.system .nick::before,
.message.system .nick::after { content: ''; }
/* Input */
.input-bar {
display: flex;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.input-bar input {
flex: 1;
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
}
/* User list */
.user-list {
width: 160px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
flex-shrink: 0;
}
.user-list h3 {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
}
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
cursor: pointer;
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.server-messages {
color: var(--text-muted);
padding: 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoIRC</title> <title>Chat</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,466 +1,274 @@
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
--bg: #0a0e14; --bg: #1a1a2e;
--bg-panel: #0d1117; --bg-secondary: #16213e;
--bg-input: #0d1117; --bg-input: #0f3460;
--bg-tab: #161b22; --text: #e0e0e0;
--bg-tab-active: #0d1117; --text-muted: #888;
--bg-topic: #0d1117; --accent: #e94560;
--text: #c9d1d9; --accent2: #0f3460;
--text-dim: #6e7681; --border: #2a2a4a;
--text-bright: #e6edf3; --nick: #53a8b6;
--accent: #58a6ff; --timestamp: #666;
--accent-dim: #1f6feb; --tab-active: #e94560;
--border: #21262d; --tab-bg: #16213e;
--system: #7d8590; --tab-hover: #1a1a3e;
--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, html, body, #root {
body,
#root {
height: 100%; height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono", font-family: 'Courier New', Courier, monospace;
"Consolas", "Liberation Mono", "Courier New", monospace; font-size: 14px;
font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
overflow: hidden;
} }
/* ============================================ /* Login screen */
Login Screen
============================================ */
.login-screen { .login-screen {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
background: var(--bg); gap: 16px;
} }
.login-box { .login-screen h1 {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
color: var(--accent); color: var(--accent);
font-size: 1.8em; font-size: 2em;
margin-bottom: 16px;
font-weight: 400;
} }
.login-box .motd { .login-screen input {
color: var(--accent); padding: 10px 16px;
font-size: 11px; font-size: 16px;
margin-bottom: 20px;
text-align: left;
white-space: pre;
font-family: inherit; font-family: inherit;
line-height: 1.2; background: var(--bg-input);
overflow-x: auto;
}
.login-box form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.login-box label {
color: var(--text-dim);
text-align: left;
font-size: 12px;
}
.login-box input {
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
background: var(--input-bg);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-bright); color: var(--text);
border-radius: 3px; border-radius: 4px;
outline: none; width: 280px;
} }
.login-box input:focus { .login-screen button {
border-color: var(--accent-dim); padding: 10px 24px;
} font-size: 16px;
.login-box button {
padding: 8px 16px;
font-family: inherit; 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); background: var(--accent);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
} }
.login-box .error { .login-screen .error {
color: var(--error); color: var(--accent);
font-size: 12px;
margin-top: 8px;
} }
/* ============================================ .login-screen .motd {
IRC App Layout color: var(--text-muted);
============================================ */ max-width: 400px;
text-align: center;
white-space: pre-wrap;
}
.irc-app { /* Main layout */
.app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden;
} }
/* ============================================ /* Tab bar */
Tab Bar
============================================ */
.tab-bar { .tab-bar {
display: flex; display: flex;
background: var(--bg-tab); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto; overflow-x: auto;
flex: 1; flex-shrink: 0;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
} }
.tab { .tab {
display: flex; padding: 8px 16px;
align-items: center;
padding: 0 12px;
cursor: pointer; cursor: pointer;
color: var(--text-dim); border-bottom: 2px solid transparent;
white-space: nowrap; white-space: nowrap;
color: var(--text-muted);
user-select: none; user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative;
} }
.tab:hover { .tab:hover {
color: var(--text); background: var(--tab-hover);
background: rgba(255, 255, 255, 0.03);
} }
.tab.active { .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); color: var(--text);
border-bottom-color: var(--tab-active);
} }
/* ============================================ .tab .close-btn {
Main Content Area margin-left: 8px;
============================================ */ color: var(--text-muted);
font-size: 12px;
}
.main-area { .tab .close-btn:hover {
color: var(--accent);
}
/* Content area */
.content {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
/* ============================================ /* Messages */
Messages Panel .messages-pane {
============================================ */
.messages-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 0;
} }
.messages-scroll { .messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 8px; padding: 8px 12px;
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 { .message {
padding: 1px 0; padding: 2px 0;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
font-size: 13px;
} }
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 12px; font-size: 12px;
margin-right: 8px;
} }
.message .nick { .message .nick {
color: var(--nick);
font-weight: bold; font-weight: bold;
margin-right: 8px;
} }
.message .content { .message .nick::before { content: '<'; }
color: var(--text); .message .nick::after { content: '>'; }
}
/* System messages (joins, parts, quits, etc.) */ .message.system {
.system-message { color: var(--text-muted);
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; font-style: italic;
} }
/* ============================================ .message.system .nick {
Responsive color: var(--text-muted);
============================================ */ }
@media (max-width: 600px) { .message.system .nick::before,
.user-list { .message.system .nick::after { content: ''; }
display: none;
} /* Input */
.input-bar {
.tab { display: flex;
padding: 0 8px; border-top: 1px solid var(--border);
font-size: 11px; background: var(--bg-secondary);
} flex-shrink: 0;
}
.input-prompt {
font-size: 12px; .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; }
} }