11 Commits

Author SHA1 Message Date
clawbot
73cae71171 deps: migrate from go-chi/chi v1 to go-chi/chi/v5
All checks were successful
check / check (push) Successful in 1m2s
Migrate all import paths from github.com/go-chi/chi to
github.com/go-chi/chi/v5 and github.com/go-chi/chi/middleware
to github.com/go-chi/chi/v5/middleware.

This resolves GO-2026-4316 (open redirect vulnerability in
RedirectSlashes middleware) by moving to the maintained v5
module path.

Files changed:
- go.mod: chi v1.5.5 → chi/v5 v5.2.1
- internal/server/server.go: updated import
- internal/server/routes.go: updated imports
- internal/middleware/middleware.go: updated import
- internal/handlers/api.go: updated import
- README.md: updated Required Libraries table
2026-03-10 07:27:26 -07:00
67446b36a1 feat: store auth tokens as SHA-256 hashes instead of plaintext (#69)
All checks were successful
check / check (push) Successful in 5s
## Summary

Hash client auth tokens with SHA-256 before storing in the database. When validating tokens, hash the incoming token and compare against the stored hash. This prevents token exposure if the database is compromised.

Existing plaintext tokens are implicitly invalidated since they will not match the new hashed lookups — users will need to create new sessions.

## Changes

- **`internal/db/queries.go`**: Added `hashToken()` helper using `crypto/sha256`. Updated `CreateSession` to store hashed token. Updated `GetSessionByToken` to hash the incoming token before querying.
- **`internal/db/auth.go`**: Updated `RegisterUser` and `LoginUser` to store hashed tokens.

## Migration

No schema changes needed. The `token` column remains `TEXT` but now stores 64-char hex SHA-256 digests instead of 64-char hex random tokens. Existing plaintext tokens are effectively invalidated.

closes #34

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #69
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 12:44:29 +01:00
b1fd2f1b96 Replace string-matching error detection with typed SQLite errors (closes #39) (#66)
All checks were successful
check / check (push) Successful in 4s
## Summary

Replaces fragile `strings.Contains(err.Error(), "UNIQUE")` checks with typed error detection using `errors.As` and the SQLite driver's `*sqlite.Error` type.

## Changes

- **`internal/db/errors.go`** (new): Adds `IsUniqueConstraintError(err)` helper that uses `errors.As` to unwrap the error into `*sqlite.Error` and checks for `SQLITE_CONSTRAINT_UNIQUE` (code 2067).
- **`internal/handlers/api.go`**: Replaces two `strings.Contains(err.Error(), "UNIQUE")` calls with `db.IsUniqueConstraintError(err)` — in `handleCreateSessionError` and `executeNickChange`.
- **`internal/handlers/auth.go`**: Replaces one `strings.Contains(err.Error(), "UNIQUE")` call with `db.IsUniqueConstraintError(err)` — in `handleRegisterError`.

## Why

String matching on error messages is fragile — if the SQLite driver changes its error message format, the detection silently breaks. Using `errors.As` with the driver's typed error and checking the specific SQLite error code is robust, idiomatic Go, and immune to message format changes.

closes #39

<!-- session: agent:sdlc-manager:subagent:3fb0b8e2-d635-4848-a5bd-131c5033cdb1 -->

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #66
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:54:27 +01:00
c07f94a432 Remove dead Auth() middleware method (#68)
All checks were successful
check / check (push) Successful in 5s
Remove the unused `Auth()` method from `internal/middleware/middleware.go`.

This method only logged "AUTH: before request" and passed through to the next handler — it performed no actual authentication. It was never referenced anywhere in the codebase; authentication is handled per-handler via `requireAuth` in the handlers package.

closes #38

<!-- session: agent:sdlc-manager:subagent:629a7621-ec4b-49af-b7e8-03141664d682 -->

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #68
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:41:43 +01:00
a98e0ca349 feat: add Content-Security-Policy middleware (#64)
All checks were successful
check / check (push) Successful in 4s
Add CSP header to all HTTP responses for defense-in-depth against XSS.

The policy restricts all resource loading to same-origin and disables dangerous features (object embeds, framing, base tag injection). The embedded SPA requires no inline scripts or inline style attributes (Preact applies styles programmatically via DOM properties), so a strict policy without `unsafe-inline` works correctly.

**Directives:**
- `default-src 'self'` — baseline same-origin restriction
- `script-src 'self'` — same-origin scripts only
- `style-src 'self'` — same-origin stylesheets only
- `connect-src 'self'` — same-origin fetch/XHR only
- `img-src 'self'` — same-origin images only
- `font-src 'self'` — same-origin fonts only
- `object-src 'none'` — no plugin content
- `frame-ancestors 'none'` — prevent clickjacking
- `base-uri 'self'` — prevent base tag injection
- `form-action 'self'` — restrict form submissions

closes #41

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #64
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:20:15 +01:00
f287fdf6d1 fix: replay channel state on SPA reconnect (#61)
All checks were successful
check / check (push) Successful in 4s
## Summary

When closing and reopening the SPA, channel tabs were not restored because the client relied on localStorage to remember joined channels and re-sent JOIN commands on reconnect. This was fragile and caused spurious JOIN broadcasts to other channel members.

## Changes

### Server (`internal/handlers/api.go`, `internal/handlers/auth.go`)

- **`replayChannelState()`** — new method that enqueues synthetic JOIN messages plus join-numerics (332 TOPIC, 353 NAMES, 366 ENDOFNAMES) for every channel the session belongs to, targeted only at the specified client (no broadcast to other users).
- **`HandleState`** — accepts `?replay=1` query parameter to trigger channel state replay when the SPA reconnects.
- **`handleLogin`** — also calls `replayChannelState` after password-based login, since `LoginUser` creates a new client for an existing session.

### SPA (`web/src/app.jsx`, `web/dist/app.js`)

- On resume, calls `/state?replay=1` instead of `/state` so the server enqueues channel state into the message queue.
- `processMessage` now creates channel tabs when receiving a JOIN where `msg.from` matches the current nick (handles both live joins and replayed joins on reconnect).
- `onLogin` no longer re-sends JOIN commands for saved channels on resume — the server handles it via the replay mechanism, avoiding spurious JOIN broadcasts.

## How It Works

1. SPA loads, finds saved token in localStorage
2. Calls `GET /api/v1/state?replay=1` — server validates token and enqueues synthetic JOIN + TOPIC + NAMES for all session channels into the client's queue
3. `onLogin(nick, true)` sets `loggedIn = true` and requests MOTD (no re-JOIN needed)
4. Poll loop starts, picks up replayed channel messages
5. `processMessage` handles the JOIN messages, creating tabs and refreshing members/topics naturally

closes #60

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #61
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:08:13 +01:00
687c958bd1 fix: add version field to /api/v1/server response (#62)
All checks were successful
check / check (push) Successful in 4s
Add `version` field from `globals.Version` to the `handleServerInfo` response and update README documentation to include the new field.

Closes #43

<!-- session: agent:sdlc-manager:subagent:35f84819-55dd-4bb6-a94b-8103777cc433 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #62
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 11:05:10 +01:00
946f208ac2 feat: implement IRC numerics batch 2 — connection registration, channel ops, user queries (#59)
All checks were successful
check / check (push) Successful in 5s
## Summary

Implements the remaining important/commonly-used IRC numeric reply codes, as requested in [issue #52](#52).

### Connection Registration (001-005)
- **002 RPL_YOURHOST** — "Your host is <server>, running version <ver>"
- **003 RPL_CREATED** — "This server was created <date>"
- **004 RPL_MYINFO** — "<server> <version> <usermodes> <chanmodes>"
- **005 RPL_ISUPPORT** — CHANTYPES=#, NICKLEN=32, CHANMODES, NETWORK=neoirc, CASEMAPPING=ascii

All sent automatically after RPL_WELCOME during session creation/login.

### Server Statistics (251-255)
- **251 RPL_LUSERCLIENT** — user count
- **252 RPL_LUSEROP** — operator count
- **254 RPL_LUSERCHANNELS** — channel count
- **255 RPL_LUSERME** — local client count

Sent during connection registration and available via LUSERS command.

### Channel Operations
- **MODE command** — query channel modes (324 RPL_CHANNELMODEIS + 329 RPL_CREATIONTIME) and user modes (221 RPL_UMODEIS)
- **NAMES command** — query channel member list (reuses 353/366)
- **LIST command** — list all channels with member counts (322 RPL_LIST + 323 end)

### User Queries
- **WHOIS command** — 311 RPL_WHOISUSER, 312 RPL_WHOISSERVER, 319 RPL_WHOISCHANNELS, 318 RPL_ENDOFWHOIS
- **WHO command** — 352 RPL_WHOREPLY, 315 RPL_ENDOFWHO

### Database Additions
- `GetChannelCount()` — total channel count for LUSERS
- `ListAllChannelsWithCounts()` — channels with member counts for LIST
- `GetChannelCreatedAt()` — channel creation time for RPL_CREATIONTIME
- `GetSessionCreatedAt()` — session creation time

### Other Changes
- Added `StartTime` to `Globals` struct for RPL_CREATED
- Updated README with comprehensive documentation of all new commands and numerics
- Updated roadmap to reflect implemented features

`docker build .` passes (lint, tests, build all green).

closes [#52](#52)

<!-- session: agent:sdlc-manager:subagent:1f3dcab8-ad6a-4c4c-af72-34a617640c9d -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@git.eeqj.de>
Reviewed-on: #59
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-10 00:53:46 +01:00
47fb089969 fix: IRC SPA cleanup — /motd, /query, Firefox / key, default MOTD (#58)
All checks were successful
check / check (push) Successful in 1m0s
## Summary

Fixes IRC client SPA issues reported in [issue #57](#57).

## Changes

### Server-side
- **Default MOTD**: Added figlet-style ASCII art MOTD for "neoirc" as the default when no MOTD is configured via environment/config
- **MOTD command handler**: Added `MOTD` case to `dispatchCommand` so clients can re-request the MOTD at any time (proper IRC behavior)

### SPA (web client)
- **`/motd` command**: Sends MOTD request to server, displays 375/372/376 numerics in server window
- **`/query nick [message]`**: Opens a DM tab with the specified user, optionally sends a message
- **`/clear`**: Clears messages in the current tab
- **Firefox `/` key fix**: Added global `keydown` listener that captures `/` when input is not focused, preventing Firefox quick search and redirecting focus to the input element. Also auto-focuses input on SPA init.
- **MOTD on resumed sessions**: When restoring from a saved token, the MOTD is re-requested so it always appears in the server window
- **Updated `/help`**: Shows all new commands with descriptions
- **Login screen MOTD styling**: Improved for ASCII art display (monospace, proper line height)

## Testing
- `docker build .` passes (includes `make check` with tests, lint, fmt-check)
- All existing tests pass with no modifications

closes #57

<!-- session: agent:sdlc-manager:subagent:7c880fec-f818-49ff-a548-2d3c26758bb6 -->

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #58
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-09 23:00:34 +01:00
f8f0b6afbb refactor: replace HTTP error codes with IRC numeric replies (#56)
All checks were successful
check / check (push) Successful in 58s
## Summary

Refactors all IRC command handlers to respond with proper IRC numeric replies via the message queue instead of HTTP status codes.

HTTP error codes are now reserved exclusively for transport-level concerns:
- **401** — missing/invalid auth token
- **400** — malformed JSON, empty command
- **500** — server errors

## IRC Numerics Implemented

### Success replies (delivered via message queue on success):
- **001 RPL_WELCOME** — sent on session creation and login
- **331 RPL_NOTOPIC** — channel has no topic (on JOIN)
- **332 RPL_TOPIC** — channel topic (on JOIN, TOPIC set)
- **353 RPL_NAMREPLY** — channel member list (on JOIN)
- **366 RPL_ENDOFNAMES** — end of NAMES list (on JOIN)
- **375/372/376** — MOTD (already existed)

### Error replies (delivered via message queue instead of HTTP 4xx):
- **401 ERR_NOSUCHNICK** — DM target not found (was HTTP 404)
- **403 ERR_NOSUCHCHANNEL** — channel not found / invalid name (was HTTP 404)
- **421 ERR_UNKNOWNCOMMAND** — unrecognized command (was HTTP 400)
- **432 ERR_ERRONEUSNICKNAME** — invalid nick format (was HTTP 400)
- **433 ERR_NICKNAMEINUSE** — nick taken (was HTTP 409)
- **442 ERR_NOTONCHANNEL** — not a member of channel (was HTTP 403)
- **461 ERR_NEEDMOREPARAMS** — missing required fields (was HTTP 400)

## Database Changes
- Added `params` column to messages table for IRC-style parameters
- Added `Params` field to `IRCMessage` struct
- Updated `InsertMessage` to accept params

## Test Updates
- All existing tests updated to expect HTTP 200 + IRC numerics
- New tests: `TestWelcomeNumeric`, `TestJoinNumerics`

## Client Impact
- CLI and SPA already handle unknown numerics via default event handlers
- PRIVMSG/NOTICE success changed from HTTP 201 to HTTP 200

closes #54

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #56
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-09 22:21:30 +01:00
8e3166969e Redesign SPA to look like a proper IRC client (closes #50) (#53)
All checks were successful
check / check (push) Successful in 6s
## Summary

Complete UI overhaul of the embedded web SPA to look and feel like a proper IRC client.

## Changes

### Layout & Structure
- **Tab bar** at top with channel/DM/server tabs and unread indicators
- **Topic bar** below tabs for channel windows
- **Messages panel** with classic IRC message formatting
- **User list** on right side for channels with @/+/regular prefixes
- **Persistent input line** at bottom with IRC-style prompt `[nick] #channel >`

### IRC Commands
Full command support in the input line:
- `/join #channel` — Join a channel
- `/part [reason]` — Part the current channel
- `/msg nick message` — Send a private message
- `/me action` — Send a CTCP ACTION
- `/nick newnick` — Change nickname
- `/topic [text]` — View or set channel topic
- `/mode +/-flags` — Set channel modes
- `/quit [reason]` — Disconnect
- `/help` — Show available commands

### Message Display
- Messages on the **same line** as the nick: `[HH:MM:SS] <nick> message text`
- System messages: `[HH:MM:SS] * alice has parted #channel (reason)`
- Actions: `[HH:MM:SS] * alice waves hello`
- IRC vocabulary throughout ("parted" not "left", etc.)

### User List
- Right-side panel showing channel members
- Sorted by mode: `@operators` first, then `+voiced`, then regular users
- Click to open DM
- Parses RPL_NAMREPLY (353) for mode prefixes when available

### Other
- Input history with up/down arrow keys
- Dark theme with monospace font (classic IRC aesthetic)
- CTCP ACTION support (`/me`)
- RPL_TOPIC (332) parsing for server-sent topics
- Responsive: user list hidden on narrow screens

closes #50

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #53
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-09 22:12:34 +01:00
28 changed files with 3130 additions and 1404 deletions

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ node_modules/
*.key *.key
# Build artifacts # Build artifacts
web/dist/
/neoircd /neoircd
/bin/ /bin/
*.exe *.exe

View File

@@ -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 # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02 # golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
@@ -5,6 +15,9 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 fmt-check
RUN make lint RUN make lint
@@ -21,6 +34,7 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test RUN make test

277
README.md
View File

@@ -764,21 +764,98 @@ not pollute the message queue.
**IRC reference:** RFC 1459 §4.6.2, §4.6.3 **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:** **C2S:**
```json ```json
{"command": "MODE", "to": "#general", "params": ["+m"]} {"command": "MODE", "to": "#general"}
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]} {"command": "MODE", "to": "alice"}
``` ```
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the **S2C (via message queue):**
planned mode set.
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 **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) #### KICK — Kick User (Planned)
Remove a user from a channel. 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 | | Code | Name | When Sent | Example |
|------|----------------------|-----------|---------| |------|----------------------|-----------|---------|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | | `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is 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"]}` | | `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"]}` | | `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | | `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `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!"]}` | | `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"]}` | | `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | | `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | | `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
@@ -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"]}` | | `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | | `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | | `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
| `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"]}` | | `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"]}` | | `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"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are planned for full implementation. The current MVP **Note:** Numeric replies are now implemented. All IRC command responses
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead (success and error) are delivered as numeric replies through the message queue.
of numeric replies for error conditions. Numeric replies in the message queue HTTP error codes are reserved for transport-level issues (auth failures,
will be added post-MVP. malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes ### Channel Modes
@@ -936,6 +1032,12 @@ Return the current user's session state.
**Request:** No body. Requires auth. **Request:** No body. Requires auth.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------|
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{ {
@@ -968,6 +1070,12 @@ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll) ### GET /api/v1/messages — Poll Messages (Long-Poll)
Retrieve messages from the client's delivery queue. This is the primary Retrieve messages from the client's delivery queue. This is the primary
@@ -1054,27 +1162,78 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status | | Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------| |-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 201 Created | | `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `JOIN` | `to` | | 200 OK | | `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK | | `NICK` | `body` | | 200 OK |
| `TOPIC` | `to`, `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 | | `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK | | `PING` | | | 200 OK |
**Errors (all commands):** All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `invalid request` | Malformed JSON | | 400 | `invalid request` | Malformed JSON or empty command |
| 400 | `to field required` | Missing `to` for commands that need it |
| 400 | `body required` | Missing `body` for commands that need it |
| 400 | `unknown command: X` | Unrecognized command |
| 401 | `unauthorized` | Missing or invalid auth token | | 401 | `unauthorized` | Missing or invalid auth token |
| 404 | `channel not found` | Target channel doesn't exist | | 500 | `internal error` | Server-side failure |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken | **IRC numeric error replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
**IRC numeric success replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 001 | RPL_WELCOME | Sent on session creation/login |
| 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 ### GET /api/v1/history — Message History
@@ -1215,16 +1374,18 @@ Return server metadata. No authentication required.
```json ```json
{ {
"name": "My NeoIRC Server", "name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.", "motd": "Welcome! Be nice.",
"users": 42 "users": 42
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|---------|---------|-------------| |-----------|---------|-------------|
| `name` | string | Server display name | | `name` | string | Server display name |
| `motd` | string | Message of the day | | `version` | string | Server version |
| `users` | integer | Number of currently active user sessions | | `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
### GET /.well-known/healthcheck.json — Health Check ### GET /.well-known/healthcheck.json — Health Check
@@ -1463,6 +1624,10 @@ authenticity.
termination. termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`). - **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed. Restrict this in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
embedded SPA works without `'unsafe-inline'` for scripts or styles.
--- ---
@@ -1691,26 +1856,16 @@ docker run -p 8080:8080 \
neoirc neoirc
``` ```
The Dockerfile is a multi-stage build: The Dockerfile is a four-stage build:
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify 1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
bundled JS via esbuild) into `web/dist/`
2. **lint**: Runs formatting checks and golangci-lint against the Go source
(uses empty placeholder files for `web/dist/` so it runs independently of
web-builder for fast feedback)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
binaries with the real SPA assets from web-builder (CLI built to verify
compilation, not included in final image) compilation, not included in final image)
2. **Final stage**: Alpine Linux + `neoircd` binary only 4. **final**: Minimal Alpine image with only the `neoircd` binary
```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"]
```
### Binary ### Binary
@@ -2077,10 +2232,18 @@ GET /api/v1/challenge
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [ ] **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 - [ ] **KICK command** — remove users from channels
- [ ] **Numeric replies** — send IRC numeric codes via the message queue - [x] **Numeric replies** — send IRC numeric codes via the message queue
(001 welcome, 353 NAMES, 332 TOPIC, etc.) (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 - [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session - [ ] **Multi-client sessions** — add client to existing session
@@ -2100,7 +2263,7 @@ GET /api/v1/challenge
- [ ] **Push notifications** — optional webhook/push for mobile clients - [ ] **Push notifications** — optional webhook/push for mobile clients
when messages arrive during disconnect when messages arrive during disconnect
- [ ] **Message search** — full-text search over channel history - [ ] **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 - [ ] **Connection flood protection** — per-IP connection limits as a
complement to hashcash complement to hashcash
- [ ] **Invite system** — `INVITE` command for `+i` channels - [ ] **Invite system** — `INVITE` command for `+i` channels
@@ -2151,10 +2314,14 @@ neoirc/
│ └── http.go # HTTP timeouts │ └── http.go # HTTP timeouts
├── web/ ├── web/
│ ├── embed.go # go:embed directive for SPA │ ├── embed.go # go:embed directive for SPA
── dist/ # Built SPA (vanilla JS, no build step) ── build.sh # SPA build script (esbuild, runs in Docker)
├── index.html ├── package.json # Node dependencies (preact, esbuild)
├── style.css ├── package-lock.json
└── app.js ├── src/ # SPA source files (JSX + HTML + CSS)
│ │ ├── app.jsx
│ │ ├── index.html
│ │ └── style.css
│ └── dist/ # Generated at Docker build time (not committed)
├── schema/ # JSON Schema definitions (planned) ├── schema/ # JSON Schema definitions (planned)
├── go.mod ├── go.mod
├── go.sum ├── go.sum
@@ -2169,7 +2336,7 @@ neoirc/
| Purpose | Library | | Purpose | Library |
|------------|---------| |------------|---------|
| DI | `go.uber.org/fx` | | DI | `go.uber.org/fx` |
| Router | `github.com/go-chi/chi` | | Router | `github.com/go-chi/chi/v5` |
| Logging | `log/slog` (stdlib) | | Logging | `log/slog` (stdlib) |
| Config | `github.com/spf13/viper` | | Config | `github.com/spf13/viper` |
| Env | `github.com/joho/godotenv/autoload` | | Env | `github.com/joho/godotenv/autoload` |

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-02-22 last_modified: 2026-03-09
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -98,6 +98,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -144,8 +151,14 @@ style conventions are in separate documents:
- Use SemVer. - Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed). the binary.
Post-1.0.0: add new migration files. - `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.

View File

@@ -13,6 +13,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/irc"
) )
const ( const (
@@ -168,7 +170,7 @@ func (client *Client) PollMessages(
func (client *Client) JoinChannel(channel string) error { func (client *Client) JoinChannel(channel string) error {
return client.SendMessage( return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: "JOIN", To: channel, Command: irc.CmdJoin, To: channel,
}, },
) )
} }
@@ -177,7 +179,7 @@ func (client *Client) JoinChannel(channel string) error {
func (client *Client) PartChannel(channel string) error { func (client *Client) PartChannel(channel string) error {
return client.SendMessage( return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: "PART", To: channel, Command: irc.CmdPart, To: channel,
}, },
) )
} }

View File

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

2
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0 github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1

4
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

View File

@@ -13,6 +13,14 @@ import (
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -62,7 +70,7 @@ func New(
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("MOTD", "") viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")

View File

@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
now := time.Now() now := time.Now()
tokenHash := hashToken(token)
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
"create login client: %w", err, "create login client: %w", err,

20
internal/db/errors.go Normal file
View File

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

View File

@@ -3,12 +3,15 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -29,18 +32,37 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages. // IRCMessage is the IRC envelope for all messages.
type IRCMessage struct { type IRCMessage struct {
ID string `json:"id"` ID string `json:"id"`
Command string `json:"command"` Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"` Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"` TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"` Meta json.RawMessage `json:"meta,omitempty"`
DBID int64 `json:"-"` 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. // ChannelInfo is a lightweight channel representation.
type ChannelInfo struct { type ChannelInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -92,12 +114,14 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now) clientUUID, sessionID, tokenHash, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -130,6 +154,8 @@ func (database *Database) GetSessionByToken(
nick string nick string
) )
tokenHash := hashToken(token)
err := database.conn.QueryRowContext( err := database.conn.QueryRowContext(
ctx, ctx,
`SELECT s.id, c.id, s.nick `SELECT s.id, c.id, s.nick
@@ -137,7 +163,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s INNER JOIN sessions s
ON s.id = c.session_id ON s.id = c.session_id
WHERE c.token = ?`, WHERE c.token = ?`,
token, tokenHash,
).Scan(&sessionID, &clientID, &nick) ).Scan(&sessionID, &clientID, &nick)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
@@ -491,12 +517,17 @@ func (database *Database) GetSessionChannelIDs(
func (database *Database) InsertMessage( func (database *Database) InsertMessage(
ctx context.Context, ctx context.Context,
command, from, target string, command, from, target string,
params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil { if body == nil {
body = json.RawMessage("[]") body = json.RawMessage("[]")
} }
@@ -508,10 +539,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
body, meta, created_at) params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
string(body), string(meta), now) string(params), string(body), string(meta), now)
if err != nil { if err != nil {
return 0, "", fmt.Errorf( return 0, "", fmt.Errorf(
"insert message: %w", err, "insert message: %w", err,
@@ -578,7 +609,7 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command, `SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to, m.msg_from, m.msg_to,
m.body, m.meta, m.created_at m.params, m.body, m.meta, m.created_at
FROM client_queues cq FROM client_queues cq
INNER JOIN messages m INNER JOIN messages m
ON m.id = cq.message_id ON m.id = cq.message_id
@@ -642,7 +673,7 @@ func (database *Database) queryHistory(
if beforeID > 0 { if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -659,7 +690,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -684,16 +715,16 @@ func scanMessages(
for rows.Next() { for rows.Next() {
var ( var (
msg IRCMessage msg IRCMessage
qID int64 qID int64
body, meta string params, body, meta string
createdAt time.Time createdAt time.Time
) )
err := rows.Scan( err := rows.Scan(
&qID, &msg.ID, &msg.Command, &qID, &msg.ID, &msg.Command,
&msg.From, &msg.To, &msg.From, &msg.To,
&body, &meta, &createdAt, &params, &body, &meta, &createdAt,
) )
if err != nil { if err != nil {
return nil, fallbackQID, fmt.Errorf( return nil, fallbackQID, fmt.Errorf(
@@ -701,12 +732,25 @@ func scanMessages(
) )
} }
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body) msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta) msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano) msg.TS = createdAt.Format(time.RFC3339Nano)
msg.DBID = qID msg.DBID = qID
lastQID = 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) msgs = append(msgs, msg)
} }
@@ -943,3 +987,125 @@ func (database *Database) GetSessionChannels(
return scanChannels(rows) return scanChannels(rows)
} }
// GetChannelCount returns the total number of channels.
func (database *Database) GetChannelCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM channels",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get channel count: %w", err,
)
}
return count, nil
}
// ChannelInfoFull contains extended channel information.
type ChannelInfoFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
MemberCount int64 `json:"memberCount"`
}
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a
// channel.
func (database *Database) GetChannelCreatedAt(
ctx context.Context,
channelID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM channels WHERE id = ?",
channelID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get channel created_at: %w", err,
)
}
return createdAt, nil
}
// GetSessionCreatedAt returns the creation time of a
// session.
func (database *Database) GetSessionCreatedAt(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM sessions WHERE id = ?",
sessionID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session created_at: %w", err,
)
}
return createdAt, nil
}

View File

@@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) {
for range msgCount { for range msgCount {
_, _, err := database.InsertMessage( _, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -115,8 +116,9 @@ func newTestServer(
func newTestGlobals() *globals.Globals { func newTestGlobals() *globals.Globals {
return &globals.Globals{ return &globals.Globals{
Appname: "neoirc-test", Appname: "neoirc-test",
Version: "test", Version: "test",
StartTime: time.Now(),
} }
} }
@@ -462,6 +464,22 @@ func findMessage(
return false 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 --- // --- Tests ---
func TestCreateSessionValid(t *testing.T) { func TestCreateSessionValid(t *testing.T) {
@@ -473,6 +491,47 @@ func TestCreateSessionValid(t *testing.T) {
} }
} }
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) { func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
tserver.createSession("alice") tserver.createSession("alice")
@@ -668,11 +727,23 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("joiner3") token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd}, token, map[string]any{commandKey: joinCmd},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -699,9 +770,9 @@ func TestChannelMessage(t *testing.T) {
bodyKey: []string{"hello world"}, bodyKey: []string{"hello world"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -728,11 +799,22 @@ func TestMessageMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#test", commandKey: joinCmd, toKey: "#test",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#test", commandKey: privmsgCmd, toKey: "#test",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -740,12 +822,23 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("noto") token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
bodyKey: []string{"hello"}, bodyKey: []string{"hello"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -759,6 +852,8 @@ func TestNonMemberCannotSend(t *testing.T) {
commandKey: joinCmd, toKey: "#private", commandKey: joinCmd, toKey: "#private",
}) })
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining. // Alice tries to send without joining.
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
aliceToken, aliceToken,
@@ -768,8 +863,17 @@ func TestNonMemberCannotSend(t *testing.T) {
bodyKey: []string{"sneaky"}, bodyKey: []string{"sneaky"},
}, },
) )
if status != http.StatusForbidden { if status != http.StatusOK {
t.Fatalf("expected 403, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
} }
} }
@@ -786,9 +890,9 @@ func TestDirectMessage(t *testing.T) {
bodyKey: []string{"hey bob"}, bodyKey: []string{"hey bob"},
}, },
) )
if status != http.StatusCreated { if status != http.StatusOK {
t.Fatalf( t.Fatalf(
"expected 201, got %d: %v", status, result, "expected 200, got %d: %v", status, result,
) )
} }
@@ -818,13 +922,24 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("dmsender") token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, commandKey: privmsgCmd,
toKey: "nobody", toKey: "nobody",
bodyKey: []string{"hello?"}, bodyKey: []string{"hello?"},
}) })
if status != http.StatusNotFound { if status != http.StatusOK {
t.Fatalf("expected 404, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
} }
} }
@@ -871,12 +986,23 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick") tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"taken_nick"}, bodyKey: []string{"taken_nick"},
}) })
if status != http.StatusConflict { if status != http.StatusOK {
t.Fatalf("expected 409, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
} }
} }
@@ -884,12 +1010,23 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nickval") token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK", commandKey: "NICK",
bodyKey: []string{"bad nick!"}, bodyKey: []string{"bad nick!"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
} }
} }
@@ -897,11 +1034,22 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("nicknobody") token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"}, token, map[string]any{commandKey: "NICK"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -938,12 +1086,23 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("topicnoto") token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", commandKey: "TOPIC",
bodyKey: []string{"topic"}, bodyKey: []string{"topic"},
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -955,11 +1114,22 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest", commandKey: joinCmd, toKey: "#topictest",
}) })
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest", commandKey: "TOPIC", toKey: "#topictest",
}) })
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
} }
} }
@@ -1027,11 +1197,22 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("cmdtest") token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand( status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"}, token, map[string]any{commandKey: "BOGUS"},
) )
if status != http.StatusBadRequest { if status != http.StatusOK {
t.Fatalf("expected 400, got %d", status) t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
} }
} }
@@ -1278,12 +1459,18 @@ func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("lp_timeout") token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now() start := time.Now()
resp, err := doRequestAuth( resp, err := doRequestAuth(
t, t,
http.MethodGet, http.MethodGet,
tserver.url(apiMessages+"?timeout=1"), tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token, token,
nil, nil,
) )

