1 Commits

Author SHA1 Message Date
clawbot
3b42620749 feat: split Dockerfile into dedicated lint stage
All checks were successful
check / check (push) Successful in 6s
Use pre-built golangci/golangci-lint:v2.1.6 image for fast lint feedback
instead of installing golangci-lint from source on every build.

- Lint stage: runs fmt-check and lint using pre-built image
- Build stage: runs tests and compiles binaries
- COPY --from=lint forces BuildKit to execute the lint stage
- All images pinned by sha256 digest
- Runtime stage unchanged
2026-03-02 00:03:04 -08:00
70 changed files with 1321 additions and 4026 deletions

View File

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

5
.gitignore vendored
View File

@@ -21,7 +21,7 @@ node_modules/
*.key
# Build artifacts
/neoircd
/chatd
/bin/
*.exe
*.dll
@@ -34,6 +34,5 @@ vendor/
# Project
data.db
debug.log
/neoirc-cli
/chat-cli
web/node_modules/
web/dist/

View File

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

View File

@@ -1,5 +1,5 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
# golangci/golangci-lint:v2.1.6
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
@@ -8,13 +8,13 @@ COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# Build stage — tests and compilation
# golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src
RUN apk add --no-cache git build-base make
# Force BuildKit to run the lint stage before proceeding
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
@@ -26,20 +26,17 @@ RUN make test
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# Runtime stage
# alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
&& mkdir -p /var/lib/neoirc \
&& chown neoirc:neoirc /var/lib/neoirc
COPY --from=builder /neoircd /usr/local/bin/neoircd
&& addgroup -S chat && adduser -S chat -G chat
COPY --from=builder /chatd /usr/local/bin/chatd
USER neoirc
USER chat
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["neoircd"]
ENTRYPOINT ["chatd"]

View File

@@ -1,6 +1,6 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := neoircd
BINARY := chatd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
lint:
golangci-lint run --config .golangci.yml ./...
@@ -27,7 +27,7 @@ test:
# Used by CI and Docker build — fails if anything is wrong
check: test lint fmt-check
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
@echo "==> All checks passed!"
run: build
@@ -37,10 +37,10 @@ debug: build
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
clean:
rm -rf bin/ neoircd
rm -rf bin/ chatd data.db
docker:
docker build -t neoirc .
docker build -t chat .
hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit

447
README.md
View File

