Compare commits
5 Commits
fix/29-spl
...
f9c145ad09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c145ad09 | ||
| c0e344d6fc | |||
| 2da7f11484 | |||
| 6e7bf028c1 | |||
| 2761ee156a |
@@ -1,8 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
chatd
|
neoircd
|
||||||
chat-cli
|
neoirc-cli
|
||||||
data.db
|
data.db
|
||||||
data.db-wal
|
data.db-wal
|
||||||
data.db-shm
|
data.db-shm
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,7 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/chatd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -34,5 +34,5 @@ vendor/
|
|||||||
# Project
|
# Project
|
||||||
data.db
|
data.db
|
||||||
debug.log
|
debug.log
|
||||||
/chat-cli
|
/neoirc-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -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/chatd` — compiles clean
|
4. **Build**: `go build ./cmd/neoircd` — 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.
|
||||||
|
|
||||||
|
|||||||
33
Dockerfile
33
Dockerfile
@@ -1,32 +1,45 @@
|
|||||||
|
# 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 . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Build stage
|
||||||
# golang:1.24-alpine, 2026-02-26
|
# golang:1.24-alpine, 2026-02-26
|
||||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache git build-base make
|
RUN apk add --no-cache git build-base make
|
||||||
|
|
||||||
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d
|
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 . .
|
||||||
|
|
||||||
# Run all checks — build fails if branch is not green
|
RUN make test
|
||||||
RUN make check
|
|
||||||
|
|
||||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-26
|
# alpine:3.21, 2026-02-26
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN apk add --no-cache ca-certificates \
|
||||||
&& addgroup -S chat && adduser -S chat -G chat
|
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
|
||||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
&& mkdir -p /var/lib/neoirc \
|
||||||
|
&& chown neoirc:neoirc /var/lib/neoirc
|
||||||
|
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||||
|
|
||||||
USER chat
|
USER neoirc
|
||||||
EXPOSE 8080
|
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 ["chatd"]
|
ENTRYPOINT ["neoircd"]
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||||
|
|
||||||
BINARY := chatd
|
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)
|
BUILDARCH := $(shell go env GOARCH)
|
||||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||||
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -27,7 +27,7 @@ test:
|
|||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
check: test lint fmt-check
|
check: test lint fmt-check
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
@@ -37,10 +37,10 @@ debug: build
|
|||||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/ chatd data.db
|
rm -rf bin/ neoircd
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t chat .
|
docker build -t neoirc .
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
|||||||
128
README.md
128
README.md
@@ -1,9 +1,9 @@
|
|||||||
# chat
|
# neoirc
|
||||||
|
|
||||||
**IRC semantics, structured message metadata, cryptographic signing, and
|
**IRC semantics, structured message metadata, cryptographic signing, and
|
||||||
server-held session state with per-client delivery queues. All over HTTP+JSON.**
|
server-held session state with per-client delivery queues. All over HTTP+JSON.**
|
||||||
|
|
||||||
A chat server written in Go that decouples session state from transport
|
An IRC-inspired server written in Go that decouples session state from transport
|
||||||
connections, enabling mobile-friendly persistent sessions over plain HTTP.
|
connections, enabling mobile-friendly persistent sessions over plain HTTP.
|
||||||
|
|
||||||
The **HTTP API is the primary interface**. It's designed to be simple enough
|
The **HTTP API is the primary interface**. It's designed to be simple enough
|
||||||
@@ -44,7 +44,7 @@ IRC is in decline because session state is tied to the TCP connection. In a
|
|||||||
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
|
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
|
||||||
or pay for IRCCloud.
|
or pay for IRCCloud.
|
||||||
|
|
||||||
This project builds a chat server that:
|
This project builds a server that:
|
||||||
|
|
||||||
- Holds session state server-side (message queues, presence, channel membership)
|
- Holds session state server-side (message queues, presence, channel membership)
|
||||||
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||||||
@@ -132,7 +132,7 @@ makes signing consistent — you sign the same structure you send.
|
|||||||
|
|
||||||
### Why not XMPP or Matrix?
|
### Why not XMPP or Matrix?
|
||||||
|
|
||||||
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented
|
XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented
|
||||||
across incompatible extensions (XEPs). Matrix is a federated append-only event
|
across incompatible extensions (XEPs). Matrix is a federated append-only event
|
||||||
graph with a spec that runs to hundreds of pages. Both are fine protocols, but
|
graph with a spec that runs to hundreds of pages. Both are fine protocols, but
|
||||||
they're solving different problems at different scales.
|
they're solving different problems at different scales.
|
||||||
@@ -828,16 +828,16 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| Code | Name | When Sent | Example |
|
| Code | Name | When Sent | Example |
|
||||||
|------|----------------------|-----------|---------|
|
|------|----------------------|-----------|---------|
|
||||||
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
|
||||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` |
|
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc-server, running version 0.1"]}` |
|
||||||
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
|
||||||
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` |
|
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc-server","0.1","","imnst"]}` |
|
||||||
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` |
|
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
||||||
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
|
||||||
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
||||||
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
|
||||||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
||||||
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
|
||||||
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` |
|
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
|
||||||
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
|
||||||
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
|
||||||
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
||||||
@@ -845,10 +845,11 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
|||||||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
||||||
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
||||||
|
|
||||||
**Note:** Numeric replies are planned for full implementation. The current MVP
|
**Note:** Numeric replies are now implemented. All IRC command responses
|
||||||
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
|
(success and error) are delivered as numeric replies through the message queue.
|
||||||
of numeric replies for error conditions. Numeric replies in the message queue
|
HTTP error codes are reserved for transport-level issues (auth failures,
|
||||||
will be added post-MVP.
|
malformed requests, server errors). The `params` field in the message envelope
|
||||||
|
carries IRC-style parameters (e.g., channel name, target nick).
|
||||||
|
|
||||||
### Channel Modes
|
### Channel Modes
|
||||||
|
|
||||||
@@ -1054,8 +1055,8 @@ reference with all required and optional fields.
|
|||||||
|
|
||||||
| Command | Required Fields | Optional | Response Status |
|
| Command | Required Fields | Optional | Response Status |
|
||||||
|-----------|---------------------|---------------|-----------------|
|
|-----------|---------------------|---------------|-----------------|
|
||||||
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
|
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
||||||
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
|
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
||||||
| `JOIN` | `to` | | 200 OK |
|
| `JOIN` | `to` | | 200 OK |
|
||||||
| `PART` | `to` | `body` | 200 OK |
|
| `PART` | `to` | `body` | 200 OK |
|
||||||
| `NICK` | `body` | | 200 OK |
|
| `NICK` | `body` | | 200 OK |
|
||||||
@@ -1063,18 +1064,44 @@ reference with all required and optional fields.
|
|||||||
| `QUIT` | | `body` | 200 OK |
|
| `QUIT` | | `body` | 200 OK |
|
||||||
| `PING` | | | 200 OK |
|
| `PING` | | | 200 OK |
|
||||||
|
|
||||||
**Errors (all commands):**
|
All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
||||||
|
are delivered as **numeric replies** through the message queue (see
|
||||||
|
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
||||||
|
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
||||||
|
auth tokens (401), and server errors (500).
|
||||||
|
|
||||||
|
**HTTP errors (transport-level only):**
|
||||||
|
|
||||||
| Status | Error | When |
|
| Status | Error | When |
|
||||||
|--------|-------|------|
|
|--------|-------|------|
|
||||||
| 400 | `invalid request` | Malformed JSON |
|
| 400 | `invalid request` | Malformed JSON or empty command |
|
||||||
| 400 | `to field required` | Missing `to` for commands that need it |
|
|
||||||
| 400 | `body required` | Missing `body` for commands that need it |
|
|
||||||
| 400 | `unknown command: X` | Unrecognized command |
|
|
||||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||||
| 404 | `channel not found` | Target channel doesn't exist |
|
| 500 | `internal error` | Server-side failure |
|
||||||
| 404 | `user not found` | DM target nick doesn't exist |
|
|
||||||
| 409 | `nick already in use` | NICK target is taken |
|
**IRC numeric error replies (delivered via message queue):**
|
||||||
|
|
||||||
|
| Numeric | Name | When |
|
||||||
|
|---------|------|------|
|
||||||
|
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
|
||||||
|
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
|
||||||
|
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
|
||||||
|
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
|
||||||
|
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
||||||
|
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
||||||
|
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
||||||
|
|
||||||
|
**IRC numeric success replies (delivered via message queue):**
|
||||||
|
|
||||||
|
| Numeric | Name | When |
|
||||||
|
|---------|------|------|
|
||||||
|
| 001 | RPL_WELCOME | Sent on session creation/login |
|
||||||
|
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
|
||||||
|
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
|
||||||
|
| 353 | RPL_NAMREPLY | Channel member list (on JOIN) |
|
||||||
|
| 366 | RPL_ENDOFNAMES | End of NAMES list (on JOIN) |
|
||||||
|
| 375 | RPL_MOTDSTART | Start of MOTD |
|
||||||
|
| 372 | RPL_MOTD | MOTD line |
|
||||||
|
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
||||||
|
|
||||||
### GET /api/v1/history — Message History
|
### GET /api/v1/history — Message History
|
||||||
|
|
||||||
@@ -1214,7 +1241,7 @@ Return server metadata. No authentication required.
|
|||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Chat Server",
|
"name": "My NeoIRC Server",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42
|
||||||
}
|
}
|
||||||
@@ -1468,7 +1495,7 @@ authenticity.
|
|||||||
|
|
||||||
## Federation (Server-to-Server)
|
## Federation (Server-to-Server)
|
||||||
|
|
||||||
Federation allows multiple chat servers to link together, forming a network
|
Federation allows multiple neoirc servers to link together, forming a network
|
||||||
where users on different servers can share channels — similar to IRC server
|
where users on different servers can share channels — similar to IRC server
|
||||||
linking.
|
linking.
|
||||||
|
|
||||||
@@ -1645,7 +1672,7 @@ directory is also loaded automatically via
|
|||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|--------------------|---------|--------------------------------------|-------------|
|
|--------------------|---------|--------------------------------------|-------------|
|
||||||
| `PORT` | int | `8080` | HTTP listen port |
|
| `PORT` | int | `8080` | HTTP listen port |
|
||||||
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
|
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
|
||||||
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
|
||||||
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
|
||||||
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
|
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
|
||||||
@@ -1664,10 +1691,10 @@ directory is also loaded automatically via
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
PORT=8080
|
PORT=8080
|
||||||
SERVER_NAME=My Chat Server
|
SERVER_NAME=My NeoIRC Server
|
||||||
MOTD=Welcome! Be excellent to each other.
|
MOTD=Welcome! Be excellent to each other.
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
DBURL=file:./data.db?_journal_mode=WAL
|
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||||
SESSION_IDLE_TIMEOUT=24h
|
SESSION_IDLE_TIMEOUT=24h
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1677,25 +1704,24 @@ SESSION_IDLE_TIMEOUT=24h
|
|||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
|
|
||||||
The Docker image contains a single static binary (`chatd`) and nothing else.
|
The Docker image contains a single static binary (`neoircd`) and nothing else.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
# Build
|
||||||
docker build -t chat .
|
docker build -t neoirc .
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
docker run -p 8080:8080 \
|
docker run -p 8080:8080 \
|
||||||
-v chat-data:/data \
|
-v neoirc-data:/var/lib/neoirc \
|
||||||
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
|
|
||||||
-e SERVER_NAME="My Server" \
|
-e SERVER_NAME="My Server" \
|
||||||
-e MOTD="Welcome!" \
|
-e MOTD="Welcome!" \
|
||||||
chat
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a multi-stage build:
|
The Dockerfile is a multi-stage build:
|
||||||
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify
|
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||||
compilation, not included in final image)
|
compilation, not included in final image)
|
||||||
2. **Final stage**: Alpine Linux + `chatd` binary only
|
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
@@ -1704,13 +1730,13 @@ RUN apk add --no-cache make
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go build -o /chatd ./cmd/chatd/
|
RUN go build -o /neoircd ./cmd/neoircd/
|
||||||
RUN go build -o /chat-cli ./cmd/chat-cli/
|
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
COPY --from=builder /chatd /usr/local/bin/chatd
|
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["chatd"]
|
CMD ["neoircd"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
@@ -1718,11 +1744,11 @@ CMD ["chatd"]
|
|||||||
```bash
|
```bash
|
||||||
# Build from source
|
# Build from source
|
||||||
make build
|
make build
|
||||||
# Binary at ./bin/chatd
|
# Binary at ./bin/neoircd
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
./bin/chatd
|
./bin/neoircd
|
||||||
# Listens on :8080, creates ./data.db
|
# Listens on :8080, writes to /var/lib/neoirc/state.db
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reverse Proxy (Production)
|
### Reverse Proxy (Production)
|
||||||
@@ -1731,7 +1757,7 @@ For production, run behind a TLS-terminating reverse proxy.
|
|||||||
|
|
||||||
**Caddy:**
|
**Caddy:**
|
||||||
```
|
```
|
||||||
chat.example.com {
|
neoirc.example.com {
|
||||||
reverse_proxy localhost:8080
|
reverse_proxy localhost:8080
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1740,7 +1766,7 @@ chat.example.com {
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name chat.example.com;
|
server_name neoirc.example.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/cert.pem;
|
ssl_certificate /path/to/cert.pem;
|
||||||
ssl_certificate_key /path/to/key.pem;
|
ssl_certificate_key /path/to/key.pem;
|
||||||
@@ -1763,15 +1789,15 @@ seconds to accommodate long-poll connections.
|
|||||||
string). This allows concurrent reads during writes.
|
string). This allows concurrent reads during writes.
|
||||||
- **Single writer**: SQLite allows only one writer at a time. For high-traffic
|
- **Single writer**: SQLite allows only one writer at a time. For high-traffic
|
||||||
servers, Postgres support is planned.
|
servers, Postgres support is planned.
|
||||||
- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode).
|
- **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode).
|
||||||
- **Location**: By default, `data.db` is created in the working directory.
|
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`.
|
||||||
Use the `DBURL` env var to place it elsewhere.
|
Use the `DBURL` env var to place it elsewhere.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Client Development Guide
|
## Client Development Guide
|
||||||
|
|
||||||
This section explains how to write a client against the chat API. The API is
|
This section explains how to write a client against the neoirc API. The API is
|
||||||
designed to be simple enough that a basic client can be written in any language
|
designed to be simple enough that a basic client can be written in any language
|
||||||
with an HTTP client library.
|
with an HTTP client library.
|
||||||
|
|
||||||
@@ -2061,7 +2087,7 @@ GET /api/v1/challenge
|
|||||||
- [x] NICK change with broadcast
|
- [x] NICK change with broadcast
|
||||||
- [x] QUIT with broadcast and cleanup
|
- [x] QUIT with broadcast and cleanup
|
||||||
- [x] Embedded web SPA client
|
- [x] Embedded web SPA client
|
||||||
- [x] CLI client (chat-cli)
|
- [x] CLI client (neoirc-cli)
|
||||||
- [x] SQLite storage with WAL mode
|
- [x] SQLite storage with WAL mode
|
||||||
- [x] Docker deployment
|
- [x] Docker deployment
|
||||||
- [x] Prometheus metrics endpoint
|
- [x] Prometheus metrics endpoint
|
||||||
@@ -2114,11 +2140,11 @@ GET /api/v1/challenge
|
|||||||
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
|
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
|
||||||
|
|
||||||
```
|
```
|
||||||
chat/
|
neoirc/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── chatd/ # Server binary entry point
|
│ ├── neoircd/ # Server binary entry point
|
||||||
│ │ └── main.go
|
│ │ └── main.go
|
||||||
│ └── chat-cli/ # TUI client
|
│ └── neoirc-cli/ # TUI client
|
||||||
│ ├── main.go # Command handling, poll loop
|
│ ├── main.go # Command handling, poll loop
|
||||||
│ ├── ui.go # tview-based terminal UI
|
│ ├── ui.go # tview-based terminal UI
|
||||||
│ └── api/
|
│ └── api/
|
||||||
@@ -2249,7 +2275,7 @@ chat/
|
|||||||
- IRC message envelope format with per-client queue fan-out
|
- IRC message envelope format with per-client queue fan-out
|
||||||
- Long-polling with in-memory broker
|
- Long-polling with in-memory broker
|
||||||
- Embedded web SPA client
|
- Embedded web SPA client
|
||||||
- TUI client (chat-cli)
|
- TUI client (neoirc-cli)
|
||||||
- Docker image
|
- Docker image
|
||||||
- Prometheus metrics
|
- Prometheus metrics
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package chatapi provides a client for the chat server API.
|
// Package neoircapi provides a client for the neoirc server API.
|
||||||
package chatapi
|
package neoircapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
|
|
||||||
var errHTTP = errors.New("HTTP error")
|
var errHTTP = errors.New("HTTP error")
|
||||||
|
|
||||||
// Client wraps HTTP calls to the chat server API.
|
// Client wraps HTTP calls to the neoirc server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
Token string
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package chatapi
|
package neoircapi
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ type StateResponse struct {
|
|||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a chat message envelope.
|
// Message represents a neoirc 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"`
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package main is the entry point for the chat-cli client.
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,7 +41,7 @@ func main() {
|
|||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Welcome to chat-cli — an IRC-style client",
|
"Welcome to neoirc-cli — an IRC-style client",
|
||||||
)
|
)
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Type [yellow]/connect <server-url>" +
|
"Type [yellow]/connect <server-url>" +
|
||||||
@@ -564,7 +564,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
func (a *App) cmdHelp() {
|
func (a *App) cmdHelp() {
|
||||||
help := []string{
|
help := []string{
|
||||||
"[cyan]*** chat-cli commands:",
|
"[cyan]*** neoirc-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",
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
// Package main is the entry point for the chatd server.
|
// Package main is the entry point for the neoircd server.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||||
"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/chat/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/chat/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Appname is the application name, set at build time.
|
// Appname is the application name, set at build time.
|
||||||
Appname = "chat" //nolint:gochecknoglobals
|
Appname = "neoirc" //nolint:gochecknoglobals
|
||||||
|
|
||||||
// Version is the application version, set at build time.
|
// Version is the application version, set at build time.
|
||||||
Version string //nolint:gochecknoglobals
|
Version string //nolint:gochecknoglobals
|
||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/chat
|
module git.eeqj.de/sneak/neoirc
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/broker"
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ 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:./data.db?_journal_mode=WAL")
|
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.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", "")
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // .env
|
||||||
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
|||||||
func (database *Database) connect(ctx context.Context) error {
|
func (database *Database) connect(ctx context.Context) error {
|
||||||
dbURL := database.params.Config.DBURL
|
dbURL := database.params.Config.DBURL
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info(
|
database.log.Info(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type IRCMessage struct {
|
|||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
Body json.RawMessage `json:"body,omitempty"`
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
Meta json.RawMessage `json:"meta,omitempty"`
|
Meta json.RawMessage `json:"meta,omitempty"`
|
||||||
@@ -491,12 +492,17 @@ func (database *Database) GetSessionChannelIDs(
|
|||||||
func (database *Database) InsertMessage(
|
func (database *Database) InsertMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
|
params json.RawMessage,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
msgUUID := uuid.New().String()
|
msgUUID := uuid.New().String()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if params == nil {
|
||||||
|
params = json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
|
||||||
if body == nil {
|
if body == nil {
|
||||||
body = json.RawMessage("[]")
|
body = json.RawMessage("[]")
|
||||||
}
|
}
|
||||||
@@ -508,10 +514,10 @@ func (database *Database) InsertMessage(
|
|||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(uuid, command, msg_from, msg_to,
|
(uuid, command, msg_from, msg_to,
|
||||||
body, meta, created_at)
|
params, body, meta, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msgUUID, command, from, target,
|
msgUUID, command, from, target,
|
||||||
string(body), string(meta), now)
|
string(params), string(body), string(meta), now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", fmt.Errorf(
|
return 0, "", fmt.Errorf(
|
||||||
"insert message: %w", err,
|
"insert message: %w", err,
|
||||||
@@ -578,7 +584,7 @@ func (database *Database) PollMessages(
|
|||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT cq.id, m.uuid, m.command,
|
`SELECT cq.id, m.uuid, m.command,
|
||||||
m.msg_from, m.msg_to,
|
m.msg_from, m.msg_to,
|
||||||
m.body, m.meta, m.created_at
|
m.params, m.body, m.meta, m.created_at
|
||||||
FROM client_queues cq
|
FROM client_queues cq
|
||||||
INNER JOIN messages m
|
INNER JOIN messages m
|
||||||
ON m.id = cq.message_id
|
ON m.id = cq.message_id
|
||||||
@@ -642,7 +648,7 @@ func (database *Database) queryHistory(
|
|||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, body, meta, created_at
|
msg_to, params, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ? AND id < ?
|
WHERE msg_to = ? AND id < ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -659,7 +665,7 @@ func (database *Database) queryHistory(
|
|||||||
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, body, meta, created_at
|
msg_to, params, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ?
|
WHERE msg_to = ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -684,16 +690,16 @@ func scanMessages(
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
msg IRCMessage
|
msg IRCMessage
|
||||||
qID int64
|
qID int64
|
||||||
body, meta string
|
params, body, meta string
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&qID, &msg.ID, &msg.Command,
|
&qID, &msg.ID, &msg.Command,
|
||||||
&msg.From, &msg.To,
|
&msg.From, &msg.To,
|
||||||
&body, &meta, &createdAt,
|
¶ms, &body, &meta, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, fmt.Errorf(
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
@@ -701,6 +707,10 @@ func scanMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if params != "" && params != "[]" {
|
||||||
|
msg.Params = json.RawMessage(params)
|
||||||
|
}
|
||||||
|
|
||||||
msg.Body = json.RawMessage(body)
|
msg.Body = json.RawMessage(body)
|
||||||
msg.Meta = json.RawMessage(meta)
|
msg.Meta = json.RawMessage(meta)
|
||||||
msg.TS = createdAt.Format(time.RFC3339Nano)
|
msg.TS = createdAt.Format(time.RFC3339Nano)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, msgUUID, err := database.InsertMessage(
|
dbID, msgUUID, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
|||||||
for range msgCount {
|
for range msgCount {
|
||||||
_, _, err := database.InsertMessage(
|
_, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "user", "#hist",
|
ctx, "PRIVMSG", "user", "#hist",
|
||||||
json.RawMessage(`["msg"]`), nil,
|
nil, json.RawMessage(`["msg"]`), nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
body := json.RawMessage(`["test"]`)
|
body := json.RawMessage(`["test"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func (hdlr *Handlers) fanOut(
|
|||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), command, from, target, body, nil,
|
request.Context(), command, from, target, nil, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert message: %w", err)
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
@@ -185,7 +185,7 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
@@ -219,49 +219,76 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
|
||||||
|
// new client.
|
||||||
|
func (hdlr *Handlers) deliverWelcome(
|
||||||
|
request *http.Request,
|
||||||
|
clientID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "001", nick, nil,
|
||||||
|
"Welcome to the network, "+nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// deliverMOTD sends the MOTD as IRC numeric messages to a
|
// deliverMOTD sends the MOTD as IRC numeric messages to a
|
||||||
// new client.
|
// new client.
|
||||||
func (hdlr *Handlers) deliverMOTD(
|
func (hdlr *Handlers) deliverMOTD(
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
|
nick string,
|
||||||
) {
|
) {
|
||||||
motd := hdlr.params.Config.MOTD
|
motd := hdlr.params.Config.MOTD
|
||||||
serverName := hdlr.params.Config.ServerName
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
if serverName == "" {
|
|
||||||
serverName = "chat"
|
|
||||||
}
|
|
||||||
|
|
||||||
if motd == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
|
hdlr.deliverWelcome(request, clientID, nick)
|
||||||
|
|
||||||
|
if motd == "" {
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "375", serverName,
|
ctx, clientID, "375", nick, nil,
|
||||||
"- "+serverName+" Message of the Day -",
|
"- "+srvName+" Message of the Day -",
|
||||||
)
|
)
|
||||||
|
|
||||||
for line := range strings.SplitSeq(motd, "\n") {
|
for line := range strings.SplitSeq(motd, "\n") {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "372", serverName,
|
ctx, clientID, "372", nick, nil,
|
||||||
"- "+line,
|
"- "+line,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "376", serverName,
|
ctx, clientID, "376", nick, nil,
|
||||||
"End of /MOTD command.",
|
"End of /MOTD command.",
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) serverName() string {
|
||||||
|
name := hdlr.params.Config.ServerName
|
||||||
|
if name == "" {
|
||||||
|
return "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
command, serverName, text string,
|
command, nick string,
|
||||||
|
params []string,
|
||||||
|
text string,
|
||||||
) {
|
) {
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,9 +299,22 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var paramsJSON json.RawMessage
|
||||||
|
|
||||||
|
if len(params) > 0 {
|
||||||
|
paramsJSON, err = json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"marshal numeric params", "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
|
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
|
||||||
ctx, command, serverName, "",
|
ctx, command, hdlr.serverName(), nick,
|
||||||
json.RawMessage(body), nil,
|
paramsJSON, json.RawMessage(body), nil,
|
||||||
)
|
)
|
||||||
if insertErr != nil {
|
if insertErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -532,7 +572,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
writer, request.Body, hdlr.maxBodySize(),
|
writer, request.Body, hdlr.maxBodySize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -582,7 +622,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.dispatchCommand(
|
hdlr.dispatchCommand(
|
||||||
writer, request, sessionID, nick,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
payload.Command, payload.To,
|
payload.Command, payload.To,
|
||||||
payload.Body, bodyLines,
|
payload.Body, bodyLines,
|
||||||
)
|
)
|
||||||
@@ -592,7 +633,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
func (hdlr *Handlers) dispatchCommand(
|
func (hdlr *Handlers) dispatchCommand(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
@@ -600,24 +641,30 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
switch command {
|
switch command {
|
||||||
case cmdPrivmsg, "NOTICE":
|
case cmdPrivmsg, "NOTICE":
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request, sessionID, nick,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case "JOIN":
|
case "JOIN":
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
writer, request, sessionID, nick, target,
|
writer, request,
|
||||||
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case "PART":
|
case "PART":
|
||||||
hdlr.handlePart(
|
hdlr.handlePart(
|
||||||
writer, request, sessionID, nick, target, body,
|
writer, request,
|
||||||
|
sessionID, clientID, nick, target, body,
|
||||||
)
|
)
|
||||||
case "NICK":
|
case "NICK":
|
||||||
hdlr.handleNick(
|
hdlr.handleNick(
|
||||||
writer, request, sessionID, nick, bodyLines,
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
case "TOPIC":
|
case "TOPIC":
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request, nick, target, body, bodyLines,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case "QUIT":
|
case "QUIT":
|
||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
@@ -627,50 +674,63 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"command": "PONG",
|
"command": "PONG",
|
||||||
"from": hdlr.params.Config.ServerName,
|
"from": hdlr.serverName(),
|
||||||
},
|
},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
default:
|
default:
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"unknown command: "+command,
|
"421", nick, []string{command},
|
||||||
http.StatusBadRequest,
|
"Unknown command",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePrivmsg(
|
func (hdlr *Handlers) handlePrivmsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"to field required",
|
"461", nick, []string{command},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"body required",
|
"461", nick, []string{command},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request, sessionID, nick,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -678,15 +738,36 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.handleDirectMsg(
|
hdlr.handleDirectMsg(
|
||||||
writer, request, sessionID, nick,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// respondIRCError enqueues a numeric error reply, notifies
|
||||||
|
// the broker, and sends HTTP 200 with {"status":"error"}.
|
||||||
|
func (hdlr *Handlers) respondIRCError(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
numeric, nick string,
|
||||||
|
params []string,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID,
|
||||||
|
numeric, nick, params, text,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleChannelMsg(
|
func (hdlr *Handlers) handleChannelMsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
@@ -694,10 +775,10 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"channel not found",
|
"403", nick, []string{target},
|
||||||
http.StatusNotFound,
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -720,15 +801,27 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"not a member of this channel",
|
"442", nick, []string{target},
|
||||||
http.StatusForbidden,
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.sendChannelMsg(
|
||||||
|
writer, request, command, nick, target, body, chID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) sendChannelMsg(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
command, nick, target string,
|
||||||
|
body json.RawMessage,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||||
request.Context(), chID,
|
request.Context(), chID,
|
||||||
)
|
)
|
||||||
@@ -761,13 +854,13 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusCreated)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleDirectMsg(
|
func (hdlr *Handlers) handleDirectMsg(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
@@ -775,11 +868,15 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"user not found",
|
"401", nick, []string{target},
|
||||||
http.StatusNotFound,
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -805,20 +902,20 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusCreated)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleJoin(
|
func (hdlr *Handlers) handleJoin(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, target string,
|
nick, target string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"to field required",
|
"461", nick, []string{"JOIN"},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -830,15 +927,27 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !validChannelRe.MatchString(channel) {
|
if !validChannelRe.MatchString(channel) {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"invalid channel name",
|
"403", nick, []string{channel},
|
||||||
http.StatusBadRequest,
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.executeJoin(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeJoin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
) {
|
||||||
chID, err := hdlr.params.Database.GetOrCreateChannel(
|
chID, err := hdlr.params.Database.GetOrCreateChannel(
|
||||||
request.Context(), channel,
|
request.Context(), channel,
|
||||||
)
|
)
|
||||||
@@ -879,6 +988,10 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
request, "JOIN", nick, channel, nil, memberIDs,
|
request, "JOIN", nick, channel, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hdlr.deliverJoinNumerics(
|
||||||
|
request, clientID, sessionID, nick, channel, chID,
|
||||||
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"status": "joined",
|
"status": "joined",
|
||||||
@@ -887,19 +1000,96 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
|
||||||
|
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
|
||||||
|
func (hdlr *Handlers) deliverJoinNumerics(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
chInfo, err := hdlr.params.Database.GetChannelByName(
|
||||||
|
ctx, channel,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
_ = chInfo // chInfo is the ID; topic comes from DB.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get topic from channel info.
|
||||||
|
channels, listErr := hdlr.params.Database.ListChannels(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
|
||||||
|
topic := ""
|
||||||
|
|
||||||
|
if listErr == nil {
|
||||||
|
for _, ch := range channels {
|
||||||
|
if ch.Name == channel {
|
||||||
|
topic = ch.Topic
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if topic != "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "332", nick,
|
||||||
|
[]string{channel}, topic,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "331", nick,
|
||||||
|
[]string{channel}, "No topic is set",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get member list for NAMES reply.
|
||||||
|
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if memErr == nil && len(members) > 0 {
|
||||||
|
nicks := make([]string, 0, len(members))
|
||||||
|
|
||||||
|
for _, mem := range members {
|
||||||
|
nicks = append(nicks, mem.Nick)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "353", nick,
|
||||||
|
[]string{"=", channel},
|
||||||
|
strings.Join(nicks, " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "366", nick,
|
||||||
|
[]string{channel}, "End of /NAMES list",
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePart(
|
func (hdlr *Handlers) handlePart(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick, target string,
|
nick, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"to field required",
|
"461", nick, []string{"PART"},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -913,11 +1103,15 @@ func (hdlr *Handlers) handlePart(
|
|||||||
request.Context(), channel,
|
request.Context(), channel,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.enqueueNumeric(
|
||||||
writer, request,
|
request.Context(), clientID,
|
||||||
"channel not found",
|
"403", nick, []string{channel},
|
||||||
http.StatusNotFound,
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -961,16 +1155,16 @@ func (hdlr *Handlers) handlePart(
|
|||||||
func (hdlr *Handlers) handleNick(
|
func (hdlr *Handlers) handleNick(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID int64,
|
sessionID, clientID int64,
|
||||||
nick string,
|
nick string,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"body required (new nick)",
|
"461", nick, []string{"NICK"},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -979,10 +1173,10 @@ func (hdlr *Handlers) handleNick(
|
|||||||
newNick := strings.TrimSpace(lines[0])
|
newNick := strings.TrimSpace(lines[0])
|
||||||
|
|
||||||
if !validNickRe.MatchString(newNick) {
|
if !validNickRe.MatchString(newNick) {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"invalid nick",
|
"432", nick, []string{newNick},
|
||||||
http.StatusBadRequest,
|
"Erroneous nickname",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -998,15 +1192,27 @@ func (hdlr *Handlers) handleNick(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.executeNickChange(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, newNick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeNickChange(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, newNick string,
|
||||||
|
) {
|
||||||
err := hdlr.params.Database.ChangeNick(
|
err := hdlr.params.Database.ChangeNick(
|
||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"nick already in use",
|
"433", nick, []string{newNick},
|
||||||
http.StatusConflict,
|
"Nickname is already in use",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1056,7 +1262,7 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
|
|
||||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), "NICK", oldNick, "",
|
request.Context(), "NICK", oldNick, "",
|
||||||
json.RawMessage(nickBody), nil,
|
nil, json.RawMessage(nickBody), nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToSession(
|
_ = hdlr.params.Database.EnqueueToSession(
|
||||||
@@ -1088,15 +1294,16 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
func (hdlr *Handlers) handleTopic(
|
func (hdlr *Handlers) handleTopic(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
nick, target string,
|
nick, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"to field required",
|
"461", nick, []string{"TOPIC"},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1104,46 +1311,60 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
|
|
||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondError(
|
hdlr.respondIRCError(
|
||||||
writer, request,
|
writer, request, clientID, sessionID,
|
||||||
"body required (topic text)",
|
"461", nick, []string{"TOPIC"},
|
||||||
http.StatusBadRequest,
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
topic := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
channel := target
|
channel := target
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
channel = "#" + channel
|
channel = "#" + channel
|
||||||
}
|
}
|
||||||
|
|
||||||
err := hdlr.params.Database.SetTopic(
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
request.Context(), channel, topic,
|
request.Context(), channel,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error(
|
hdlr.respondIRCError(
|
||||||
"set topic failed", "error", err,
|
writer, request, clientID, sessionID,
|
||||||
)
|
"403", nick, []string{channel},
|
||||||
hdlr.respondError(
|
"No such channel",
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
chID, err := hdlr.params.Database.GetChannelByName(
|
hdlr.executeTopic(
|
||||||
request.Context(), channel,
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
channel, strings.Join(lines, " "),
|
||||||
|
body, chID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeTopic(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel, topic string,
|
||||||
|
body json.RawMessage,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
|
setErr := hdlr.params.Database.SetTopic(
|
||||||
|
request.Context(), channel, topic,
|
||||||
|
)
|
||||||
|
if setErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set topic failed", "error", setErr,
|
||||||
|
)
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"channel not found",
|
"internal error",
|
||||||
http.StatusNotFound,
|
http.StatusInternalServerError,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1157,6 +1378,12 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
request, "TOPIC", nick, channel, body, memberIDs,
|
request, "TOPIC", nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
request.Context(), clientID,
|
||||||
|
"332", nick, []string{channel}, topic,
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"status": "ok", "topic": topic,
|
"status": "ok", "topic": topic,
|
||||||
@@ -1182,7 +1409,8 @@ func (hdlr *Handlers) handleQuit(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), "QUIT", nick, "", body, nil,
|
request.Context(), "QUIT", nick, "",
|
||||||
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,7 +1659,8 @@ func (hdlr *Handlers) cleanupUser(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
ctx, "QUIT", nick, "", nil, nil,
|
ctx, "QUIT", nick, "",
|
||||||
|
nil, nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||||
"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/chat/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/chat/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
)
|
)
|
||||||
@@ -115,7 +115,7 @@ func newTestServer(
|
|||||||
|
|
||||||
func newTestGlobals() *globals.Globals {
|
func newTestGlobals() *globals.Globals {
|
||||||
return &globals.Globals{
|
return &globals.Globals{
|
||||||
Appname: "chat-test",
|
Appname: "neoirc-test",
|
||||||
Version: "test",
|
Version: "test",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,6 +462,19 @@ func findMessage(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findNumeric(
|
||||||
|
msgs []map[string]any,
|
||||||
|
numeric string,
|
||||||
|
) bool {
|
||||||
|
for _, msg := range msgs {
|
||||||
|
if msg[commandKey] == numeric {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
func TestCreateSessionValid(t *testing.T) {
|
func TestCreateSessionValid(t *testing.T) {
|
||||||
@@ -473,6 +486,47 @@ func TestCreateSessionValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWelcomeNumeric(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("welcomer")
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "001") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_WELCOME (001), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinNumerics(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("jnumtest")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#numtest",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "353") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_NAMREPLY (353), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "366") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_ENDOFNAMES (366), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSessionDuplicate(t *testing.T) {
|
func TestCreateSessionDuplicate(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
tserver.createSession("alice")
|
tserver.createSession("alice")
|
||||||
@@ -668,11 +722,23 @@ func TestJoinMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("joiner3")
|
token := tserver.createSession("joiner3")
|
||||||
|
|
||||||
|
// Drain initial MOTD/welcome numerics.
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
status, _ := tserver.sendCommand(
|
||||||
token, map[string]any{commandKey: joinCmd},
|
token, map[string]any{commandKey: joinCmd},
|
||||||
)
|
)
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,10 +748,10 @@ func TestChannelMessage(t *testing.T) {
|
|||||||
bobToken := tserver.createSession("bob_msg")
|
bobToken := tserver.createSession("bob_msg")
|
||||||
|
|
||||||
tserver.sendCommand(aliceToken, map[string]any{
|
tserver.sendCommand(aliceToken, map[string]any{
|
||||||
commandKey: joinCmd, toKey: "#chat",
|
commandKey: joinCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
tserver.sendCommand(bobToken, map[string]any{
|
tserver.sendCommand(bobToken, map[string]any{
|
||||||
commandKey: joinCmd, toKey: "#chat",
|
commandKey: joinCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _ = tserver.pollMessages(aliceToken, 0)
|
_, _ = tserver.pollMessages(aliceToken, 0)
|
||||||
@@ -695,13 +761,13 @@ func TestChannelMessage(t *testing.T) {
|
|||||||
aliceToken,
|
aliceToken,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
toKey: "#chat",
|
toKey: "#test",
|
||||||
bodyKey: []string{"hello world"},
|
bodyKey: []string{"hello world"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if status != http.StatusCreated {
|
if status != http.StatusOK {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected 201, got %d: %v", status, result,
|
"expected 200, got %d: %v", status, result,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,14 +791,25 @@ func TestMessageMissingBody(t *testing.T) {
|
|||||||
token := tserver.createSession("nobody")
|
token := tserver.createSession("nobody")
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: joinCmd, toKey: "#chat",
|
commandKey: joinCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: privmsgCmd, toKey: "#chat",
|
commandKey: privmsgCmd, toKey: "#test",
|
||||||
})
|
})
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,12 +817,23 @@ func TestMessageMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("noto")
|
token := tserver.createSession("noto")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
bodyKey: []string{"hello"},
|
bodyKey: []string{"hello"},
|
||||||
})
|
})
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,6 +847,8 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#private",
|
commandKey: joinCmd, toKey: "#private",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(aliceToken, 0)
|
||||||
|
|
||||||
// Alice tries to send without joining.
|
// Alice tries to send without joining.
|
||||||
status, _ := tserver.sendCommand(
|
status, _ := tserver.sendCommand(
|
||||||
aliceToken,
|
aliceToken,
|
||||||
@@ -768,8 +858,17 @@ func TestNonMemberCannotSend(t *testing.T) {
|
|||||||
bodyKey: []string{"sneaky"},
|
bodyKey: []string{"sneaky"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if status != http.StatusForbidden {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 403, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "442") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,9 +885,9 @@ func TestDirectMessage(t *testing.T) {
|
|||||||
bodyKey: []string{"hey bob"},
|
bodyKey: []string{"hey bob"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if status != http.StatusCreated {
|
if status != http.StatusOK {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected 201, got %d: %v", status, result,
|
"expected 200, got %d: %v", status, result,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,13 +917,24 @@ func TestDMToNonexistentUser(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("dmsender")
|
token := tserver.createSession("dmsender")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: privmsgCmd,
|
commandKey: privmsgCmd,
|
||||||
toKey: "nobody",
|
toKey: "nobody",
|
||||||
bodyKey: []string{"hello?"},
|
bodyKey: []string{"hello?"},
|
||||||
})
|
})
|
||||||
if status != http.StatusNotFound {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 404, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "401") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NOSUCHNICK (401), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,12 +981,23 @@ func TestNickCollision(t *testing.T) {
|
|||||||
|
|
||||||
tserver.createSession("taken_nick")
|
tserver.createSession("taken_nick")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: "NICK",
|
commandKey: "NICK",
|
||||||
bodyKey: []string{"taken_nick"},
|
bodyKey: []string{"taken_nick"},
|
||||||
})
|
})
|
||||||
if status != http.StatusConflict {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 409, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "433") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NICKNAMEINUSE (433), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,12 +1005,23 @@ func TestNickInvalid(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("nickval")
|
token := tserver.createSession("nickval")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: "NICK",
|
commandKey: "NICK",
|
||||||
bodyKey: []string{"bad nick!"},
|
bodyKey: []string{"bad nick!"},
|
||||||
})
|
})
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "432") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,11 +1029,22 @@ func TestNickEmptyBody(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("nicknobody")
|
token := tserver.createSession("nicknobody")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
status, _ := tserver.sendCommand(
|
||||||
token, map[string]any{commandKey: "NICK"},
|
token, map[string]any{commandKey: "NICK"},
|
||||||
)
|
)
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,12 +1081,23 @@ func TestTopicMissingTo(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("topicnoto")
|
token := tserver.createSession("topicnoto")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: "TOPIC",
|
commandKey: "TOPIC",
|
||||||
bodyKey: []string{"topic"},
|
bodyKey: []string{"topic"},
|
||||||
})
|
})
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,11 +1109,22 @@ func TestTopicMissingBody(t *testing.T) {
|
|||||||
commandKey: joinCmd, toKey: "#topictest",
|
commandKey: joinCmd, toKey: "#topictest",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(token, map[string]any{
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
commandKey: "TOPIC", toKey: "#topictest",
|
commandKey: "TOPIC", toKey: "#topictest",
|
||||||
})
|
})
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1027,11 +1192,22 @@ func TestUnknownCommand(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("cmdtest")
|
token := tserver.createSession("cmdtest")
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
status, _ := tserver.sendCommand(
|
status, _ := tserver.sendCommand(
|
||||||
token, map[string]any{commandKey: "BOGUS"},
|
token, map[string]any{commandKey: "BOGUS"},
|
||||||
)
|
)
|
||||||
if status != http.StatusBadRequest {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "421") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,12 +1454,18 @@ func TestLongPollTimeout(t *testing.T) {
|
|||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("lp_timeout")
|
token := tserver.createSession("lp_timeout")
|
||||||
|
|
||||||
|
// Drain initial welcome/MOTD numerics.
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
resp, err := doRequestAuth(
|
resp, err := doRequestAuth(
|
||||||
t,
|
t,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
tserver.url(apiMessages+"?timeout=1"),
|
tserver.url(fmt.Sprintf(
|
||||||
|
"%s?timeout=1&after=%d",
|
||||||
|
apiMessages, lastID,
|
||||||
|
)),
|
||||||
token,
|
token,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID)
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, _, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
payload.Nick,
|
||||||
@@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.deliverMOTD(
|
||||||
|
request, clientID, sessionID, payload.Nick,
|
||||||
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package handlers provides HTTP request handlers for the chat server.
|
// Package handlers provides HTTP request handlers for the neoirc server.
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,12 +9,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/broker"
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package middleware provides HTTP middleware for the chat server.
|
// Package middleware provides HTTP middleware for the neoirc server.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package server implements the main HTTP server for the chat application.
|
// Package server implements the main HTTP server for the neoirc application.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/chat/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/chat/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/chat/internal/handlers"
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||||
"git.eeqj.de/sneak/chat/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/chat/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Message Schemas
|
# Message Schemas
|
||||||
|
|
||||||
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
||||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||||
over HTTP.
|
over HTTP.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"title": "PRIVMSG",
|
||||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"title": "PUBKEY",
|
||||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/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 chat"] }
|
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"title": "IRC Message Envelope",
|
||||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/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 chat.example.com, running version 0.1.0"
|
"Your host is neoirc.example.com, running version 0.1.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/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": [
|
||||||
"chat.example.com",
|
"neoirc.example.com",
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"o",
|
"o",
|
||||||
"imnst+ov"
|
"imnst+ov"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/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 chat"
|
"Welcome to the channel"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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/chat/schema/numerics/353.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
||||||
"title": "401 ERR_NOSUCHNICK",
|
"title": "401 ERR_NOSUCHNICK",
|
||||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
||||||
"title": "403 ERR_NOSUCHCHANNEL",
|
"title": "403 ERR_NOSUCHCHANNEL",
|
||||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
12
web/build.sh
12
web/build.sh
@@ -16,19 +16,11 @@ fi
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Build JS bundle
|
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
||||||
${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\" \
|
||||||
|
|||||||
4
web/dist/app.js
vendored
4
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
4
web/dist/index.html
vendored
4
web/dist/index.html
vendored
@@ -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>Chat</title>
|
<title>NeoIRC</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 src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const RECONNECT_DELAY = 3000;
|
|||||||
const MEMBER_REFRESH_INTERVAL = 10000;
|
const MEMBER_REFRESH_INTERVAL = 10000;
|
||||||
|
|
||||||
function api(path, opts = {}) {
|
function api(path, opts = {}) {
|
||||||
const token = localStorage.getItem('chat_token');
|
const token = localStorage.getItem('neoirc_token');
|
||||||
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
|
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
const { signal, ...rest } = opts;
|
const { signal, ...rest } = opts;
|
||||||
@@ -34,7 +34,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
const [nick, setNick] = useState('');
|
const [nick, setNick] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [motd, setMotd] = useState('');
|
const [motd, setMotd] = useState('');
|
||||||
const [serverName, setServerName] = useState('Chat');
|
const [serverName, setServerName] = useState('NeoIRC');
|
||||||
const inputRef = useRef();
|
const inputRef = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,9 +42,9 @@ function LoginScreen({ onLogin }) {
|
|||||||
if (s.name) setServerName(s.name);
|
if (s.name) setServerName(s.name);
|
||||||
if (s.motd) setMotd(s.motd);
|
if (s.motd) setMotd(s.motd);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
const saved = localStorage.getItem('chat_token');
|
const saved = localStorage.getItem('neoirc_token');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('chat_token'));
|
api('/state').then(u => onLogin(u.nick)).catch(() => localStorage.removeItem('neoirc_token'));
|
||||||
}
|
}
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -57,7 +57,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ nick: nick.trim() })
|
body: JSON.stringify({ nick: nick.trim() })
|
||||||
});
|
});
|
||||||
localStorage.setItem('chat_token', res.token);
|
localStorage.setItem('neoirc_token', res.token);
|
||||||
onLogin(res.nick);
|
onLogin(res.nick);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.data?.error || 'Connection failed');
|
setError(err.data?.error || 'Connection failed');
|
||||||
@@ -132,7 +132,7 @@ function App() {
|
|||||||
// Persist joined channels
|
// Persist joined channels
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
|
const channels = tabs.filter(t => t.type === 'channel').map(t => t.name);
|
||||||
localStorage.setItem('chat_channels', JSON.stringify(channels));
|
localStorage.setItem('neoirc_channels', JSON.stringify(channels));
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
// Clear unread on tab switch
|
// Clear unread on tab switch
|
||||||
@@ -321,7 +321,7 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage('Server', `Connected as ${userNick}`);
|
addSystemMessage('Server', `Connected as ${userNick}`);
|
||||||
// Auto-rejoin saved channels
|
// Auto-rejoin saved channels
|
||||||
const saved = JSON.parse(localStorage.getItem('chat_channels') || '[]');
|
const saved = JSON.parse(localStorage.getItem('neoirc_channels') || '[]');
|
||||||
for (const ch of saved) {
|
for (const ch of saved) {
|
||||||
try {
|
try {
|
||||||
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });
|
await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) });
|
||||||
|
|||||||
@@ -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>Chat</title>
|
<title>NeoIRC</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 src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user