View File

@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
@@ -80,7 +82,7 @@ func (hdlr *Handlers) handleRegister(
return return
} }
hdlr.deliverMOTD(request, clientID, sessionID) hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if strings.Contains(err.Error(), "UNIQUE") { if db.IsUniqueConstraintError(err) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -162,7 +164,7 @@ func (hdlr *Handlers) handleLogin(
return return
} }
sessionID, _, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser( hdlr.params.Database.LoginUser(
request.Context(), request.Context(),
payload.Nick, payload.Nick,
@@ -178,6 +180,16 @@ func (hdlr *Handlers) handleLogin(
return return
} }
hdlr.deliverMOTD(
request, clientID, sessionID, payload.Nick,
)
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,

21
internal/irc/commands.go Normal file
View 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
View 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]
}

View File

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
@@ -180,3 +166,36 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
}, },
) )
} }
// cspPolicy is the Content-Security-Policy header value applied to all
// responses. The embedded SPA loads scripts and styles from same-origin
// files only (no inline scripts or inline style attributes), so a strict
// policy works without 'unsafe-inline'.
const cspPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"connect-src 'self'; " +
"img-src 'self'; " +
"font-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// CSP returns middleware that sets the Content-Security-Policy header on
// every response for defense-in-depth against XSS.
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspPolicy,
)
next.ServeHTTP(writer, request)
})
}
}

