Compare commits
8 Commits
feature/ir
...
3513943d47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3513943d47 | ||
|
|
5b07730bd2 | ||
|
|
5d0b362c0f | ||
| 687c958bd1 | |||
| 946f208ac2 | |||
| 47fb089969 | |||
| f8f0b6afbb | |||
| 8e3166969e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ data.db
|
||||
debug.log
|
||||
/neoirc-cli
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,3 +1,13 @@
|
||||
# Web build stage — compile SPA from source
|
||||
# node:22-alpine, 2026-03-09
|
||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/src/ src/
|
||||
COPY web/build.sh build.sh
|
||||
RUN sh build.sh
|
||||
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||
@@ -5,6 +15,9 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
||||
# without depending on the web-builder stage (lint should fail fast)
|
||||
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
@@ -21,6 +34,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=web-builder /web/dist/ web/dist/
|
||||
|
||||
RUN make test
|
||||
|
||||
|
||||
430
README.md
430
README.md
@@ -764,21 +764,98 @@ not pollute the message queue.
|
||||
|
||||
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
|
||||
|
||||
#### MODE — Set/Query Modes (Planned)
|
||||
#### MODE — Query Modes
|
||||
|
||||
Set channel or user modes.
|
||||
Query channel or user modes. Returns the current mode string and, for
|
||||
channels, the creation timestamp.
|
||||
|
||||
**C2S:**
|
||||
```json
|
||||
{"command": "MODE", "to": "#general", "params": ["+m"]}
|
||||
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
|
||||
{"command": "MODE", "to": "#general"}
|
||||
{"command": "MODE", "to": "alice"}
|
||||
```
|
||||
|
||||
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
|
||||
planned mode set.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
@@ -828,12 +905,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-server, running version 0.1"]}` |
|
||||
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, 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-server","0.1","","imnst"]}` |
|
||||
| `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"]}` |
|
||||
| `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"]}` |
|
||||
@@ -841,14 +933,18 @@ the server to the client (never C2S) and use 3-digit string codes in the
|
||||
| `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 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.
|
||||
**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).
|
||||
|
||||
### Channel Modes
|
||||
|
||||
@@ -891,7 +987,18 @@ the format:
|
||||
|
||||
Create a new user session. This is the entry point for all clients.
|
||||
|
||||
**Request:**
|
||||
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:**
|
||||
```json
|
||||
{"nick": "alice"}
|
||||
```
|
||||
@@ -920,12 +1027,15 @@ Create a new user session. This is the entry point for all clients.
|
||||
| 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
|
||||
```
|
||||
@@ -1054,27 +1164,78 @@ reference with all required and optional fields.
|
||||
|
||||
| Command | Required Fields | Optional | Response Status |
|
||||
|-----------|---------------------|---------------|-----------------|
|
||||
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
|
||||
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
|
||||
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
|
||||
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
|
||||
| `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 |
|
||||
|
||||
**Errors (all commands):**
|
||||
All IRC commands return HTTP 200 OK. IRC-level success and error responses
|
||||
are delivered as **numeric replies** through the message queue (see
|
||||
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
|
||||
reserved for transport-level problems: malformed JSON (400), missing/invalid
|
||||
auth tokens (401), and server errors (500).
|
||||
|
||||
**HTTP errors (transport-level only):**
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 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 |
|
||||
| 400 | `invalid request` | Malformed JSON or empty command |
|
||||
| 401 | `unauthorized` | Missing or invalid auth token |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### GET /api/v1/history — Message History
|
||||
|
||||
@@ -1215,16 +1376,20 @@ Return server metadata. No authentication required.
|
||||
```json
|
||||
{
|
||||
"name": "My NeoIRC Server",
|
||||
"version": "0.1.0",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42
|
||||
"users": 42,
|
||||
"hashcash_bits": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `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
|
||||
|
||||
@@ -1658,6 +1823,7 @@ 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
|
||||
@@ -1669,6 +1835,7 @@ MOTD=Welcome! Be excellent to each other.
|
||||
DEBUG=false
|
||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||
SESSION_IDLE_TIMEOUT=24h
|
||||
NEOIRC_HASHCASH_BITS=20
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1691,26 +1858,16 @@ docker run -p 8080:8080 \
|
||||
neoirc
|
||||
```
|
||||
|
||||
The Dockerfile is a multi-stage build:
|
||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||
compilation, not included in final image)
|
||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
||||
The Dockerfile is a 4-stage build:
|
||||
1. **Web builder stage** (`web-builder`): Compiles the Preact JSX SPA into
|
||||
static assets (`web/dist/`) using esbuild
|
||||
2. **Lint stage** (`lint`): Runs formatting checks and linting via golangci-lint
|
||||
3. **Build stage** (`builder`): Compiles `neoircd` and `neoirc-cli`, runs tests
|
||||
(CLI built to verify compilation, not included in final image)
|
||||
4. **Runtime stage**: Alpine Linux + `neoircd` binary only
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /src
|
||||
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/
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||
EXPOSE 8080
|
||||
CMD ["neoircd"]
|
||||
```
|
||||
`web/dist/` is not committed to git — it is built from `web/src/` by the
|
||||
web-builder stage during `docker build`.
|
||||
|
||||
### Binary
|
||||
|
||||
@@ -1951,62 +2108,102 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
## Rate Limiting & Abuse Prevention
|
||||
|
||||
Session creation (`POST /api/v1/session`) will require a
|
||||
### Hashcash Proof-of-Work
|
||||
|
||||
Session creation (`POST /api/v1/session`) requires 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 requests a challenge: `GET /api/v1/challenge`
|
||||
```json
|
||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
||||
```
|
||||
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:
|
||||
```
|
||||
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
|
||||
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)
|
||||
|
||||
### Adaptive Difficulty
|
||||
### Stamp Format
|
||||
|
||||
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.
|
||||
Standard hashcash format:
|
||||
|
||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
||||
|--------------------|-------------------|--------------------|
|
||||
| Normal (< 100/min) | 16 | ~1ms |
|
||||
| Elevated | 20 | ~15ms |
|
||||
| High | 24 | ~250ms |
|
||||
| Under attack | 28+ | ~4s+ |
|
||||
```
|
||||
1:bits:date:resource::counter
|
||||
```
|
||||
|
||||
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.
|
||||
| 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++
|
||||
```
|
||||
|
||||
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||
|
||||
### Client Integration
|
||||
|
||||
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||
|
||||
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.5–2s |
|
||||
| `24` | Strong protection | ~10–30s |
|
||||
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||
|
||||
Each additional bit doubles the expected work. An attacker creating 1000
|
||||
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||
creating one session pays once and keeps 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 hash.
|
||||
The server only needs to verify a single hash.
|
||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||
campuses, corporate networks, Tor exits). Every client computes their own
|
||||
proof independently.
|
||||
@@ -2014,36 +2211,9 @@ duration of 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
|
||||
@@ -2072,15 +2242,23 @@ GET /api/v1/challenge
|
||||
|
||||
### Post-MVP (Planned)
|
||||
|
||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [x] **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)
|
||||
- [ ] **MODE command** — set/query channel and user modes
|
||||
- [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
|
||||
- [ ] **KICK command** — remove users from channels
|
||||
- [ ] **Numeric replies** — send IRC numeric codes via the message queue
|
||||
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
|
||||
- [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)
|
||||
- [ ] **Max message size enforcement** — reject oversized messages
|
||||
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
|
||||
- [ ] **Multi-client sessions** — add client to existing session
|
||||
@@ -2100,7 +2278,7 @@ GET /api/v1/challenge
|
||||
- [ ] **Push notifications** — optional webhook/push for mobile clients
|
||||
when messages arrive during disconnect
|
||||
- [ ] **Message search** — full-text search over channel history
|
||||
- [ ] **User info command** — WHOIS-equivalent for querying user metadata
|
||||
- [x] **User info command** — WHOIS for querying user info and channels
|
||||
- [ ] **Connection flood protection** — per-IP connection limits as a
|
||||
complement to hashcash
|
||||
- [ ] **Invite system** — `INVITE` command for `+i` channels
|
||||
@@ -2151,7 +2329,13 @@ neoirc/
|
||||
│ └── http.go # HTTP timeouts
|
||||
├── web/
|
||||
│ ├── embed.go # go:embed directive for SPA
|
||||
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
||||
│ ├── build.sh # esbuild script: JSX → dist/
|
||||
│ ├── package.json # Node dependencies (esbuild, preact)
|
||||
│ ├── src/ # SPA source (Preact JSX)
|
||||
│ │ ├── app.jsx
|
||||
│ │ ├── index.html
|
||||
│ │ └── style.css
|
||||
│ └── dist/ # Built SPA (generated by web-builder Docker stage)
|
||||
│ ├── index.html
|
||||
│ ├── style.css
|
||||
│ └── app.js
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,13 +43,34 @@ 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) {
|
||||
data, err := client.do(
|
||||
// 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(
|
||||
http.MethodPost,
|
||||
"/api/v1/session",
|
||||
&SessionRequest{Nick: nick},
|
||||
headers,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,7 +191,7 @@ func (client *Client) PollMessages(
|
||||
func (client *Client) JoinChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: "JOIN", To: channel,
|
||||
Command: irc.CmdJoin, To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -177,7 +200,7 @@ func (client *Client) JoinChannel(channel string) error {
|
||||
func (client *Client) PartChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: "PART", To: channel,
|
||||
Command: irc.CmdPart, To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -259,6 +282,16 @@ 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
|
||||
|
||||
@@ -291,6 +324,10 @@ func (client *Client) do(
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
79
cmd/neoirc-cli/api/hashcash.go
Normal file
79
cmd/neoirc-cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package neoircapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
// counterSpace is the range for random counter seeds.
|
||||
counterSpace = 1 << 48
|
||||
)
|
||||
|
||||
// MintHashcash computes a hashcash stamp with the given
|
||||
// difficulty (leading zero bits) and resource string.
|
||||
func MintHashcash(bits int, resource string) string {
|
||||
date := time.Now().UTC().Format("060102")
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s::", bits, date, resource,
|
||||
)
|
||||
|
||||
for {
|
||||
counter := randomCounter()
|
||||
stamp := prefix + counter
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(
|
||||
hash []byte,
|
||||
numBits int,
|
||||
) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// randomCounter generates a random hex counter string.
|
||||
func randomCounter() string {
|
||||
counterVal, err := rand.Int(
|
||||
rand.Reader, big.NewInt(counterSpace),
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback to timestamp-based counter on error.
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return hex.EncodeToString(counterVal.Bytes())
|
||||
}
|
||||
@@ -63,9 +63,10 @@ type Channel struct {
|
||||
|
||||
// ServerInfo is the response from GET /api/v1/server.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// MessagesResponse wraps polling results.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -86,7 +87,7 @@ func (a *App) handleInput(text string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "PRIVMSG",
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -138,16 +139,29 @@ func (a *App) dispatchCommand(cmd, args string) {
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/window", "/w":
|
||||
a.cmdWindow(args)
|
||||
case "/quit":
|
||||
a.cmdQuit()
|
||||
case "/help":
|
||||
a.cmdHelp()
|
||||
default:
|
||||
a.dispatchInfoCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/motd":
|
||||
a.cmdMotd()
|
||||
case "/who":
|
||||
a.cmdWho(args)
|
||||
case "/whois":
|
||||
a.cmdWhois(args)
|
||||
default:
|
||||
a.ui.AddStatus(
|
||||
"[red]Unknown command: " + cmd,
|
||||
@@ -228,7 +242,7 @@ func (a *App) cmdNick(nick string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "NICK",
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -363,7 +377,7 @@ func (a *App) cmdMsg(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "PRIVMSG",
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -421,7 +435,7 @@ func (a *App) cmdTopic(args string) {
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "TOPIC",
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -434,7 +448,7 @@ func (a *App) cmdTopic(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: "TOPIC",
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
@@ -510,6 +524,96 @@ func (a *App) cmdList() {
|
||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||
}
|
||||
|
||||
func (a *App) cmdMotd() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]MOTD failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWho(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := args
|
||||
if channel == "" {
|
||||
channel = target
|
||||
}
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /who #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWho, To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHO failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWhois(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /whois <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWhois, To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHOIS failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWindow(args string) {
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
@@ -550,7 +654,7 @@ func (a *App) cmdQuit() {
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
@@ -574,6 +678,9 @@ func (a *App) cmdHelp() {
|
||||
" /topic [text] — View/set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
" /whois <nick> — Show user info",
|
||||
" /motd — Show message of the day",
|
||||
" /window <n> — Switch buffer",
|
||||
" /quit — Disconnect and exit",
|
||||
" /help — This help",
|
||||
@@ -632,19 +739,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case "PRIVMSG":
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case "JOIN":
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case "PART":
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case "QUIT":
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case "NICK":
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case "NOTICE":
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case "TOPIC":
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
|
||||
@@ -13,6 +13,14 @@ import (
|
||||
_ "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
|
||||
@@ -36,6 +44,7 @@ type Config struct {
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -62,10 +71,11 @@ func New(
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("MOTD", "")
|
||||
viper.SetDefault("MOTD", defaultMOTD)
|
||||
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 {
|
||||
@@ -90,6 +100,7 @@ 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: ¶ms,
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -33,14 +35,25 @@ 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"`
|
||||
@@ -491,12 +504,17 @@ 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("[]")
|
||||
}
|
||||
@@ -508,10 +526,10 @@ func (database *Database) InsertMessage(
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
`INSERT INTO messages
|
||||
(uuid, command, msg_from, msg_to,
|
||||
body, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
params, body, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msgUUID, command, from, target,
|
||||
string(body), string(meta), now)
|
||||
string(params), string(body), string(meta), now)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf(
|
||||
"insert message: %w", err,
|
||||
@@ -578,7 +596,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.body, m.meta, m.created_at
|
||||
m.params, m.body, m.meta, m.created_at
|
||||
FROM client_queues cq
|
||||
INNER JOIN messages m
|
||||
ON m.id = cq.message_id
|
||||
@@ -642,7 +660,7 @@ func (database *Database) queryHistory(
|
||||
if beforeID > 0 {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, body, meta, created_at
|
||||
msg_to, params, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ? AND id < ?
|
||||
AND command = 'PRIVMSG'
|
||||
@@ -659,7 +677,7 @@ func (database *Database) queryHistory(
|
||||
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, body, meta, created_at
|
||||
msg_to, params, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ?
|
||||
AND command = 'PRIVMSG'
|
||||
@@ -684,16 +702,16 @@ func scanMessages(
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
msg IRCMessage
|
||||
qID int64
|
||||
body, meta string
|
||||
createdAt time.Time
|
||||
msg IRCMessage
|
||||
qID int64
|
||||
params, body, meta string
|
||||
createdAt time.Time
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&qID, &msg.ID, &msg.Command,
|
||||
&msg.From, &msg.To,
|
||||
&body, &meta, &createdAt,
|
||||
¶ms, &body, &meta, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fallbackQID, fmt.Errorf(
|
||||
@@ -701,12 +719,25 @@ 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)
|
||||
}
|
||||
|
||||
@@ -943,3 +974,125 @@ 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
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, msgUUID, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
||||
for range msgCount {
|
||||
_, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "user", "#hist",
|
||||
json.RawMessage(`["msg"]`), nil,
|
||||
nil, json.RawMessage(`["msg"]`), nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
body := json.RawMessage(`["test"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||
msg_from TEXT NOT NULL DEFAULT '',
|
||||
msg_to TEXT NOT NULL DEFAULT '',
|
||||
params TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT NOT NULL DEFAULT '[]',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package globals
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -15,16 +17,18 @@ var (
|
||||
|
||||
// Globals holds application-wide metadata.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Appname string
|
||||
Version string
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from the global state.
|
||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
result := &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
return n, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -84,6 +85,7 @@ func newTestServer(
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
cfg.HashcashBits = 0
|
||||
|
||||
return cfg, nil
|
||||
},
|
||||
@@ -115,8 +117,9 @@ func newTestServer(
|
||||
|
||||
func newTestGlobals() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +465,22 @@ func findMessage(
|
||||
return false
|
||||
}
|
||||
|
||||
func findNumeric(
|
||||
msgs []map[string]any,
|
||||
numeric string,
|
||||
) bool {
|
||||
want, _ := strconv.Atoi(numeric)
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestCreateSessionValid(t *testing.T) {
|
||||
@@ -473,6 +492,47 @@ func TestCreateSessionValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeNumeric(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("welcomer")
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, 0)
|
||||
|
||||
if !findNumeric(msgs, "001") {
|
||||
t.Fatalf(
|
||||
"expected RPL_WELCOME (001), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinNumerics(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("jnumtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#numtest",
|
||||
})
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "353") {
|
||||
t.Fatalf(
|
||||
"expected RPL_NAMREPLY (353), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
|
||||
if !findNumeric(msgs, "366") {
|
||||
t.Fatalf(
|
||||
"expected RPL_ENDOFNAMES (366), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSessionDuplicate(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
tserver.createSession("alice")
|
||||
@@ -668,11 +728,23 @@ func TestJoinMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("joiner3")
|
||||
|
||||
// Drain initial MOTD/welcome numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: joinCmd},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,9 +771,9 @@ func TestChannelMessage(t *testing.T) {
|
||||
bodyKey: []string{"hello world"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -728,11 +800,22 @@ func TestMessageMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#test",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd, toKey: "#test",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,12 +823,23 @@ func TestMessageMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("noto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
bodyKey: []string{"hello"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +853,8 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#private",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(aliceToken, 0)
|
||||
|
||||
// Alice tries to send without joining.
|
||||
status, _ := tserver.sendCommand(
|
||||
aliceToken,
|
||||
@@ -768,8 +864,17 @@ func TestNonMemberCannotSend(t *testing.T) {
|
||||
bodyKey: []string{"sneaky"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(aliceToken, lastID)
|
||||
|
||||
if !findNumeric(msgs, "442") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOTONCHANNEL (442), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +891,9 @@ func TestDirectMessage(t *testing.T) {
|
||||
bodyKey: []string{"hey bob"},
|
||||
},
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"expected 201, got %d: %v", status, result,
|
||||
"expected 200, got %d: %v", status, result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -818,13 +923,24 @@ func TestDMToNonexistentUser(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("dmsender")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: "nobody",
|
||||
bodyKey: []string{"hello?"},
|
||||
})
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "401") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NOSUCHNICK (401), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,12 +987,23 @@ func TestNickCollision(t *testing.T) {
|
||||
|
||||
tserver.createSession("taken_nick")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"taken_nick"},
|
||||
})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "433") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NICKNAMEINUSE (433), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,12 +1011,23 @@ func TestNickInvalid(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nickval")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "NICK",
|
||||
bodyKey: []string{"bad nick!"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "432") {
|
||||
t.Fatalf(
|
||||
"expected ERR_ERRONEUSNICKNAME (432), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,11 +1035,22 @@ func TestNickEmptyBody(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("nicknobody")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "NICK"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,12 +1087,23 @@ func TestTopicMissingTo(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("topicnoto")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC",
|
||||
bodyKey: []string{"topic"},
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,11 +1115,22 @@ func TestTopicMissingBody(t *testing.T) {
|
||||
commandKey: joinCmd, toKey: "#topictest",
|
||||
})
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(token, map[string]any{
|
||||
commandKey: "TOPIC", toKey: "#topictest",
|
||||
})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "461") {
|
||||
t.Fatalf(
|
||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,11 +1198,22 @@ func TestUnknownCommand(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("cmdtest")
|
||||
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
status, _ := tserver.sendCommand(
|
||||
token, map[string]any{commandKey: "BOGUS"},
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, lastID)
|
||||
|
||||
if !findNumeric(msgs, "421") {
|
||||
t.Fatalf(
|
||||
"expected ERR_UNKNOWNCOMMAND (421), got %v",
|
||||
msgs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,12 +1460,18 @@ func TestLongPollTimeout(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lp_timeout")
|
||||
|
||||
// Drain initial welcome/MOTD numerics.
|
||||
_, lastID := tserver.pollMessages(token, 0)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
t,
|
||||
http.MethodGet,
|
||||
tserver.url(apiMessages+"?timeout=1"),
|
||||
tserver.url(fmt.Sprintf(
|
||||
"%s?timeout=1&after=%d",
|
||||
apiMessages, lastID,
|
||||
)),
|
||||
token,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
@@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, _, token, err :=
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.LoginUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
@@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
@@ -39,6 +40,7 @@ type Handlers struct {
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -47,11 +49,17 @@ 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: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
|
||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Package hashcash implements SHA-256-based hashcash
|
||||
// proof-of-work validation for abuse prevention.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||
//
|
||||
// The SHA-256 hash of the entire stamp string must have
|
||||
// at least `bits` leading zero bits.
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// stampVersion is the only supported hashcash version.
|
||||
stampVersion = "1"
|
||||
// stampFields is the number of fields in a stamp.
|
||||
stampFields = 6
|
||||
// maxStampAge is how old a stamp can be before
|
||||
// rejection.
|
||||
maxStampAge = 48 * time.Hour
|
||||
// maxFutureSkew allows stamps slightly in the future.
|
||||
maxFutureSkew = 1 * time.Hour
|
||||
// pruneInterval controls how often expired stamps are
|
||||
// removed from the spent set.
|
||||
pruneInterval = 10 * time.Minute
|
||||
// dateFormatShort is the YYMMDD date layout.
|
||||
dateFormatShort = "060102"
|
||||
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||
dateFormatLong = "060102150405"
|
||||
// dateShortLen is the length of YYMMDD.
|
||||
dateShortLen = 6
|
||||
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||
dateLongLen = 12
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidFields = errors.New("invalid stamp field count")
|
||||
errBadVersion = errors.New("unsupported stamp version")
|
||||
errInsufficientBits = errors.New("insufficient difficulty")
|
||||
errWrongResource = errors.New("wrong resource")
|
||||
errStampExpired = errors.New("stamp expired")
|
||||
errStampFuture = errors.New("stamp date in future")
|
||||
errProofFailed = errors.New("proof-of-work failed")
|
||||
errStampReused = errors.New("stamp already used")
|
||||
errBadDateFormat = errors.New("unrecognized date format")
|
||||
)
|
||||
|
||||
// Validator checks hashcash stamps for validity and
|
||||
// prevents replay attacks via an in-memory spent set.
|
||||
type Validator struct {
|
||||
resource string
|
||||
mu sync.Mutex
|
||||
spent map[string]time.Time
|
||||
}
|
||||
|
||||
// NewValidator creates a Validator for the given resource.
|
||||
func NewValidator(resource string) *Validator {
|
||||
validator := &Validator{
|
||||
resource: resource,
|
||||
mu: sync.Mutex{},
|
||||
spent: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
go validator.pruneLoop()
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// Validate checks a hashcash stamp. It returns nil if the
|
||||
// stamp is valid and has not been seen before.
|
||||
func (v *Validator) Validate(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
|
||||
if err := v.validateHeader(
|
||||
version, bitsStr, resource, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stampTime, err := parseStampDate(dateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateTime(stampTime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProof(
|
||||
stamp, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.checkAndRecordStamp(stamp, stampTime)
|
||||
}
|
||||
|
||||
func (v *Validator) validateHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != v.resource {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, v.resource,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTime(stampTime time.Time) error {
|
||||
now := time.Now()
|
||||
|
||||
if now.Sub(stampTime) > maxStampAge {
|
||||
return errStampExpired
|
||||
}
|
||||
|
||||
if stampTime.Sub(now) > maxFutureSkew {
|
||||
return errStampFuture
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProof(stamp string, requiredBits int) error {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d leading zero bits",
|
||||
errProofFailed, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Validator) checkAndRecordStamp(
|
||||
stamp string,
|
||||
stampTime time.Time,
|
||||
) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if _, ok := v.spent[stamp]; ok {
|
||||
return errStampReused
|
||||
}
|
||||
|
||||
v.spent[stamp] = stampTime
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if the hash has at least n
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// parseStampDate parses a hashcash date stamp.
|
||||
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||
func parseStampDate(dateStr string) (time.Time, error) {
|
||||
switch len(dateStr) {
|
||||
case dateShortLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatShort, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
case dateLongLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatLong, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"%w: %q", errBadDateFormat, dateStr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneLoop periodically removes expired stamps from the
|
||||
// spent set.
|
||||
func (v *Validator) pruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
v.prune()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) prune() {
|
||||
cutoff := time.Now().Add(-maxStampAge)
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
for stamp, stampTime := range v.spent {
|
||||
if stampTime.Before(cutoff) {
|
||||
delete(v.spent, stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
internal/irc/commands.go
Normal file
21
internal/irc/commands.go
Normal file
@@ -0,0 +1,21 @@
|
||||
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"
|
||||
)
|
||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// 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]
|
||||
}
|
||||
2
web/dist/app.js
vendored
2
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoIRC</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
431
web/dist/style.css
vendored
431
web/dist/style.css
vendored
@@ -1,431 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--bg-secondary: #0d1117;
|
||||
--bg-input: #161b22;
|
||||
--bg-highlight: #1a2030;
|
||||
--text: #c9d1d9;
|
||||
--text-muted: #6e7681;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb;
|
||||
--border: #21262d;
|
||||
--nick: #79c0ff;
|
||||
--timestamp: #484f58;
|
||||
--tab-active: #58a6ff;
|
||||
--tab-bg: #0d1117;
|
||||
--tab-hover: #161b22;
|
||||
--topic-bg: #0d1117;
|
||||
--unread-bg: #da3633;
|
||||
--warn: #d29922;
|
||||
--op-color: #f0883e;
|
||||
--voice-color: #3fb950;
|
||||
--action-color: #bc8cff;
|
||||
--system-color: #484f58;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New',
|
||||
Courier, monospace;
|
||||
font-size: 13px;
|
||||
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-dim);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-screen button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.login-screen .error {
|
||||
color: var(--unread-bg);
|
||||
}
|
||||
|
||||
.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: stretch;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--tab-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--tab-active);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.tab.server {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab .tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab .close-btn {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab .close-btn:hover {
|
||||
color: var(--unread-bg);
|
||||
}
|
||||
|
||||
.tab .unread-badge {
|
||||
display: inline-block;
|
||||
background: var(--unread-bg);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: var(--warn);
|
||||
color: var(--bg);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Topic bar */
|
||||
.topic-bar {
|
||||
padding: 4px 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;
|
||||
}
|
||||
|
||||
.topic-bar .topic-label {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 1px 12px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.message .timestamp {
|
||||
color: var(--timestamp);
|
||||
font-size: 11px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.message .nick {
|
||||
font-weight: bold;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.message .nick::before {
|
||||
content: '<';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message .nick::after {
|
||||
content: '>';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message.system {
|
||||
color: var(--system-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message.system .timestamp {
|
||||
color: var(--timestamp);
|
||||
}
|
||||
|
||||
.message.system .content::before {
|
||||
content: '*** ';
|
||||
}
|
||||
|
||||
.message.action {
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.message.action .timestamp {
|
||||
color: var(--timestamp);
|
||||
}
|
||||
|
||||
.message.action .action-nick {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Input bar — full width at bottom */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-bar .input-nick {
|
||||
padding: 0 8px 0 12px;
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-bar .input-nick::after {
|
||||
content: '>';
|
||||
color: var(--text-muted);
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
padding: 8px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* User list */
|
||||
.user-list {
|
||||
width: 170px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-header {
|
||||
padding: 6px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-entries {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.user-list-entries::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.user-list-entries::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.user-list .user {
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-list .user:hover {
|
||||
background: var(--tab-hover);
|
||||
}
|
||||
|
||||
.user-list .user.op {
|
||||
color: var(--op-color);
|
||||
}
|
||||
|
||||
.user-list .user.voice {
|
||||
color: var(--voice-color);
|
||||
}
|
||||
|
||||
/* Server tab messages */
|
||||
.server-messages {
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.server-messages .message {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.server-messages .message:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-bar .input-nick {
|
||||
padding-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
1208
web/src/app.jsx
1208
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -6,218 +6,282 @@
|
||||
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--bg-secondary: #0d1117;
|
||||
--bg-input: #161b22;
|
||||
--bg-highlight: #1a2030;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-input: #0d1117;
|
||||
--bg-tab: #161b22;
|
||||
--bg-tab-active: #0d1117;
|
||||
--bg-topic: #0d1117;
|
||||
--text: #c9d1d9;
|
||||
--text-muted: #6e7681;
|
||||
--text-dim: #6e7681;
|
||||
--text-bright: #e6edf3;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb;
|
||||
--border: #21262d;
|
||||
--nick: #79c0ff;
|
||||
--timestamp: #484f58;
|
||||
--tab-active: #58a6ff;
|
||||
--tab-bg: #0d1117;
|
||||
--tab-hover: #161b22;
|
||||
--topic-bg: #0d1117;
|
||||
--unread-bg: #da3633;
|
||||
--system: #7d8590;
|
||||
--action: #d2a8ff;
|
||||
--warn: #d29922;
|
||||
--op-color: #f0883e;
|
||||
--voice-color: #3fb950;
|
||||
--action-color: #bc8cff;
|
||||
--system-color: #484f58;
|
||||
--error: #f85149;
|
||||
--unread: #f0883e;
|
||||
--nick-brackets: #6e7681;
|
||||
--timestamp: #484f58;
|
||||
--input-bg: #161b22;
|
||||
--prompt: #3fb950;
|
||||
--tab-indicator: #58a6ff;
|
||||
--user-list-bg: #0d1117;
|
||||
--user-list-header: #484f58;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New',
|
||||
Courier, monospace;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Login screen */
|
||||
/* ============================================
|
||||
Login Screen
|
||||
============================================ */
|
||||
|
||||
.login-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-screen h1 {
|
||||
.login-box {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
color: var(--accent);
|
||||
font-size: 2em;
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-screen input {
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
.login-box .motd {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
font-family: inherit;
|
||||
background: var(--bg-input);
|
||||
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);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
width: 280px;
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-screen button {
|
||||
padding: 10px 24px;
|
||||
font-size: 16px;
|
||||
.login-box input:focus {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box button {
|
||||
padding: 8px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--accent-dim);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-screen button:hover {
|
||||
.login-box button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.login-screen .error {
|
||||
color: var(--unread-bg);
|
||||
.login-box .error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-screen .motd {
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* ============================================
|
||||
IRC App Layout
|
||||
============================================ */
|
||||
|
||||
/* Main layout */
|
||||
.app {
|
||||
.irc-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
/* ============================================
|
||||
Tab Bar
|
||||
============================================ */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-tab);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
align-items: stretch;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
.tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--tab-hover);
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--tab-active);
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-tab-active);
|
||||
border-bottom: 2px solid var(--tab-indicator);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab.server {
|
||||
.tab.has-unread .tab-label {
|
||||
color: var(--unread);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab .tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab .close-btn {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab .close-btn:hover {
|
||||
color: var(--unread-bg);
|
||||
}
|
||||
|
||||
.tab .unread-badge {
|
||||
display: inline-block;
|
||||
background: var(--unread-bg);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: var(--warn);
|
||||
color: var(--bg);
|
||||
.tab .unread-count {
|
||||
color: var(--unread);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Topic bar */
|
||||
.topic-bar {
|
||||
padding: 4px 12px;
|
||||
background: var(--topic-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
.tab-close {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topic-bar .topic-label {
|
||||
.status-nick {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
.status-warn {
|
||||
color: var(--warn);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Topic Bar
|
||||
============================================ */
|
||||
|
||||
.topic-bar {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-topic);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.topic-label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.topic-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Main Content Area
|
||||
============================================ */
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-pane {
|
||||
/* ============================================
|
||||
Messages Panel
|
||||
============================================ */
|
||||
|
||||
.messages-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -225,207 +289,178 @@ body,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
.messages-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
.messages-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages::-webkit-scrollbar-thumb {
|
||||
.messages-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Message Lines
|
||||
============================================ */
|
||||
|
||||
.message {
|
||||
padding: 1px 12px;
|
||||
line-height: 1.5;
|
||||
padding: 1px 0;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: var(--bg-highlight);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message .timestamp {
|
||||
color: var(--timestamp);
|
||||
font-size: 11px;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message .nick {
|
||||
font-weight: bold;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.message .nick::before {
|
||||
content: '<';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message .nick::after {
|
||||
content: '>';
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message.system {
|
||||
color: var(--system-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message.system .timestamp {
|
||||
color: var(--timestamp);
|
||||
}
|
||||
|
||||
.message.system .content::before {
|
||||
content: '*** ';
|
||||
}
|
||||
|
||||
.message.action {
|
||||
color: var(--action-color);
|
||||
}
|
||||
|
||||
.message.action .timestamp {
|
||||
color: var(--timestamp);
|
||||
}
|
||||
|
||||
.message.action .action-nick {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Input bar — full width at bottom */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-bar .input-nick {
|
||||
padding: 0 8px 0 12px;
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-bar .input-nick::after {
|
||||
content: '>';
|
||||
color: var(--text-muted);
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
flex: 1;
|
||||
padding: 8px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
.message .content {
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar input::placeholder {
|
||||
color: var(--text-muted);
|
||||
/* System messages (joins, parts, quits, etc.) */
|
||||
.system-message {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
/* User list */
|
||||
.system-message .system-text {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
/* /me action messages */
|
||||
.action-message .action-text {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User List (Right Panel)
|
||||
============================================ */
|
||||
|
||||
.user-list {
|
||||
width: 170px;
|
||||
background: var(--bg-secondary);
|
||||
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(--text-muted);
|
||||
color: var(--user-list-header);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-entries {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.user-list-entries::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.user-list-entries::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.user-list .user {
|
||||
.nick-entry {
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.user-list .user:hover {
|
||||
background: var(--tab-hover);
|
||||
.nick-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.user-list .user.op {
|
||||
color: var(--op-color);
|
||||
.nick-prefix {
|
||||
color: var(--text-dim);
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
text-align: right;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.user-list .user.voice {
|
||||
color: var(--voice-color);
|
||||
.nick-name {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Server tab messages */
|
||||
.server-messages {
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
/* ============================================
|
||||
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);
|
||||
}
|
||||
|
||||
.server-messages .message {
|
||||
padding: 1px 0;
|
||||
.input-line input::placeholder {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.server-messages .message:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 5px 8px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-bar .input-nick {
|
||||
padding-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-bar input {
|
||||
.input-prompt {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user