@@ -1,9 +1,9 @@
# neoirc
# chat
**IRC semantics, structured message metadata, cryptographic signing, and
server-held session state with per-client delivery queues. All over HTTP+JSON.**
An IRC-inspired server written in Go that decouples session state from transport
A chat server written in Go that decouples session state from transport
connections, enabling mobile-friendly persistent sessions over plain HTTP.
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
or pay for IRCCloud.
This project builds a server that:
This project builds a chat server that:
- Holds session state server-side (message queues, presence, channel membership)
- 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?
XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented
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
they're solving different problems at different scales.
@@ -764,98 +764,21 @@ not pollute the message queue.
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
#### MODE — Query Modes
#### MODE — Set/Query Modes (Planned)
Query channel or user modes. Returns the current mode string and, for
channels, the creation timestamp.
Set channel or user modes.
**C2S:**
```json
{"command": "MODE", "to": "#general"}
{"command": "MODE", "to": "alice"}
{"command": "MODE", "to": "#general", "params": ["+m"]}
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
```
**S2C (via message queue):**
For channels, the server sends RPL_CHANNELMODEIS (324) and
RPL_CREATIONTIME (329):
```json
{"command": "324", "to": "alice", "params": ["#general", "+n"]}
{"command": "329", "to": "alice", "params": ["#general", "1709251200"]}
```
For users, the server sends RPL_UMODEIS (221):
```json
{"command": "221", "to": "alice", "body": ["+"]}
```
**Note:** Mode changes (setting/unsetting modes) are not yet implemented.
Currently only query is supported.
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
planned mode set.
**IRC reference:** RFC 1459 §4.2.3
#### NAMES — Channel Member List
Request the member list for a channel. Returns RPL_NAMREPLY (353) and
RPL_ENDOFNAMES (366).
**C2S:**
```json
{"command": "NAMES", "to": "#general"}
```
**IRC reference:** RFC 1459 §4.2.5
#### LIST — List Channels
Request a list of all channels with member counts. Returns RPL_LIST (322)
for each channel followed by RPL_LISTEND (323).
**C2S:**
```json
{"command": "LIST"}
```
**IRC reference:** RFC 1459 §4.2.6
#### WHOIS — User Information
Query information about a user. Returns RPL_WHOISUSER (311),
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
**C2S:**
```json
{"command": "WHOIS", "to": "alice"}
```
**IRC reference:** RFC 1459 §4.5.2
#### WHO — Channel User List
Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed
by RPL_ENDOFWHO (315).
**C2S:**
```json
{"command": "WHO", "to": "#general"}
```
**IRC reference:** RFC 1459 §4.5.1
#### LUSERS — Server Statistics
Request server user/channel statistics. Returns RPL_LUSERCLIENT (251),
RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255).
**C2S:**
```json
{"command": "LUSERS"}
```
LUSERS replies are also sent automatically during connection registration.
**IRC reference:** RFC 1459 §4.3.2
#### KICK — Kick User (Planned)
Remove a user from a channel.
@@ -905,46 +828,27 @@ the server to the client (never C2S) and use 3-digit string codes in the
| Code | Name | When Sent | Example |
|------|----------------------|-----------|---------|
| `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 neoirc, running version 0.1"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` |
| `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":["neoirc","0.1","","imnst"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
| `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"]}` |
| `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":["- neoirc-server Message of the Day -"]}` |
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` |
| `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"]}` |
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` |
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` |
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are now implemented. All IRC command responses
(success and error) are delivered as numeric replies through the message queue.
HTTP error codes are reserved for transport-level issues (auth failures,
malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
**Note:** Numeric replies are planned for full implementation. The current MVP
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
of numeric replies for error conditions. Numeric replies in the message queue
will be added post-MVP.
### Channel Modes
@@ -987,18 +891,7 @@ the format:
Create a new user session. This is the entry point for all clients.
If the server requires hashcash proof-of-work (see
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
valid stamp in the `X-Hashcash` request header. The required difficulty is
advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Headers:**
| Header | Required | Description |
|--------------|----------|-------------|
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Request Body:**
**Request:**
```json
{"nick": "alice"}
```
@@ -1027,15 +920,12 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| Status | Error | When |
|--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled |
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
-d '{"nick":"alice"}' | jq -r .token)
echo $TOKEN
```
@@ -1164,78 +1054,27 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
| `LIST` | | | 200 OK |
| `WHOIS` | `to` or `body` | | 200 OK |
| `WHO` | `to` | | 200 OK |
| `LUSERS` | | | 200 OK |
| `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK |
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):**
**Errors (all commands):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command |
| 400 | `invalid request` | Malformed JSON |
| 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 |
| 500 | `internal error` | Server-side failure |
**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 |
| 002 | RPL_YOURHOST | Sent on session creation/login |
| 003 | RPL_CREATED | Sent on session creation/login |
| 004 | RPL_MYINFO | Sent on session creation/login |
| 005 | RPL_ISUPPORT | Sent on session creation/login |
| 221 | RPL_UMODEIS | In response to user MODE query |
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
| 252 | RPL_LUSEROP | On connect or LUSERS command |
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
| 255 | RPL_LUSERME | On connect or LUSERS command |
| 311 | RPL_WHOISUSER | WHOIS user info |
| 312 | RPL_WHOISSERVER | WHOIS server info |
| 315 | RPL_ENDOFWHO | End of WHO list |
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
| 322 | RPL_LIST | Channel in LIST response |
| 323 | RPL_LISTEND | End of LIST |
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
| 329 | RPL_CREATIONTIME | Channel creation timestamp |
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
| 352 | RPL_WHOREPLY | User in WHO response |
| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) |
| 366 | RPL_ENDOFNAMES | End of NAMES list |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 372 | RPL_MOTD | MOTD line |
| 376 | RPL_ENDOFMOTD | End of MOTD |
| 404 | `channel not found` | Target channel doesn't exist |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken |
### GET /api/v1/history — Message History
@@ -1375,19 +1214,17 @@ Return server metadata. No authentication required.
**Response:** `200 OK`
```json
{
"name": "My NeoIRC Server",
"name": "My Chat Server",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
"users": 42
}
```
| Field | Type | Description |
|-----------------|---------|-------------|
|---------|---------|-------------|
| `name` | string | Server display name |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
### GET /.well-known/healthcheck.json — Health Check
@@ -1631,7 +1468,7 @@ authenticity.
## Federation (Server-to-Server)
Federation allows multiple neoirc servers to link together, forming a network
Federation allows multiple chat servers to link together, forming a network
where users on different servers can share channels — similar to IRC server
linking.
@@ -1808,7 +1645,7 @@ directory is also loaded automatically via
| Variable | Type | Default | Description |
|--------------------|---------|--------------------------------------|-------------|
| `PORT` | int | `8080` | HTTP listen port |
| `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`. |
| `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`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `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. |
@@ -1821,19 +1658,17 @@ directory is also loaded automatically via
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file
```bash
PORT=8080
SERVER_NAME=My NeoIRC Server
SERVER_NAME=My Chat Server
MOTD=Welcome! Be excellent to each other.
DEBUG=false
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
DBURL=file:./data.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h
NEOIRC_HASHCASH_BITS=20
```
---
@@ -1842,24 +1677,25 @@ NEOIRC_HASHCASH_BITS=20
### Docker (Recommended)
The Docker image contains a single static binary (`neoircd`) and nothing else.
The Docker image contains a single static binary (`chatd`) and nothing else.
```bash
# Build
docker build -t neoirc .
docker build -t chat .
# Run
docker run -p 8080:8080 \
-v neoirc-data:/var/lib/neoirc \
-v chat-data:/data \
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
-e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \
neoirc
chat
```
The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify
compilation, not included in final image)
2. **Final stage**: Alpine Linux + `neoircd` binary only
2. **Final stage**: Alpine Linux + `chatd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
@@ -1868,13 +1704,13 @@ RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /neoircd ./cmd/neoircd/
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
RUN go build -o /chatd ./cmd/chatd/
RUN go build -o /chat-cli ./cmd/chat-cli/
FROM alpine:latest
COPY --from=builder /neoircd /usr/local/bin/neoircd
COPY --from=builder /chatd /usr/local/bin/chatd
EXPOSE 8080
CMD ["neoircd"]
CMD ["chatd"]
```
### Binary
@@ -1882,11 +1718,11 @@ CMD ["neoircd"]
```bash
# Build from source
make build
# Binary at ./bin/neoircd
# Binary at ./bin/chatd
# Run
./bin/neoircd
# Listens on :8080, writes to /var/lib/neoirc/state.db
./bin/chatd
# Listens on :8080, creates ./data.db
```
### Reverse Proxy (Production)
@@ -1895,7 +1731,7 @@ For production, run behind a TLS-terminating reverse proxy.
**Caddy:**
```
neoirc.example.com {
chat.example.com {
reverse_proxy localhost:8080
}
```
@@ -1904,7 +1740,7 @@ neoirc.example.com {
```nginx
server {
listen 443 ssl;
server_name neoirc.example.com;
server_name chat.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
@@ -1927,15 +1763,15 @@ seconds to accommodate long-poll connections.
string). This allows concurrent reads during writes.
- **Single writer**: SQLite allows only one writer at a time. For high-traffic
servers, Postgres support is planned.
- **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, `state.db` is created in `/var/lib/neoirc/`.
- **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).
- **Location**: By default, `data.db` is created in the working directory.
Use the `DBURL` env var to place it elsewhere.
---
## Client Development Guide
This section explains how to write a client against the neoirc API. The API is
This section explains how to write a client against the chat API. The API is
designed to be simple enough that a basic client can be written in any language
with an HTTP client library.
@@ -2116,102 +1952,62 @@ Clients should handle these message commands from the queue:
## Rate Limiting & Abuse Prevention
### Hashcash Proof-of-Work
Session creation (`POST /api/v1/session`) requires a
Session creation (`POST /api/v1/session`) will require a
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
This is the primary defense against resource exhaustion — no CAPTCHAs, no
account registration, no IP-based rate limits that punish shared networks.
### How It Works
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
field (e.g., `20`) indicating the required difficulty.
2. Client computes a hashcash stamp: find a counter value such that the
SHA-256 hash of the stamp string has the required number of leading zero
bits.
3. Client includes the stamp in the `X-Hashcash` request header when creating
a session: `POST /api/v1/session`.
4. Server validates the stamp:
- Version is `1`
- Claimed bits ≥ required bits
- Resource matches the server name
- Date is within 48 hours (not expired, not too far in the future)
- SHA-256 hash has the required leading zero bits
- Stamp has not been used before (replay prevention)
### Stamp Format
Standard hashcash format:
1. Client requests a challenge: `GET /api/v1/challenge`
```json
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
```
1:bits:date:resource::counter
2. Server returns a nonce and a required difficulty (number of leading zero
bits in the SHA-256 hash)
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
has the required number of leading zero bits:
```
| Field | Description |
|------------|-------------|
| `1` | Version (always `1`) |
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
| (empty) | Extension field (unused) |
| `counter` | Hex counter value found by the client to satisfy the PoW |
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
### Computing a Stamp
```bash
# Pseudocode
bits = 20
resource = "neoirc"
date = "260310" # YYMMDD in UTC
counter = 0
loop:
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
hash = SHA-256(stamp)
if leading_zero_bits(hash) >= bits:
return stamp
counter++
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
...
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
```
4. Client submits the proof with the session request:
```json
POST /api/v1/session
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
```
5. Server verifies:
- Nonce was issued by this server and hasn't expired
- Nonce hasn't been used before (prevent replay)
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
- If valid, create the session normally
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
average, taking roughly 0.52 seconds on modern hardware.
### Adaptive Difficulty
### Client Integration
The required difficulty scales with server load. Under normal conditions, the
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
session creation rate increases, difficulty rises — making bulk session creation
exponentially more expensive for attackers while remaining cheap for legitimate
single-user connections.
Both the embedded web SPA and the CLI client automatically handle hashcash:
| Server Load | Difficulty (bits) | Approx. Client CPU |
|--------------------|-------------------|--------------------|
| Normal (< 100/min) | 16 | ~1ms |
| Elevated | 20 | ~15ms |
| High | 24 | ~250ms |
| Under attack | 28+ | ~4s+ |
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
2. If `hashcash_bits > 0`, compute a valid stamp
3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session`
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
### Configuration
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
| Value | Effect | Approx. Client CPU |
|-------|--------|---------------------|
| `0` | Disabled (no proof-of-work required) | — |
| `16` | Light protection | ~1ms |
| `20` | Default — good balance | ~0.52s |
| `24` | Strong protection | ~1030s |
| `28` | Very strong (may frustrate users) | ~210min |
Each additional bit doubles the expected work. An attacker creating 1000
sessions at difficulty 20 needs ~10002000 CPU-seconds; a legitimate user
creating one session pays once and keeps their session.
Each additional bit of difficulty doubles the expected work. An attacker
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
user creating one session needs ~4 seconds once and never again for the
duration of their session.
### Why Hashcash and Not Rate Limits?
- **No state to track**: No IP tables, no token buckets, no sliding windows.
The server only needs to verify a single hash.
The server only needs to verify a hash.
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
campuses, corporate networks, Tor exits). Every client computes their own
proof independently.
@@ -2219,9 +2015,36 @@ creating one session pays once and keeps their session.
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
No registration, no email, no phone number, no CAPTCHA. Just compute.
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
once. A botnet trying to create thousands of sessions pays exponentially more.
- **Language-agnostic**: SHA-256 is available in every programming language.
The proof computation is trivially implementable in any client.
### Challenge Endpoint (Planned)
```
GET /api/v1/challenge
```
**Response:** `200 OK`
```json
{
"nonce": "a1b2c3d4e5f6...",
"difficulty": 20,
"algorithm": "sha256",
"expires": "2026-02-10T20:01:00Z"
}
```
| Field | Type | Description |
|--------------|---------|-------------|
| `nonce` | string | Server-generated random hex string (32+ chars) |
| `difficulty` | integer | Required number of leading zero bits in the hash |
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
| `expires` | string | ISO 8601 expiry time for this challenge |
**Status:** Not yet implemented. Tracked for post-MVP.
---
## Roadmap
@@ -2238,7 +2061,7 @@ creating one session pays once and keeps their session.
- [x] NICK change with broadcast
- [x] QUIT with broadcast and cleanup
- [x] Embedded web SPA client
- [x] CLI client (neoirc-cli)
- [x] CLI client (chat-cli)
- [x] SQLite storage with WAL mode
- [x] Docker deployment
- [x] Prometheus metrics endpoint
@@ -2250,23 +2073,15 @@ creating one session pays once and keeps their session.
### Post-MVP (Planned)
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [x] **MODE command** — query channel and user modes (set not yet implemented)
- [x] **NAMES command** — query channel member list
- [x] **LIST command** — list all channels with member counts
- [x] **WHOIS command** — query user information and channel membership
- [x] **WHO command** — query channel user list
- [x] **LUSERS command** — query server statistics
- [x] **Connection registration numerics** — 001-005 sent on session creation
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
- [ ] **MODE command** — set/query channel and user modes
- [ ] **KICK command** — remove users from channels
- [x] **Numeric replies** — send IRC numeric codes via the message queue
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Numeric replies** — send IRC numeric codes via the message queue
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session
@@ -2286,7 +2101,7 @@ creating one session pays once and keeps their session.
- [ ] **Push notifications** — optional webhook/push for mobile clients
when messages arrive during disconnect
- [ ] **Message search** — full-text search over channel history
- [x] **User info command** — WHOIS for querying user info and channels
- [ ] **User info command** — WHOIS-equivalent for querying user metadata
- [ ] **Connection flood protection** — per-IP connection limits as a
complement to hashcash
- [ ] **Invite system** — `INVITE` command for `+i` channels
@@ -2299,11 +2114,11 @@ creating one session pays once and keeps their session.
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
```
neoirc/
chat/
├── cmd/
│ ├── neoircd/ # Server binary entry point
│ ├── chatd/ # Server binary entry point
│ │ └── main.go
│ └── neoirc-cli/ # TUI client
│ └── chat-cli/ # TUI client
│ ├── main.go # Command handling, poll loop
│ ├── ui.go # tview-based terminal UI
│ └── api/
@@ -2434,7 +2249,7 @@ neoirc/
- IRC message envelope format with per-client queue fan-out
- Long-polling with in-memory broker
- Embedded web SPA client
- TUI client (neoirc-cli)
- TUI client (chat-cli)
- Docker image
- Prometheus metrics

View File

@@ -1,5 +1,5 @@
// Package neoircapi provides a client for the neoirc server API.
package neoircapi
// Package chatapi provides a client for the chat server API.
package chatapi
import (
"bytes"
@@ -13,8 +13,6 @@ import (
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
)
const (
@@ -25,7 +23,7 @@ const (
var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
// Client wraps HTTP calls to the chat server API.
type Client struct {
BaseURL string
Token string
@@ -43,34 +41,13 @@ func NewClient(baseURL string) *Client {
}
// CreateSession creates a new session on the server.
// If the server requires hashcash proof-of-work, it
// automatically fetches the difficulty and computes a
// valid stamp.
func (client *Client) CreateSession(
nick string,
) (*SessionResponse, error) {
// Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo()
var headers map[string]string
if err == nil && info.HashcashBits > 0 {
resource := info.Name
if resource == "" {
resource = "neoirc"
}
stamp := MintHashcash(info.HashcashBits, resource)
headers = map[string]string{
"X-Hashcash": stamp,
}
}
data, err := client.doWithHeaders(
data, err := client.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick},
headers,
)
if err != nil {
return nil, err
@@ -191,7 +168,7 @@ func (client *Client) PollMessages(
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel,
Command: "JOIN", To: channel,
},
)
}
@@ -200,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
func (client *Client) PartChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdPart, To: channel,
Command: "PART", To: channel,
},
)
}
@@ -282,16 +259,6 @@ func (client *Client) GetServerInfo() (
func (client *Client) do(
method, path string,
body any,
) ([]byte, error) {
return client.doWithHeaders(
method, path, body, nil,
)
}
func (client *Client) doWithHeaders(
method, path string,
body any,
extraHeaders map[string]string,
) ([]byte, error) {
var bodyReader io.Reader
@@ -324,10 +291,6 @@ func (client *Client) doWithHeaders(
)
}
for key, val := range extraHeaders {
request.Header.Set(key, val)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)

View File

@@ -1,4 +1,4 @@
package neoircapi
package chatapi
import "time"
@@ -21,7 +21,7 @@ type StateResponse struct {
Channels []string `json:"channels"`
}
// Message represents a neoirc message envelope.
// Message represents a chat message envelope.
type Message struct {
Command string `json:"command"`
From string `json:"from,omitempty"`
@@ -66,7 +66,6 @@ type ServerInfo struct {
Name string `json:"name"`
MOTD string `json:"motd"`
Version string `json:"version"`
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
}
// MessagesResponse wraps polling results.

View File

@@ -1,4 +1,4 @@
// Package main is the entry point for the neoirc-cli client.
// Package main is the entry point for the chat-cli client.
package main
import (
@@ -8,8 +8,7 @@ import (
"sync"
"time"
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
"git.eeqj.de/sneak/neoirc/internal/irc"
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
)
const (
@@ -42,7 +41,7 @@ func main() {
app.ui.SetStatus(app.nick, "", "disconnected")
app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client",
"Welcome to chat-cli — an IRC-style client",
)
app.ui.AddStatus(
"Type [yellow]/connect <server-url>" +
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdPrivmsg,
Command: "PRIVMSG",
To: target,
Body: []string{text},
})
@@ -139,29 +138,16 @@ func (a *App) dispatchCommand(cmd, args string) {
a.cmdQuery(args)
case "/topic":
a.cmdTopic(args)
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/window", "/w":
a.cmdWindow(args)
case "/quit":
a.cmdQuit()
case "/help":
a.cmdHelp()
default:
a.dispatchInfoCommand(cmd, args)
}
}
func (a *App) dispatchInfoCommand(cmd, args string) {
switch cmd {
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/motd":
a.cmdMotd()
case "/who":
a.cmdWho(args)
case "/whois":
a.cmdWhois(args)
default:
a.ui.AddStatus(
"[red]Unknown command: " + cmd,
@@ -242,7 +228,7 @@ func (a *App) cmdNick(nick string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdNick,
Command: "NICK",
Body: []string{nick},
})
if err != nil {
@@ -377,7 +363,7 @@ func (a *App) cmdMsg(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdPrivmsg,
Command: "PRIVMSG",
To: target,
Body: []string{text},
})
@@ -435,7 +421,7 @@ func (a *App) cmdTopic(args string) {
if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
Command: "TOPIC",
To: target,
})
if err != nil {
@@ -448,7 +434,7 @@ func (a *App) cmdTopic(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
Command: "TOPIC",
To: target,
Body: []string{args},
})
@@ -524,96 +510,6 @@ func (a *App) cmdList() {
a.ui.AddStatus("[cyan]*** End of channel list")
}
func (a *App) cmdMotd() {
a.mu.Lock()
connected := a.connected
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
err := a.client.SendMessage(
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]MOTD failed: %v", err,
))
}
}
func (a *App) cmdWho(args string) {
a.mu.Lock()
connected := a.connected
target := a.target
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
channel := args
if channel == "" {
channel = target
}
if channel == "" ||
!strings.HasPrefix(channel, "#") {
a.ui.AddStatus(
"[red]Usage: /who #channel",
)
return
}
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: irc.CmdWho, To: channel,
},
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]WHO failed: %v", err,
))
}
}
func (a *App) cmdWhois(args string) {
a.mu.Lock()
connected := a.connected
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
if args == "" {
a.ui.AddStatus(
"[red]Usage: /whois <nick>",
)
return
}
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: irc.CmdWhois, To: args,
},
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]WHOIS failed: %v", err,
))
}
}
func (a *App) cmdWindow(args string) {
if args == "" {
a.ui.AddStatus(
@@ -654,7 +550,7 @@ func (a *App) cmdQuit() {
if a.connected && a.client != nil {
_ = a.client.SendMessage(
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
)
}
@@ -668,7 +564,7 @@ func (a *App) cmdQuit() {
func (a *App) cmdHelp() {
help := []string{
"[cyan]*** neoirc-cli commands:",
"[cyan]*** chat-cli commands:",
" /connect <url> — Connect to server",
" /nick <name> — Change nickname",
" /join #channel — Join channel",
@@ -678,9 +574,6 @@ func (a *App) cmdHelp() {
" /topic [text] — View/set topic",
" /names — List channel members",
" /list — List channels",
" /who [#channel] — List users in channel",
" /whois <nick> — Show user info",
" /motd — Show message of the day",
" /window <n> — Switch buffer",
" /quit — Disconnect and exit",
" /help — This help",
@@ -739,19 +632,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
a.mu.Unlock()
switch msg.Command {
case irc.CmdPrivmsg:
case "PRIVMSG":
a.handlePrivmsgEvent(msg, timestamp, myNick)
case irc.CmdJoin:
case "JOIN":
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
case "PART":
a.handlePartEvent(msg, timestamp)
case irc.CmdQuit:
case "QUIT":
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
case "NICK":
a.handleNickEvent(msg, timestamp, myNick)
case irc.CmdNotice:
case "NOTICE":
a.handleNoticeEvent(msg, timestamp)
case irc.CmdTopic:
case "TOPIC":
a.handleTopicEvent(msg, timestamp)
default:
a.handleDefaultEvent(msg, timestamp)

View File

@@ -1,21 +1,21 @@
// Package main is the entry point for the neoircd server.
// Package main is the entry point for the chatd server.
package main
import (
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server"
"go.uber.org/fx"
)
var (
// Appname is the application name, set at build time.
Appname = "neoirc" //nolint:gochecknoglobals
Appname = "chat" //nolint:gochecknoglobals
// Version is the application version, set at build time.
Version string //nolint:gochecknoglobals

View File

@@ -1,79 +0,0 @@
package neoircapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"time"
)
const (
// bitsPerByte is the number of bits in a byte.
bitsPerByte = 8
// fullByteMask is 0xFF, a mask for all bits in a byte.
fullByteMask = 0xFF
// counterSpace is the range for random counter seeds.
counterSpace = 1 << 48
)
// MintHashcash computes a hashcash stamp with the given
// difficulty (leading zero bits) and resource string.
func MintHashcash(bits int, resource string) string {
date := time.Now().UTC().Format("060102")
prefix := fmt.Sprintf(
"1:%d:%s:%s::", bits, date, resource,
)
for {
counter := randomCounter()
stamp := prefix + counter
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
}
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / bitsPerByte
remainBits := numBits % bitsPerByte
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(
fullByteMask << (bitsPerByte - remainBits),
)
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
// randomCounter generates a random hex counter string.
func randomCounter() string {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(counterSpace),
)
if err != nil {
// Fallback to timestamp-based counter on error.
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(counterVal.Bytes())
}

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/neoirc
module git.eeqj.de/sneak/chat
go 1.24.0

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/chat/internal/broker"
)
func TestNewBroker(t *testing.T) {

View File

@@ -5,22 +5,14 @@ import (
"errors"
"log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger"
"github.com/spf13/viper"
"go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // loads .env file
)
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// Params defines the dependencies for creating a Config.
type Params struct {
fx.In
@@ -44,7 +36,6 @@ type Config struct {
ServerName string
FederationKey string
SessionIdleTimeout string
HashcashBits int
params *Params
log *slog.Logger
}
@@ -65,17 +56,16 @@ func New(
viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("MOTD", "")
viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig()
if err != nil {
@@ -100,7 +90,6 @@ func New(
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
log: log,
params: &params,
}

View File

@@ -12,8 +12,8 @@ import (
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/logger"
"go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // .env
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL
if dbURL == "" {
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
}
database.log.Info(

View File

@@ -7,10 +7,8 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid"
)
@@ -35,25 +33,14 @@ func generateToken() (string, error) {
type IRCMessage struct {
ID string `json:"id"`
Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"`
DBID int64 `json:"-"`
}
// isNumericCode returns true if s is exactly a 3-digit
// IRC numeric reply code.
func isNumericCode(s string) bool {
return len(s) == 3 &&
s[0] >= '0' && s[0] <= '9' &&
s[1] >= '0' && s[1] <= '9' &&
s[2] >= '0' && s[2] <= '9'
}
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID int64 `json:"id"`
@@ -504,17 +491,12 @@ func (database *Database) GetSessionChannelIDs(
func (database *Database) InsertMessage(
ctx context.Context,
command, from, target string,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) {
msgUUID := uuid.New().String()
now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil {
body = json.RawMessage("[]")
}
@@ -526,10 +508,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages
(uuid, command, msg_from, msg_to,
params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target,
string(params), string(body), string(meta), now)
string(body), string(meta), now)
if err != nil {
return 0, "", fmt.Errorf(
"insert message: %w", err,
@@ -596,7 +578,7 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to,
m.params, m.body, m.meta, m.created_at
m.body, m.meta, m.created_at
FROM client_queues cq
INNER JOIN messages m
ON m.id = cq.message_id
@@ -660,7 +642,7 @@ func (database *Database) queryHistory(
if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG'
@@ -677,7 +659,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at
msg_to, body, meta, created_at
FROM messages
WHERE msg_to = ?
AND command = 'PRIVMSG'
@@ -704,14 +686,14 @@ func scanMessages(
var (
msg IRCMessage
qID int64
params, body, meta string
body, meta string
createdAt time.Time
)
err := rows.Scan(
&qID, &msg.ID, &msg.Command,
&msg.From, &msg.To,
&params, &body, &meta, &createdAt,
&body, &meta, &createdAt,
)
if err != nil {
return nil, fallbackQID, fmt.Errorf(
@@ -719,25 +701,12 @@ func scanMessages(
)
}
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano)
msg.DBID = qID
lastQID = qID
if isNumericCode(msg.Command) {
code, _ := strconv.Atoi(msg.Command)
msg.Code = code
if name := irc.Name(code); name != "" {
msg.Command = name
}
}
msgs = append(msgs, msg)
}
@@ -974,125 +943,3 @@ func (database *Database) GetSessionChannels(
return scanChannels(rows)
}
// GetChannelCount returns the total number of channels.
func (database *Database) GetChannelCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM channels",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get channel count: %w", err,
)
}
return count, nil
}
// ChannelInfoFull contains extended channel information.
type ChannelInfoFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
MemberCount int64 `json:"memberCount"`
}
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a
// channel.
func (database *Database) GetChannelCreatedAt(
ctx context.Context,
channelID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM channels WHERE id = ?",
channelID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get channel created_at: %w", err,
)
}
return createdAt, nil
}
// GetSessionCreatedAt returns the creation time of a
// session.
func (database *Database) GetSessionCreatedAt(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM sessions WHERE id = ?",
sessionID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session created_at: %w", err,
)
}
return createdAt, nil
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/chat/internal/db"
_ "modernc.org/sqlite"
)
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil {
t.Fatal(err)
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
ctx, "PRIVMSG", "poller", "#test", body, nil,
)
if err != nil {
t.Fatal(err)
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
for range msgCount {
_, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist",
nil, json.RawMessage(`["msg"]`), nil,
json.RawMessage(`["msg"]`), nil,
)
if err != nil {
t.Fatal(err)
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
ctx, "PRIVMSG", "sender", "#ch", body, nil,
)
if err != nil {
t.Fatal(err)

View File

@@ -50,7 +50,6 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,19 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -85,7 +84,6 @@ func newTestServer(
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
return cfg, nil
},
@@ -117,9 +115,8 @@ func newTestServer(
func newTestGlobals() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Appname: "chat-test",
Version: "test",
StartTime: time.Now(),
}
}
@@ -465,22 +462,6 @@ func findMessage(
return false
}
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return true
}
}
return false
}
// --- Tests ---
func TestCreateSessionValid(t *testing.T) {
@@ -492,47 +473,6 @@ func TestCreateSessionValid(t *testing.T) {
}
}
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t)
tserver.createSession("alice")
@@ -728,23 +668,11 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -754,10 +682,10 @@ func TestChannelMessage(t *testing.T) {
bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
commandKey: joinCmd, toKey: "#chat",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
commandKey: joinCmd, toKey: "#chat",
})
_, _ = tserver.pollMessages(aliceToken, 0)
@@ -767,13 +695,13 @@ func TestChannelMessage(t *testing.T) {
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#test",
toKey: "#chat",
bodyKey: []string{"hello world"},
},
)
if status != http.StatusOK {
if status != http.StatusCreated {
t.Fatalf(
"expected 200, got %d: %v", status, result,
"expected 201, got %d: %v", status, result,
)
}
@@ -797,25 +725,14 @@ func TestMessageMissingBody(t *testing.T) {
token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#test",
commandKey: joinCmd, toKey: "#chat",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#test",
commandKey: privmsgCmd, toKey: "#chat",
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -823,23 +740,12 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
bodyKey: []string{"hello"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -853,8 +759,6 @@ func TestNonMemberCannotSend(t *testing.T) {
commandKey: joinCmd, toKey: "#private",
})
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining.
status, _ := tserver.sendCommand(
aliceToken,
@@ -864,17 +768,8 @@ func TestNonMemberCannotSend(t *testing.T) {
bodyKey: []string{"sneaky"},
},
)
if status != http.StatusOK {
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,
)
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d", status)
}
}
@@ -891,9 +786,9 @@ func TestDirectMessage(t *testing.T) {
bodyKey: []string{"hey bob"},
},
)
if status != http.StatusOK {
if status != http.StatusCreated {
t.Fatalf(
"expected 200, got %d: %v", status, result,
"expected 201, got %d: %v", status, result,
)
}
@@ -923,24 +818,13 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "nobody",
bodyKey: []string{"hello?"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
if status != http.StatusNotFound {
t.Fatalf("expected 404, got %d", status)
}
}
@@ -987,23 +871,12 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"taken_nick"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
if status != http.StatusConflict {
t.Fatalf("expected 409, got %d", status)
}
}
@@ -1011,23 +884,12 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"bad nick!"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -1035,22 +897,11 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -1087,23 +938,12 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC",
bodyKey: []string{"topic"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -1115,22 +955,11 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest",
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -1198,22 +1027,11 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
@@ -1460,18 +1278,12 @@ func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now()
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
tserver.url(apiMessages+"?timeout=1"),
token,
nil,
)