View File

@@ -8,8 +8,8 @@ import (
"git.eeqj.de/sneak/neoirc/web" "git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
} }
srv.router.Use(srv.mw.CORS()) srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout)) srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled { if srv.sentryEnabled {

View File

@@ -20,7 +20,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "github.com/go-chi/chi/v5"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )

2
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored
View File

@@ -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
View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,218 +6,282 @@
:root { :root {
--bg: #0a0e14; --bg: #0a0e14;
--bg-secondary: #0d1117; --bg-panel: #0d1117;
--bg-input: #161b22; --bg-input: #0d1117;
--bg-highlight: #1a2030; --bg-tab: #161b22;
--bg-tab-active: #0d1117;
--bg-topic: #0d1117;
--text: #c9d1d9; --text: #c9d1d9;
--text-muted: #6e7681; --text-dim: #6e7681;
--text-bright: #e6edf3;
--accent: #58a6ff; --accent: #58a6ff;
--accent-dim: #1f6feb; --accent-dim: #1f6feb;
--border: #21262d; --border: #21262d;
--nick: #79c0ff; --system: #7d8590;
--timestamp: #484f58; --action: #d2a8ff;
--tab-active: #58a6ff;
--tab-bg: #0d1117;
--tab-hover: #161b22;
--topic-bg: #0d1117;
--unread-bg: #da3633;
--warn: #d29922; --warn: #d29922;
--op-color: #f0883e; --error: #f85149;
--voice-color: #3fb950; --unread: #f0883e;
--action-color: #bc8cff; --nick-brackets: #6e7681;
--system-color: #484f58; --timestamp: #484f58;
--input-bg: #161b22;
--prompt: #3fb950;
--tab-indicator: #58a6ff;
--user-list-bg: #0d1117;
--user-list-header: #484f58;
} }
html, html,
body, body,
#root { #root {
height: 100%; height: 100%;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
Courier, monospace; "Consolas", "Liberation Mono", "Courier New", monospace;
font-size: 13px; font-size: 13px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
overflow: hidden;
} }
/* Login screen */ /* ============================================
Login Screen
============================================ */
.login-screen { .login-screen {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
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); color: var(--accent);
font-size: 2em; font-size: 1.8em;
margin-bottom: 16px;
font-weight: 400;
} }
.login-screen input { .login-box .motd {
padding: 10px 16px; color: var(--accent);
font-size: 16px; font-size: 11px;
margin-bottom: 20px;
text-align: left;
white-space: pre;
font-family: inherit; 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); border: 1px solid var(--border);
color: var(--text); color: var(--text-bright);
border-radius: 4px; border-radius: 3px;
width: 280px; outline: none;
} }
.login-screen button { .login-box input:focus {
padding: 10px 24px; border-color: var(--accent-dim);
font-size: 16px; }
.login-box button {
padding: 8px 16px;
font-family: inherit; font-family: inherit;
font-size: 14px;
background: var(--accent-dim); background: var(--accent-dim);
border: none; border: none;
color: white; color: var(--text-bright);
border-radius: 4px; border-radius: 3px;
cursor: pointer; cursor: pointer;
margin-top: 4px;
} }
.login-screen button:hover { .login-box button:hover {
background: var(--accent); background: var(--accent);
} }
.login-screen .error { .login-box .error {
color: var(--unread-bg); color: var(--error);
font-size: 12px;
margin-top: 8px;
} }
.login-screen .motd { /* ============================================
color: var(--text-muted); IRC App Layout
max-width: 400px; ============================================ */
text-align: center;
white-space: pre-wrap;
}
/* Main layout */ .irc-app {
.app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden;
} }
/* Tab bar */ /* ============================================
Tab Bar
============================================ */
.tab-bar { .tab-bar {
display: flex; display: flex;
background: var(--bg-secondary); background: var(--bg-tab);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
height: 32px;
align-items: stretch; align-items: stretch;
min-height: 32px;
} }
.tab-bar::-webkit-scrollbar { .tabs {
height: 2px; display: flex;
overflow-x: auto;
flex: 1;
scrollbar-width: none;
} }
.tab-bar::-webkit-scrollbar-thumb { .tabs::-webkit-scrollbar {
background: var(--border); display: none;
} }
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px 12px; padding: 0 12px;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; color: var(--text-dim);
white-space: nowrap; white-space: nowrap;
color: var(--text-muted);
user-select: none; user-select: none;
border-right: 1px solid var(--border);
font-size: 12px; font-size: 12px;
gap: 6px; gap: 4px;
transition: position: relative;
background 0.1s,
color 0.1s;
} }
.tab:hover { .tab:hover {
background: var(--tab-hover);
color: var(--text); color: var(--text);
background: rgba(255, 255, 255, 0.03);
} }
.tab.active { .tab.active {
color: var(--text); color: var(--text-bright);
border-bottom-color: var(--tab-active); background: var(--bg-tab-active);
background: var(--bg-highlight); border-bottom: 2px solid var(--tab-indicator);
margin-bottom: -1px;
} }
.tab.server { .tab.has-unread .tab-label {
color: var(--unread);
font-weight: bold; font-weight: bold;
} }
.tab .tab-name { .tab .unread-count {
overflow: hidden; color: var(--unread);
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-size: 11px;
font-weight: bold; font-weight: bold;
white-space: nowrap;
flex-shrink: 0;
} }
/* Topic bar */ .tab-close {
.topic-bar { color: var(--text-dim);
padding: 4px 12px; font-size: 14px;
background: var(--topic-bg); line-height: 1;
border-bottom: 1px solid var(--border); margin-left: 2px;
color: var(--text-muted); }
.tab-close:hover {
color: var(--error);
}
.status-area {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
flex-shrink: 0;
font-size: 12px; font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
} }
.topic-bar .topic-label { .status-nick {
color: var(--accent); color: var(--accent);
font-weight: bold; font-weight: bold;
} }
/* Content area */ .status-warn {
.content { 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; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
min-height: 0;
} }
/* Messages */ /* ============================================
.messages-pane { Messages Panel
============================================ */
.messages-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -225,207 +289,178 @@ body,
min-width: 0; min-width: 0;
} }
.messages { .messages-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 4px 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
} }
.messages::-webkit-scrollbar { .messages-scroll::-webkit-scrollbar {
width: 6px; width: 8px;
} }
.messages::-webkit-scrollbar-thumb { .messages-scroll::-webkit-scrollbar-track {
background: transparent;
}
.messages-scroll::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 3px; border-radius: 4px;
} }
/* ============================================
Message Lines
============================================ */
.message { .message {
padding: 1px 12px; padding: 1px 0;
line-height: 1.5; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} font-size: 13px;
.message:hover {
background: var(--bg-highlight);
} }
.message .timestamp { .message .timestamp {
color: var(--timestamp); color: var(--timestamp);
font-size: 11px; font-size: 12px;
margin-right: 6px;
} }
.message .nick { .message .nick {
font-weight: bold; font-weight: bold;
margin-right: 6px;
} }
.message .nick::before { .message .content {
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); color: var(--text);
outline: none;
} }
.input-bar input::placeholder { /* System messages (joins, parts, quits, etc.) */
color: var(--text-muted); .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 { .user-list {
width: 170px; width: 160px;
background: var(--bg-secondary); background: var(--user-list-bg);
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
} }
.user-list-header { .user-list-header {
padding: 6px 10px; padding: 6px 10px;
color: var(--text-muted); color: var(--user-list-header);
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-weight: bold;
flex-shrink: 0; flex-shrink: 0;
} }
.user-list-entries { .user-list-entries {
overflow-y: auto; overflow-y: auto;
flex: 1;
padding: 4px 0; padding: 4px 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
} }
.user-list-entries::-webkit-scrollbar { .nick-entry {
width: 4px;
}
.user-list-entries::-webkit-scrollbar-thumb {
background: var(--border);
}
.user-list .user {
padding: 2px 10px; padding: 2px 10px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--text); line-height: 1.5;
} }
.user-list .user:hover { .nick-entry:hover {
background: var(--tab-hover); background: rgba(255, 255, 255, 0.04);
} }
.user-list .user.op { .nick-prefix {
color: var(--op-color); color: var(--text-dim);
display: inline-block;
width: 1ch;
text-align: right;
margin-right: 1px;
} }
.user-list .user.voice { .nick-name {
color: var(--voice-color); font-weight: normal;
} }
/* Server tab messages */ /* ============================================
.server-messages { Input Line (Bottom)
color: var(--text-muted); ============================================ */
padding: 8px 12px;
white-space: pre-wrap; .input-line {
overflow-y: auto; 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; 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 { .input-line input::placeholder {
padding: 1px 0; color: var(--text-dim);
font-style: italic;
} }
.server-messages .message:hover { /* ============================================
background: var(--bg-highlight); Responsive
} ============================================ */
/* Responsive */
@media (max-width: 600px) { @media (max-width: 600px) {
.user-list { .user-list {
display: none; display: none;
} }
.tab { .tab {
padding: 5px 8px; padding: 0 8px;
font-size: 11px; font-size: 11px;
} }
.input-bar .input-nick { .input-prompt {
padding-left: 8px;
font-size: 12px;
}
.input-bar input {
font-size: 12px; font-size: 12px;
} }
} }