View File

@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
return
}
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.deliverMOTD(request, clientID, sessionID)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
return
}
sessionID, clientID, token, err :=
sessionID, _, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
@@ -178,10 +178,6 @@ func (hdlr *Handlers) handleLogin(
return
}
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,

View File

@@ -1,4 +1,4 @@
// Package handlers provides HTTP request handlers for the neoirc server.
// Package handlers provides HTTP request handlers for the chat server.
package handlers
import (
@@ -9,13 +9,12 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"go.uber.org/fx"
)
@@ -40,7 +39,6 @@ type Handlers struct {
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
cancelCleanup context.CancelFunc
}
@@ -49,17 +47,11 @@ func New(
lifecycle fx.Lifecycle,
params Params,
) (*Handlers, error) {
resource := params.Config.ServerName
if resource == "" {
resource = "neoirc"
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
}
lifecycle.Append(fx.Hook{

View File

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

View File

@@ -6,10 +6,10 @@ import (
"log/slog"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger"
"go.uber.org/fx"
)

View File

@@ -1,21 +0,0 @@
package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTopic = "TOPIC"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)

View File

@@ -1,150 +0,0 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
// Connection registration replies (001-005).
const (
RplWelcome = 1
RplYourHost = 2
RplCreated = 3
RplMyInfo = 4
RplIsupport = 5
)
// Command responses (200-399).
const (
RplUmodeIs = 221
RplLuserClient = 251
RplLuserOp = 252
RplLuserUnknown = 253
RplLuserChannels = 254
RplLuserMe = 255
RplAway = 301
RplUserHost = 302
RplIson = 303
RplUnaway = 305
RplNowAway = 306
RplWhoisUser = 311
RplWhoisServer = 312
RplWhoisOperator = 313
RplEndOfWho = 315
RplWhoisIdle = 317
RplEndOfWhois = 318
RplWhoisChannels = 319
RplList = 322
RplListEnd = 323
RplChannelModeIs = 324
RplCreationTime = 329
RplNoTopic = 331
RplTopic = 332
RplTopicWhoTime = 333
RplInviting = 341
RplWhoReply = 352
RplNamReply = 353
RplEndOfNames = 366
RplBanList = 367
RplEndOfBanList = 368
RplMotd = 372
RplMotdStart = 375
RplEndOfMotd = 376
)
// Error replies (400-599).
const (
ErrNoSuchNick = 401
ErrNoSuchServer = 402
ErrNoSuchChannel = 403
ErrCannotSendToChan = 404
ErrTooManyChannels = 405
ErrNoRecipient = 411
ErrNoTextToSend = 412
ErrUnknownCommand = 421
ErrNoNicknameGiven = 431
ErrErroneusNickname = 432
ErrNicknameInUse = 433
ErrUserNotInChannel = 441
ErrNotOnChannel = 442
ErrNotRegistered = 451
ErrNeedMoreParams = 461
ErrAlreadyRegistered = 462
ErrChannelIsFull = 471
ErrInviteOnlyChan = 473
ErrBannedFromChan = 474
ErrBadChannelKey = 475
ErrChanOpPrivsNeeded = 482
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[int]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplIsupport: "RPL_ISUPPORT",
RplUmodeIs: "RPL_UMODEIS",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplMotd: "RPL_MOTD",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
func Name(code int) string {
return names[code]
}

View File

@@ -5,7 +5,7 @@ import (
"log/slog"
"os"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/chat/internal/globals"
"go.uber.org/fx"
)

View File

@@ -1,4 +1,4 @@
// Package middleware provides HTTP middleware for the neoirc server.
// Package middleware provides HTTP middleware for the chat server.
package middleware
import (
@@ -7,9 +7,9 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger"
basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"

View File

@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/web"
"git.eeqj.de/sneak/chat/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"

View File

@@ -1,4 +1,4 @@
// Package server implements the main HTTP server for the neoirc application.
// Package server implements the main HTTP server for the chat application.
package server
import (
@@ -12,11 +12,11 @@ import (
"syscall"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware"
"go.uber.org/fx"
"github.com/getsentry/sentry-go"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
"title": "PRIVMSG",
"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",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
web/dist/app.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,466 +1,317 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0e14;
--bg-panel: #0d1117;
--bg-input: #0d1117;
--bg-tab: #161b22;
--bg-tab-active: #0d1117;
--bg-topic: #0d1117;
--text: #c9d1d9;
--text-dim: #6e7681;
--text-bright: #e6edf3;
--accent: #58a6ff;
--accent-dim: #1f6feb;
--border: #21262d;
--system: #7d8590;
--action: #d2a8ff;
--warn: #d29922;
--error: #f85149;
--unread: #f0883e;
--nick-brackets: #6e7681;
--timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
--bg: #1a1a2e;
--bg-secondary: #16213e;
--bg-input: #0f3460;
--text: #e0e0e0;
--text-muted: #888;
--accent: #e94560;
--accent2: #0f3460;
--border: #2a2a4a;
--nick: #53a8b6;
--timestamp: #666;
--tab-active: #e94560;
--tab-bg: #16213e;
--tab-hover: #1a1a3e;
--topic-bg: #121a30;
--unread-bg: #e94560;
--warn: #f0ad4e;
}
html,
body,
#root {
html, body, #root {
height: 100%;
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
"Consolas", "Liberation Mono", "Courier New", monospace;
font-size: 13px;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ============================================
Login Screen
============================================ */
/* Login screen */
.login-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: var(--bg);
gap: 16px;
}
.login-box {
text-align: center;
max-width: 360px;
width: 100%;
padding: 32px;
}
.login-box h1 {
.login-screen h1 {
color: var(--accent);
font-size: 1.8em;
margin-bottom: 16px;
font-weight: 400;
font-size: 2em;
}
.login-box .motd {
color: var(--accent);
font-size: 11px;
margin-bottom: 20px;
text-align: left;
white-space: pre;
.login-screen input {
padding: 10px 16px;
font-size: 16px;
font-family: inherit;
line-height: 1.2;
overflow-x: auto;
}
.login-box form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.login-box label {
color: var(--text-dim);
text-align: left;
font-size: 12px;
}
.login-box input {
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
background: var(--input-bg);
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-bright);
border-radius: 3px;
outline: none;
color: var(--text);
border-radius: 4px;
width: 280px;
}
.login-box input:focus {
border-color: var(--accent-dim);
}
.login-box button {
padding: 8px 16px;
.login-screen button {
padding: 10px 24px;
font-size: 16px;
font-family: inherit;
font-size: 14px;
background: var(--accent-dim);
border: none;
color: var(--text-bright);
border-radius: 3px;
cursor: pointer;
margin-top: 4px;
}
.login-box button:hover {
background: var(--accent);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
.login-box .error {
color: var(--error);
font-size: 12px;
margin-top: 8px;
.login-screen .error {
color: var(--accent);
}
/* ============================================
IRC App Layout
============================================ */
.login-screen .motd {
color: var(--text-muted);
max-width: 400px;
text-align: center;
white-space: pre-wrap;
}
.irc-app {
/* Main layout */
.app {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ============================================
Tab Bar
============================================ */
/* Tab bar */
.tab-bar {
display: flex;
background: var(--bg-tab);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
height: 32px;
align-items: stretch;
}
.tabs {
display: flex;
overflow-x: auto;
flex: 1;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
flex-shrink: 0;
align-items: center;
}
.tab {
display: flex;
align-items: center;
padding: 0 12px;
padding: 8px 16px;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
white-space: nowrap;
color: var(--text-muted);
user-select: none;
border-right: 1px solid var(--border);
font-size: 12px;
gap: 4px;
position: relative;
}
.tab:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.03);
background: var(--tab-hover);
}
.tab.active {
color: var(--text-bright);
background: var(--bg-tab-active);
border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
color: var(--text);
border-bottom-color: var(--tab-active);
}
.tab.has-unread .tab-label {
color: var(--unread);
font-weight: bold;
}
.tab .unread-count {
color: var(--unread);
font-size: 11px;
font-weight: bold;
}
.tab-close {
color: var(--text-dim);
font-size: 14px;
line-height: 1;
margin-left: 2px;
}
.tab-close:hover {
color: var(--error);
}
.status-area {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
.tab .close-btn {
margin-left: 8px;
color: var(--text-muted);
font-size: 12px;
}
.status-nick {
.tab .close-btn:hover {
color: var(--accent);
}
.tab .unread-badge {
display: inline-block;
background: var(--unread-bg);
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 5px;
border-radius: 8px;
margin-left: 6px;
min-width: 16px;
text-align: center;
}
.status-warn {
color: var(--warn);
animation: blink 1.5s ease-in-out infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ============================================
Topic Bar
============================================ */
.topic-bar {
/* Connection status */
.connection-status {
padding: 4px 12px;
background: var(--bg-topic);
background: var(--warn);
color: #1a1a2e;
font-size: 12px;
font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
}
/* Topic bar */
.topic-bar {
padding: 6px 12px;
background: var(--topic-bg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
line-height: 1.5;
}
.topic-label {
color: var(--text-dim);
}
.topic-text {
color: var(--text);
}
/* ============================================
Main Content Area
============================================ */
.main-area {
/* Content area */
.content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ============================================
Messages Panel
============================================ */
.messages-panel {
/* Messages */
.messages-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.messages-scroll {
.messages {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
padding: 8px 12px;
}
.messages-scroll::-webkit-scrollbar {
width: 8px;
}
.messages-scroll::-webkit-scrollbar-track {
background: transparent;
}
.messages-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
/* ============================================
Message Lines
============================================ */
.message {
padding: 1px 0;
padding: 2px 0;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
}
.message .timestamp {
color: var(--timestamp);
font-size: 12px;
margin-right: 8px;
}
.message .nick {
color: var(--nick);
font-weight: bold;
margin-right: 8px;
}
.message .content {
color: var(--text);
}
.message .nick::before { content: '<'; }
.message .nick::after { content: '>'; }
/* System messages (joins, parts, quits, etc.) */
.system-message {
color: var(--system);
}
.system-message .system-text {
color: var(--system);
}
/* /me action messages */
.action-message .action-text {
color: var(--action);
}
/* ============================================
User List (Right Panel)
============================================ */
.user-list {
width: 160px;
background: var(--user-list-bg);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.user-list-header {
padding: 6px 10px;
color: var(--user-list-header);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.user-list-entries {
overflow-y: auto;
padding: 4px 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.nick-entry {
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.nick-entry:hover {
background: rgba(255, 255, 255, 0.04);
}
.nick-prefix {
color: var(--text-dim);
display: inline-block;
width: 1ch;
text-align: right;
margin-right: 1px;
}
.nick-name {
font-weight: normal;
}
/* ============================================
Input Line (Bottom)
============================================ */
.input-line {
display: flex;
align-items: center;
background: var(--input-bg);
border-top: 1px solid var(--border);
flex-shrink: 0;
height: 36px;
padding: 0 8px;
gap: 6px;
}
.input-prompt {
color: var(--prompt);
font-size: 13px;
flex-shrink: 0;
white-space: nowrap;
}
.input-line input {
flex: 1;
padding: 4px 0;
font-family: inherit;
font-size: 13px;
background: transparent;
border: none;
color: var(--text-bright);
outline: none;
caret-color: var(--accent);
}
.input-line input::placeholder {
color: var(--text-dim);
.message.system {
color: var(--text-muted);
font-style: italic;
}
/* ============================================
Responsive
============================================ */
.message.system .nick {
color: var(--text-muted);
}
@media (max-width: 600px) {
.message.system .nick::before,
.message.system .nick::after { content: ''; }
/* Input */
.input-bar {
display: flex;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.input-bar input {
flex: 1;
padding: 10px 12px;
font-family: inherit;
font-size: 14px;
background: var(--bg-input);
border: none;
color: var(--text);
outline: none;
}
.input-bar button {
padding: 10px 16px;
font-family: inherit;
background: var(--accent);
border: none;
color: white;
cursor: pointer;
}
/* User list */
.user-list {
display: none;
width: 160px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
flex-shrink: 0;
}
.tab {
padding: 0 8px;
.user-list h3 {
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
margin-bottom: 8px;
letter-spacing: 1px;
}
.input-prompt {
font-size: 12px;
.user-list .user {
padding: 3px 4px;
color: var(--nick);
font-size: 13px;
cursor: pointer;
}
.user-list .user:hover {
background: var(--tab-hover);
}
/* Server tab */
.server-messages {
color: var(--text-muted);
padding: 12px;
white-space: pre-wrap;
overflow-y: auto;
flex: 1;
}
/* Channel join dialog */
.join-dialog {
padding: 12px;
display: flex;
gap: 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
margin-left: auto;
}
.join-dialog input {
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
width: 200px;
}
.join-dialog button {
padding: 6px 14px;
font-family: inherit;
font-size: 13px;
background: var(--accent2);
border: none;
color: var(--text);
border-radius: 3px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 600px) {
.user-list { display: none; }
.tab { padding: 6px 10px; font-size: 13px; }
}