32 Commits

Author SHA1 Message Date
clawbot
18d46b2ac0 refactor: move hashcash stamp from X-Hashcash header to JSON request body
All checks were successful
check / check (push) Successful in 4s
Move the hashcash proof-of-work stamp from the X-Hashcash HTTP header
into the JSON request body as a 'hashcash' field on POST /api/v1/session.

Updated server handler, CLI client, SPA client, and documentation.
2026-03-10 10:00:20 -07:00
clawbot
8854b17ebc refactor: move CLI code from cmd/ to internal/cli
All checks were successful
check / check (push) Successful in 4s
Move all non-bootstrapping CLI code to internal/cli package.
cmd/neoirc-cli/main.go now contains only minimal bootstrapping
that calls cli.Run(). The App struct, UI, command handlers, poll
loop, and api client are now in internal/cli/ and internal/cli/api/.
2026-03-10 03:28:52 -07:00
clawbot
6fa46f4b75 fix: move hashcash PoW from build artifact to JSX source
The hashcash proof-of-work implementation was incorrectly added to the
build artifact web/dist/app.js instead of the source file web/src/app.jsx.
Running web/build.sh would overwrite all hashcash changes.

Changes:
- Add checkLeadingZeros() and mintHashcash() functions to app.jsx
- Integrate hashcash into LoginScreen: fetch hashcash_bits from /server,
  compute stamp via Web Crypto API before session creation, show
  'Computing proof-of-work...' feedback
- Remove web/dist/ from git tracking (build artifacts)
- Add web/dist/ to .gitignore
2026-03-10 03:27:16 -07:00
clawbot
a89393186f feat: implement hashcash proof-of-work for session creation
Add SHA-256-based hashcash proof-of-work requirement to POST /session
to prevent abuse via rapid session creation. The server advertises the
required difficulty via GET /server (hashcash_bits field), and clients
must include a valid stamp in the X-Hashcash request header.

Server-side:
- New internal/hashcash package with stamp validation (format, bits,
  date, resource, replay prevention via in-memory spent set)
- Config: NEOIRC_HASHCASH_BITS env var (default 20, set 0 to disable)
- GET /server includes hashcash_bits when > 0
- POST /session validates X-Hashcash header when enabled
- Returns HTTP 402 for missing/invalid stamps

Client-side:
- SPA: fetches hashcash_bits from /server, computes stamp using Web
  Crypto API with batched SHA-256, shows 'Computing proof-of-work...'
  feedback during computation
- CLI: api package gains MintHashcash() function, CreateSession()
  auto-fetches server info and computes stamp when required

Stamp format: 1:bits:YYMMDD:resource::counter (standard hashcash)

closes #11
2026-03-10 03:27:16 -07: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
c0e344d6fc Fix SPA: bundle preact instead of leaving as external require (closes #48) (#49)
All checks were successful
check / check (push) Successful in 5s
## Problem

The SPA fails to load with:
```
Uncaught Error: Dynamic require of "preact" is not supported
```

The esbuild config in `web/build.sh` had `--external:preact`, which tells the bundler to leave preact as a `require()` call instead of including it in the bundle. Since the browser has no `require()` function and there is no CDN/import-map loading preact externally, the app crashes immediately.

## Fix

- Remove `--external:preact` from `build.sh` so preact is bundled into `app.js`
- Add `--format=esm` to output proper ESM instead of IIFE with CJS require shims
- Update `index.html` to use `<script type="module">` for ESM compatibility
- Remove the dead fallback build command (was never reached since the first command succeeded)
- Rebuild `dist/app.js` with preact properly inlined (21.1KB minified)

closes #48

Reviewed-on: #49
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-07 14:53:13 +01:00
2da7f11484 Rename app from chat to neoirc, binary to neoircd (closes #46) (#47)
All checks were successful
check / check (push) Successful in 2m24s
Complete rename of the application from `chat` to `neoirc` with binary name `neoircd`.

closes #46

## Changes

- **Go module path**: `git.eeqj.de/sneak/chat` → `git.eeqj.de/sneak/neoirc`
- **Server binary**: `chatd` → `neoircd`
- **CLI binary**: `chat-cli` → `neoirc-cli`
- **Cmd directories**: `cmd/chatd` → `cmd/neoircd`, `cmd/chat-cli` → `cmd/neoirc-cli`
- **Go package**: `chatapi` → `neoircapi`
- **Makefile**: binary name, build targets, docker image tag, clean target
- **Dockerfile**: binary paths, user/group names (`chat` → `neoirc`), ENTRYPOINT
- **`.gitignore`/`.dockerignore`**: artifact names
- **All Go imports and doc comments**
- **Default server name**: `chat` → `neoirc`
- **Web client**: localStorage keys (`chat_token`/`chat_channels` → `neoirc_token`/`neoirc_channels`), page title, default server display name
- **Schema files**: all `$id` URLs and example hostnames
- **README.md**: project name, all binary references, examples, directory tree
- **AGENTS.md**: build command reference
- **Test fixtures**: app name and channel names

Docker build passes. All tests pass.

<!-- session: agent:sdlc-manager:subagent:a4b8dbd3-a7c8-4fad-8239-bb5a64a9b3d6 -->

Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Reviewed-on: #47
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-07 14:43:58 +01:00
6e7bf028c1 fix: change appname to neoirc, default DB to /var/lib/neoirc/state.db (#45)
All checks were successful
check / check (push) Successful in 6s
## Changes

- Change `Appname` from `"chat"` to `"neoirc"` in `cmd/chatd/main.go`
- Change default `DBURL` from `file:./data.db?_journal_mode=WAL` to `file:///var/lib/neoirc/state.db?_journal_mode=WAL` in both `internal/config/config.go` and the `internal/db/db.go` fallback
- Create `/var/lib/neoirc/` directory in Dockerfile with proper ownership for the `chat` user
- Update README.md to reflect new defaults (DBURL table, `.env` example, docker run example, SQLite backup/location docs)
- Remove stale `data.db` reference from Makefile `clean` target

The DB path remains configurable via the `DBURL` environment variable. No Go packages were renamed.

Closes #44

Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Reviewed-on: #45
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-06 12:40:28 +01:00
2761ee156a feat: split Dockerfile into dedicated lint stage for faster CI (#32)
All checks were successful
check / check (push) Successful in 3m4s
## Summary

Split the Dockerfile into a dedicated lint stage using the prebuilt `golangci/golangci-lint:v2.1.6` image, so lint failures are reported faster without needing to download/compile golangci-lint first.

## Changes

- **New lint stage** (`AS lint`): Uses the prebuilt `golangci/golangci-lint` image (pinned by sha256). Runs `make fmt-check` and `make lint`.
- **Build stage** (`AS builder`): Runs `make test` + compilation. No longer installs golangci-lint via `go install`.
- **`COPY --from=lint`**: Forces BuildKit to execute the lint stage before proceeding with the build.
- **Runtime stage**: Unchanged.

All base images remain pinned by sha256 hash.

closes #27

<!-- session: agent:sdlc-manager:subagent:76cebdf6-86f0-4383-93e3-ff3e10fbc7a6 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #32
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:05:08 +01:00
cd909d59c4 Merge pull request 'feat: logout, users/me, user count, session timeout' (#24) from feature/mvp-remaining into main
All checks were successful
check / check (push) Successful in 1m58s
Reviewed-on: #24
2026-03-01 15:47:03 +01:00
clawbot
f5cc098b7b docs: update README for new endpoints, fix config name, remove dead field
All checks were successful
check / check (push) Successful in 1m24s
- Document POST /api/v1/logout endpoint
- Document GET /api/v1/users/me endpoint
- Add 'users' field to GET /api/v1/server response docs
- Fix config: SESSION_TIMEOUT -> SESSION_IDLE_TIMEOUT
- Update storage section: session expiry is implemented
- Update roadmap: move session expiry to implemented
- Remove dead SessionTimeout config field from Go code
2026-03-01 06:41:10 -08:00
user
4d7b7618b2 fix: send QUIT notifications for background idle cleanup
All checks were successful
check / check (push) Successful in 2m2s
The background idle cleanup (DeleteStaleUsers) was removing stale
clients/sessions directly via SQL without sending QUIT notifications
to channel members. This caused timed-out users to silently disappear
from channels.

Now runCleanup identifies sessions that will be orphaned by the stale
client deletion and calls cleanupUser for each one first, ensuring
QUIT messages are sent to all channel members — matching the explicit
logout behavior.

Also refactored cleanupUser to accept context.Context instead of
*http.Request so it can be called from both HTTP handlers and the
background cleanup goroutine.
2026-03-01 06:33:15 -08:00
user
910a5c2606 fix: OnStart ctx bug, rename session→user, full logout cleanup
All checks were successful
check / check (push) Successful in 1m57s
- Use context.Background() for cleanup goroutine instead of
  OnStart ctx which is cancelled after startup completes
- Rename GetSessionCount→GetUserCount, DeleteStaleSessions→
  DeleteStaleUsers to reflect that sessions represent users
- HandleLogout now fully cleans up when last client disconnects:
  parts all channels (notifying members via QUIT), removes
  empty channels, and deletes the session/user record
- docker build passes, all tests green, 0 lint issues
2026-02-28 11:14:23 -08:00
bdc243224b feat: add session idle timeout cleanup goroutine
All checks were successful
check / check (push) Successful in 1m58s
- Periodic cleanup loop deletes stale clients based on SESSION_IDLE_TIMEOUT
- Orphaned sessions (no clients) are cleaned up automatically
- last_seen already updated on each authenticated request via GetSessionByToken
2026-02-28 10:59:09 -08:00
5981c750a4 feat: add SESSION_IDLE_TIMEOUT config
- New env var SESSION_IDLE_TIMEOUT (default 24h)
- Parsed as time.Duration in handlers
2026-02-28 10:59:09 -08:00
6cfab21eaa feat: add logout endpoint and users/me endpoint
- POST /api/v1/logout: deletes client token, returns {status: ok}
- GET /api/v1/users/me: returns session info (delegates to HandleState)
- Add DeleteClient, GetSessionCount, ClientCountForSession, DeleteStaleSessions to db layer
- Add user count to GET /api/v1/server response
- Extract setupAPIv1 to fix funlen lint issue
2026-02-28 10:59:09 -08:00
4a0ed57fc0 Merge pull request 'feat: password-based registration and login (closes #1)' (#23) from feature/auth-passwords into main
All checks were successful
check / check (push) Successful in 9s
Reviewed-on: #23
Reviewed-by: Jeffrey Paul <sneak@noreply.example.org>
2026-02-28 19:57:40 +01:00
user
52c85724a7 fix: remove unused //nolint:gosec directives on password fields
All checks were successful
check / check (push) Successful in 2m4s
2026-02-28 10:33:59 -08:00
clawbot
69c9550bb2 consolidate password_hash into 001 migration
Some checks failed
check / check (push) Failing after 1m27s
Pre-1.0, no installed base — merge 002_add_passwords.sql into
001_initial.sql and remove the separate migration file.
2026-02-28 07:59:01 -08:00
7047167dc8 Add tests for register and login endpoints
Some checks failed
check / check (push) Failing after 1m39s
2026-02-27 05:00:51 -08:00
3cd942ffa5 Add /api/v1/register and /api/v1/login routes 2026-02-27 04:55:40 -08:00
b8794c2587 Add register and login HTTP handlers 2026-02-27 04:55:31 -08:00
70aa15e758 Add RegisterUser and LoginUser DB functions with bcrypt 2026-02-27 04:55:06 -08:00
5e26e53187 Add migration 002: add password_hash column to sessions 2026-02-27 04:54:31 -08:00
02b906badb Merge pull request 'feat: MVP two-user chat via embedded SPA (closes #9)' (#22) from feat/mvp-two-user-chat into main
All checks were successful
check / check (push) Successful in 10s
Reviewed-on: #22
2026-02-27 13:51:20 +01:00
clawbot
32419fb1f7 feat: MVP two-user chat via embedded SPA (#9)
All checks were successful
check / check (push) Successful in 1m51s
Backend:
- Session/client UUID model: sessions table (uuid, nick, signing_key),
  clients table (uuid, session_id, token) with per-client message queues
- MOTD delivery as IRC numeric messages (375/372/376) on connect
- EnqueueToSession fans out to all clients of a session
- EnqueueToClient for targeted delivery (MOTD)
- All queries updated for session/client model

SPA client:
- Long-poll loop (15s timeout) instead of setInterval
- IRC message envelope parsing (command/from/to/body)
- Display JOIN/PART/NICK/TOPIC/QUIT system messages
- Nick change via /nick command
- Topic display in header bar
- Unread count badges on inactive tabs
- Auto-rejoin channels on reconnect (localStorage)
- Connection status indicator
- Message deduplication by UUID
- Channel history loaded on join
- /topic command support

Closes #9
2026-02-27 02:21:48 -08:00
75 changed files with 6289 additions and 2095 deletions

View File

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

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

585
README.md
View File

@@ -1,9 +1,9 @@
# chat
# neoirc
**IRC semantics, structured message metadata, cryptographic signing, and
server-held session state with per-client delivery queues. All over HTTP+JSON.**
A chat server written in Go that decouples session state from transport
An IRC-inspired server written in Go that decouples session state from transport
connections, enabling mobile-friendly persistent sessions over plain HTTP.
The **HTTP API is the primary interface**. It's designed to be simple enough
@@ -44,7 +44,7 @@ IRC is in decline because session state is tied to the TCP connection. In a
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
or pay for IRCCloud.
This project builds a chat server that:
This project builds a server that:
- Holds session state server-side (message queues, presence, channel membership)
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
@@ -132,7 +132,7 @@ makes signing consistent — you sign the same structure you send.
### Why not XMPP or Matrix?
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented
XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented
across incompatible extensions (XEPs). Matrix is a federated append-only event
graph with a spec that runs to hundreds of pages. Both are fine protocols, but
they're solving different problems at different scales.
@@ -764,21 +764,98 @@ not pollute the message queue.
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
#### MODE — Set/Query Modes (Planned)
#### MODE — Query Modes
Set channel or user modes.
Query channel or user modes. Returns the current mode string and, for
channels, the creation timestamp.
**C2S:**
```json
{"command": "MODE", "to": "#general", "params": ["+m"]}
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
{"command": "MODE", "to": "#general"}
{"command": "MODE", "to": "alice"}
```
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
planned mode set.
**S2C (via message queue):**
For channels, the server sends RPL_CHANNELMODEIS (324) and
RPL_CREATIONTIME (329):
```json
{"command": "324", "to": "alice", "params": ["#general", "+n"]}
{"command": "329", "to": "alice", "params": ["#general", "1709251200"]}
```
For users, the server sends RPL_UMODEIS (221):
```json
{"command": "221", "to": "alice", "body": ["+"]}
```
**Note:** Mode changes (setting/unsetting modes) are not yet implemented.
Currently only query is supported.
**IRC reference:** RFC 1459 §4.2.3
#### NAMES — Channel Member List
Request the member list for a channel. Returns RPL_NAMREPLY (353) and
RPL_ENDOFNAMES (366).
**C2S:**
```json
{"command": "NAMES", "to": "#general"}
```
**IRC reference:** RFC 1459 §4.2.5
#### LIST — List Channels
Request a list of all channels with member counts. Returns RPL_LIST (322)
for each channel followed by RPL_LISTEND (323).
**C2S:**
```json
{"command": "LIST"}
```
**IRC reference:** RFC 1459 §4.2.6
#### WHOIS — User Information
Query information about a user. Returns RPL_WHOISUSER (311),
RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
**C2S:**
```json
{"command": "WHOIS", "to": "alice"}
```
**IRC reference:** RFC 1459 §4.5.2
#### WHO — Channel User List
Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed
by RPL_ENDOFWHO (315).
**C2S:**
```json
{"command": "WHO", "to": "#general"}
```
**IRC reference:** RFC 1459 §4.5.1
#### LUSERS — Server Statistics
Request server user/channel statistics. Returns RPL_LUSERCLIENT (251),
RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255).
**C2S:**
```json
{"command": "LUSERS"}
```
LUSERS replies are also sent automatically during connection registration.
**IRC reference:** RFC 1459 §4.3.2
#### KICK — Kick User (Planned)
Remove a user from a channel.
@@ -828,27 +905,46 @@ the server to the client (never C2S) and use 3-digit string codes in the
| Code | Name | When Sent | Example |
|------|----------------------|-----------|---------|
| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is chatserver, running version 0.1"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["chatserver","0.1","","imnst"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` |
| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` |
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` |
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` |
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` |
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` |
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` |
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` |
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` |
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- chatserver Message of the Day -"]}` |
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` |
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` |
| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` |
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` |
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` |
| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` |
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` |
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are planned for full implementation. The current MVP
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
of numeric replies for error conditions. Numeric replies in the message queue
will be added post-MVP.
**Note:** Numeric replies are now implemented. All IRC command responses
(success and error) are delivered as numeric replies through the message queue.
HTTP error codes are reserved for transport-level issues (auth failures,
malformed requests, server errors). The `params` field in the message envelope
carries IRC-style parameters (e.g., channel name, target nick).
### Channel Modes
@@ -891,14 +987,20 @@ the format:
Create a new user session. This is the entry point for all clients.
**Request:**
If the server requires hashcash proof-of-work (see
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
valid stamp in the `hashcash` field of the JSON request body. The required
difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
**Request Body:**
```json
{"nick": "alice"}
{"nick": "alice", "hashcash": "1:20:260310:neoirc::3a2f1"}
```
| Field | Type | Required | Constraints |
|--------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| Field | Type | Required | Constraints |
|------------|--------|-------------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `hashcash` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created`
```json
@@ -920,13 +1022,15 @@ Create a new user session. This is the entry point for all clients.
| Status | Error | When |
|--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
| 402 | `hashcash proof-of-work required` | Missing `hashcash` field in request body when hashcash is enabled |
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"alice"}' | jq -r .token)
-d '{"nick":"alice","hashcash":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
```
@@ -936,6 +1040,12 @@ Return the current user's session state.
**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`
```json
{
@@ -968,6 +1078,12 @@ curl -s http://localhost:8080/api/v1/state \
-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)
Retrieve messages from the client's delivery queue. This is the primary
@@ -1054,27 +1170,78 @@ reference with all required and optional fields.
| Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
| `PRIVMSG` | `to`, `body` | `meta` | 200 OK |
| `NOTICE` | `to`, `body` | `meta` | 200 OK |
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
| `LIST` | | | 200 OK |
| `WHOIS` | `to` or `body` | | 200 OK |
| `WHO` | `to` | | 200 OK |
| `LUSERS` | | | 200 OK |
| `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK |
**Errors (all commands):**
All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON |
| 400 | `to field required` | Missing `to` for commands that need it |
| 400 | `body required` | Missing `body` for commands that need it |
| 400 | `unknown command: X` | Unrecognized command |
| 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth token |
| 404 | `channel not found` | Target channel doesn't exist |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken |
| 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist |
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
**IRC numeric success replies (delivered via message queue):**
| Numeric | Name | When |
|---------|------|------|
| 001 | RPL_WELCOME | Sent on session creation/login |
| 002 | RPL_YOURHOST | Sent on session creation/login |
| 003 | RPL_CREATED | Sent on session creation/login |
| 004 | RPL_MYINFO | Sent on session creation/login |
| 005 | RPL_ISUPPORT | Sent on session creation/login |
| 221 | RPL_UMODEIS | In response to user MODE query |
| 251 | RPL_LUSERCLIENT | On connect or LUSERS command |
| 252 | RPL_LUSEROP | On connect or LUSERS command |
| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command |
| 255 | RPL_LUSERME | On connect or LUSERS command |
| 311 | RPL_WHOISUSER | WHOIS user info |
| 312 | RPL_WHOISSERVER | WHOIS server info |
| 315 | RPL_ENDOFWHO | End of WHO list |
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
| 322 | RPL_LIST | Channel in LIST response |
| 323 | RPL_LISTEND | End of LIST |
| 324 | RPL_CHANNELMODEIS | Channel mode query response |
| 329 | RPL_CREATIONTIME | Channel creation timestamp |
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
| 352 | RPL_WHOREPLY | User in WHO response |
| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) |
| 366 | RPL_ENDOFNAMES | End of NAMES list |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 372 | RPL_MOTD | MOTD line |
| 376 | RPL_ENDOFMOTD | End of MOTD |
### GET /api/v1/history — Message History
@@ -1158,6 +1325,55 @@ curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/users/me — Current User Info
Return the current user's session state. This is an alias for
`GET /api/v1/state`.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"}
]
}
```
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/server — Server Info
Return server metadata. No authentication required.
@@ -1165,11 +1381,22 @@ Return server metadata. No authentication required.
**Response:** `200 OK`
```json
{
"name": "My Chat Server",
"motd": "Welcome! Be nice."
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}
```
| Field | Type | Description |
|-----------------|---------|-------------|
| `name` | string | Server display name |
| `version` | string | Server version |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required.
@@ -1407,12 +1634,16 @@ authenticity.
termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
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.
---
## Federation (Server-to-Server)
Federation allows multiple chat servers to link together, forming a network
Federation allows multiple neoirc servers to link together, forming a network
where users on different servers can share channels — similar to IRC server
linking.
@@ -1572,8 +1803,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
planned.
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT`. Session expiry by `SESSION_TIMEOUT`
is planned.
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
24h) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
---
@@ -1587,10 +1820,10 @@ directory is also loaded automatically via
| Variable | Type | Default | Description |
|--------------------|---------|--------------------------------------|-------------|
| `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_TIMEOUT` | int | `86400` | Session idle timeout in seconds (planned). Sessions with no activity for this long are expired and the nick is released. |
| `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
@@ -1600,17 +1833,19 @@ directory is also loaded automatically via
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file
```bash
PORT=8080
SERVER_NAME=My Chat Server
SERVER_NAME=My NeoIRC Server
MOTD=Welcome! Be excellent to each other.
DEBUG=false
DBURL=file:./data.db?_journal_mode=WAL
SESSION_TIMEOUT=86400
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h
NEOIRC_HASHCASH_BITS=20
```
---
@@ -1619,52 +1854,41 @@ SESSION_TIMEOUT=86400
### Docker (Recommended)
The Docker image contains a single static binary (`chatd`) and nothing else.
The Docker image contains a single static binary (`neoircd`) and nothing else.
```bash
# Build
docker build -t chat .
docker build -t neoirc .
# Run
docker run -p 8080:8080 \
-v chat-data:/data \
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
-v neoirc-data:/var/lib/neoirc \
-e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \
chat
neoirc
```
The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify
The Dockerfile is a four-stage build:
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)
2. **Final stage**: Alpine Linux + `chatd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /chatd ./cmd/chatd/
RUN go build -o /chat-cli ./cmd/chat-cli/
FROM alpine:latest
COPY --from=builder /chatd /usr/local/bin/chatd
EXPOSE 8080
CMD ["chatd"]
```
4. **final**: Minimal Alpine image with only the `neoircd` binary
### Binary
```bash
# Build from source
make build
# Binary at ./bin/chatd
# Binary at ./bin/neoircd
# Run
./bin/chatd
# Listens on :8080, creates ./data.db
./bin/neoircd
# Listens on :8080, writes to /var/lib/neoirc/state.db
```
### Reverse Proxy (Production)
@@ -1673,7 +1897,7 @@ For production, run behind a TLS-terminating reverse proxy.
**Caddy:**
```
chat.example.com {
neoirc.example.com {
reverse_proxy localhost:8080
}
```
@@ -1682,7 +1906,7 @@ chat.example.com {
```nginx
server {
listen 443 ssl;
server_name chat.example.com;
server_name neoirc.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
@@ -1705,15 +1929,15 @@ seconds to accommodate long-poll connections.
string). This allows concurrent reads during writes.
- **Single writer**: SQLite allows only one writer at a time. For high-traffic
servers, Postgres support is planned.
- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode).
- **Location**: By default, `data.db` is created in the working directory.
- **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode).
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`.
Use the `DBURL` env var to place it elsewhere.
---
## Client Development Guide
This section explains how to write a client against the chat API. The API is
This section explains how to write a client against the neoirc API. The API is
designed to be simple enough that a basic client can be written in any language
with an HTTP client library.
@@ -1894,62 +2118,102 @@ Clients should handle these message commands from the queue:
## Rate Limiting & Abuse Prevention
Session creation (`POST /api/v1/session`) will require a
### Hashcash Proof-of-Work
Session creation (`POST /api/v1/session`) requires a
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
This is the primary defense against resource exhaustion — no CAPTCHAs, no
account registration, no IP-based rate limits that punish shared networks.
### How It Works
1. Client requests a challenge: `GET /api/v1/challenge`
```json
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
```
2. Server returns a nonce and a required difficulty (number of leading zero
bits in the SHA-256 hash)
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
has the required number of leading zero bits:
```
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
...
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
```
4. Client submits the proof with the session request:
```json
POST /api/v1/session
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
```
5. Server verifies:
- Nonce was issued by this server and hasn't expired
- Nonce hasn't been used before (prevent replay)
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
- If valid, create the session normally
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
field (e.g., `20`) indicating the required difficulty.
2. Client computes a hashcash stamp: find a counter value such that the
SHA-256 hash of the stamp string has the required number of leading zero
bits.
3. Client includes the stamp in the `hashcash` field of the JSON request body when creating
a session: `POST /api/v1/session`.
4. Server validates the stamp:
- Version is `1`
- Claimed bits ≥ required bits
- Resource matches the server name
- Date is within 48 hours (not expired, not too far in the future)
- SHA-256 hash has the required leading zero bits
- Stamp has not been used before (replay prevention)
### Adaptive Difficulty
### Stamp Format
The required difficulty scales with server load. Under normal conditions, the
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
session creation rate increases, difficulty rises — making bulk session creation
exponentially more expensive for attackers while remaining cheap for legitimate
single-user connections.
Standard hashcash format:
| Server Load | Difficulty (bits) | Approx. Client CPU |
|--------------------|-------------------|--------------------|
| Normal (< 100/min) | 16 | ~1ms |
| Elevated | 20 | ~15ms |
| High | 24 | ~250ms |
| Under attack | 28+ | ~4s+ |
```
1:bits:date:resource::counter
```
Each additional bit of difficulty doubles the expected work. An attacker
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
user creating one session needs ~4 seconds once and never again for the
duration of their session.
| Field | Description |
|------------|-------------|
| `1` | Version (always `1`) |
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
| (empty) | Extension field (unused) |
| `counter` | Hex counter value found by the client to satisfy the PoW |
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
### Computing a Stamp
```bash
# Pseudocode
bits = 20
resource = "neoirc"
date = "260310" # YYMMDD in UTC
counter = 0
loop:
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
hash = SHA-256(stamp)
if leading_zero_bits(hash) >= bits:
return stamp
counter++
```
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
average, taking roughly 0.52 seconds on modern hardware.
### Client Integration
Both the embedded web SPA and the CLI client automatically handle hashcash:
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
2. If `hashcash_bits > 0`, compute a valid stamp
3. Include the stamp in the `hashcash` field of the JSON body on `POST /api/v1/session`
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
### Configuration
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
| Value | Effect | Approx. Client CPU |
|-------|--------|---------------------|
| `0` | Disabled (no proof-of-work required) | — |
| `16` | Light protection | ~1ms |
| `20` | Default — good balance | ~0.52s |
| `24` | Strong protection | ~1030s |
| `28` | Very strong (may frustrate users) | ~210min |
Each additional bit doubles the expected work. An attacker creating 1000
sessions at difficulty 20 needs ~10002000 CPU-seconds; a legitimate user
creating one session pays once and keeps their session.
### Why Hashcash and Not Rate Limits?
- **No state to track**: No IP tables, no token buckets, no sliding windows.
The server only needs to verify a hash.
The server only needs to verify a single hash.
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
campuses, corporate networks, Tor exits). Every client computes their own
proof independently.
@@ -1957,36 +2221,9 @@ duration of their session.
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
No registration, no email, no phone number, no CAPTCHA. Just compute.
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
once. A botnet trying to create thousands of sessions pays exponentially more.
- **Language-agnostic**: SHA-256 is available in every programming language.
The proof computation is trivially implementable in any client.
### Challenge Endpoint (Planned)
```
GET /api/v1/challenge
```
**Response:** `200 OK`
```json
{
"nonce": "a1b2c3d4e5f6...",
"difficulty": 20,
"algorithm": "sha256",
"expires": "2026-02-10T20:01:00Z"
}
```
| Field | Type | Description |
|--------------|---------|-------------|
| `nonce` | string | Server-generated random hex string (32+ chars) |
| `difficulty` | integer | Required number of leading zero bits in the hash |
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
| `expires` | string | ISO 8601 expiry time for this challenge |
**Status:** Not yet implemented. Tracked for post-MVP.
---
## Roadmap
@@ -2003,24 +2240,35 @@ GET /api/v1/challenge
- [x] NICK change with broadcast
- [x] QUIT with broadcast and cleanup
- [x] Embedded web SPA client
- [x] CLI client (chat-cli)
- [x] CLI client (neoirc-cli)
- [x] SQLite storage with WAL mode
- [x] Docker deployment
- [x] Prometheus metrics endpoint
- [x] Health check endpoint
- [x] Session expiry — auto-expire idle sessions, release nicks
- [x] Logout endpoint (`POST /api/v1/logout`)
- [x] Current user endpoint (`GET /api/v1/users/me`)
- [x] User count in server info (`GET /api/v1/server`)
### Post-MVP (Planned)
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Session expiry** — auto-expire idle sessions, release nicks
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** — `+o` (operator), `+v` (voice)
- [ ] **MODE command** — set/query channel and user modes
- [x] **MODE command** — query channel and user modes (set not yet implemented)
- [x] **NAMES command** — query channel member list
- [x] **LIST command** — list all channels with member counts
- [x] **WHOIS command** — query user information and channel membership
- [x] **WHO command** — query channel user list
- [x] **LUSERS command** — query server statistics
- [x] **Connection registration numerics** — 001-005 sent on session creation
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS
- [ ] **KICK command** — remove users from channels
- [ ] **Numeric replies** — send IRC numeric codes via the message queue
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
- [x] **Numeric replies** — send IRC numeric codes via the message queue
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE,
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [ ] **Multi-client sessions** — add client to existing session
@@ -2040,7 +2288,7 @@ GET /api/v1/challenge
- [ ] **Push notifications** — optional webhook/push for mobile clients
when messages arrive during disconnect
- [ ] **Message search** — full-text search over channel history
- [ ] **User info command** — WHOIS-equivalent for querying user metadata
- [x] **User info command** — WHOIS for querying user info and channels
- [ ] **Connection flood protection** — per-IP connection limits as a
complement to hashcash
- [ ] **Invite system** — `INVITE` command for `+i` channels
@@ -2053,19 +2301,22 @@ GET /api/v1/challenge
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
```
chat/
neoirc/
├── cmd/
│ ├── chatd/ # Server binary entry point
│ ├── neoircd/ # Server binary entry point
│ │ └── main.go
│ └── chat-cli/ # TUI client
── main.go # Command handling, poll loop
│ ├── ui.go # tview-based terminal UI
│ └── api/
│ ├── client.go # HTTP API client library
│ └── types.go # Request/response types
│ └── neoirc-cli/ # TUI client entry point
── main.go # Minimal bootstrapping (calls internal/cli)
├── internal/
│ ├── broker/ # In-memory pub/sub for long-poll notifications
│ │ └── broker.go
│ ├── cli/ # TUI client implementation
│ │ ├── app.go # App struct, command handling, poll loop
│ │ ├── ui.go # tview-based terminal UI
│ │ └── api/
│ │ ├── client.go # HTTP API client library
│ │ ├── types.go # Request/response types
│ │ └── hashcash.go # Hashcash proof-of-work minting
│ ├── config/ # Viper-based configuration
│ │ └── config.go
│ ├── db/ # Database access and migrations
@@ -2091,10 +2342,14 @@ chat/
│ └── http.go # HTTP timeouts
├── web/
│ ├── embed.go # go:embed directive for SPA
── dist/ # Built SPA (vanilla JS, no build step)
├── index.html
├── style.css
└── app.js
── build.sh # SPA build script (esbuild, runs in Docker)
├── package.json # Node dependencies (preact, esbuild)
├── package-lock.json
├── 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)
├── go.mod
├── go.sum
@@ -2188,7 +2443,7 @@ chat/
- IRC message envelope format with per-client queue fan-out
- Long-polling with in-memory broker
- Embedded web SPA client
- TUI client (chat-cli)
- TUI client (neoirc-cli)
- Docker image
- Prometheus metrics

View File

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

8
cmd/neoirc-cli/main.go Normal file
View File

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

View File

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

15
go.mod
View File

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

30
go.sum
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
package chatapi
package neoircapi
import "time"
// SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct {
Nick string `json:"nick"`
Nick string `json:"nick"`
Hashcash string `json:"hashcash,omitempty"`
}
// SessionResponse is the response from session creation.
@@ -21,7 +22,7 @@ type StateResponse struct {
Channels []string `json:"channels"`
}
// Message represents a chat message envelope.
// Message represents a neoirc message envelope.
type Message struct {
Command string `json:"command"`
From string `json:"from,omitempty"`
@@ -63,9 +64,10 @@ type Channel struct {
// ServerInfo is the response from GET /api/v1/server.
type ServerInfo struct {
Name string `json:"name"`
MOTD string `json:"motd"`
Version string `json:"version"`
Name string `json:"name"`
MOTD string `json:"motd"`
Version string `json:"version"`
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
}
// MessagesResponse wraps polling results.

View File

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

View File

@@ -1,4 +1,4 @@
package main
package cli
import (
"fmt"

View File

@@ -5,14 +5,22 @@ import (
"errors"
"log/slog"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper"
"go.uber.org/fx"
_ "github.com/joho/godotenv/autoload" // loads .env file
)
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// Params defines the dependencies for creating a Config.
type Params struct {
fx.In
@@ -23,21 +31,22 @@ type Params struct {
// Config holds all application configuration values.
type Config struct {
DBURL string
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
SessionTimeout int
MaxMessageSize int
MOTD string
ServerName string
FederationKey string
params *Params
log *slog.Logger
DBURL string
Debug bool
MaintenanceMode bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
MaxHistory int
MaxMessageSize int
MOTD string
ServerName string
FederationKey string
SessionIdleTimeout string
HashcashBits int
params *Params
log *slog.Logger
}
// New creates a new Config by reading from files and environment variables.
@@ -56,16 +65,17 @@ func New(
viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("SESSION_TIMEOUT", "86400")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("MOTD", "")
viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig()
if err != nil {
@@ -77,21 +87,22 @@ func New(
}
cfg := &Config{
DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
log: log,
params: &params,
DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
log: log,
params: &params,
}
if cfg.Debug {

161
internal/db/auth.go Normal file
View File

@@ -0,0 +1,161 @@
package db
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = bcrypt.DefaultCost
var errNoPassword = errors.New(
"account has no password set",
)
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
}
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
}
// LoginUser verifies a nick/password and creates a new
// client token.
func (database *Database) LoginUser(
ctx context.Context,
nick, password string,
) (int64, int64, string, error) {
var (
sessionID int64
passwordHash string
)
err := database.conn.QueryRowContext(
ctx,
`SELECT id, password_hash
FROM sessions WHERE nick = ?`,
nick,
).Scan(&sessionID, &passwordHash)
if err != nil {
return 0, 0, "", fmt.Errorf(
"get session for login: %w", err,
)
}
if passwordHash == "" {
return 0, 0, "", fmt.Errorf(
"login: %w", errNoPassword,
)
}
err = bcrypt.CompareHashAndPassword(
[]byte(passwordHash), []byte(password),
)
if err != nil {
return 0, 0, "", fmt.Errorf(
"verify password: %w", err,
)
}
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, 0, "", err
}
now := time.Now()
res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
return 0, 0, "", fmt.Errorf(
"create login client: %w", err,
)
}
clientID, _ := res.LastInsertId()
_, _ = database.conn.ExecContext(
ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID,
)
return sessionID, clientID, token, nil
}

178
internal/db/auth_test.go Normal file
View File

@@ -0,0 +1,178 @@
package db_test
import (
"testing"
_ "modernc.org/sqlite"
)
func TestRegisterUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
_ = dupSID
_ = dupCID
_ = dupToken
}
func TestLoginUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify the new token works.
_, _, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "loginuser" {
t.Fatalf("expected loginuser, got %s", nick)
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create anonymous session (no password).
anonSID, anonCID, anonToken, err :=
database.CreateSession(ctx, "anon")
if err != nil {
t.Fatal(err)
}
_ = anonSID
_ = anonCID
_ = anonToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "anon", "anything1")
if loginErr == nil {
t.Fatal(
"expected error for login on passwordless account",
)
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNonexistent(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "ghost", "password123")
if err == nil {
t.Fatal("expected error for nonexistent user")
}
_ = loginSID
_ = loginCID
_ = loginToken
}

View File

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

View File

@@ -7,8 +7,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid"
)
@@ -33,14 +35,25 @@ func generateToken() (string, error) {
type IRCMessage struct {
ID string `json:"id"`
Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"`
DBID int64 `json:"-"`
}
// isNumericCode returns true if s is exactly a 3-digit
// IRC numeric reply code.
func isNumericCode(s string) bool {
return len(s) == 3 &&
s[0] >= '0' && s[0] <= '9' &&
s[1] >= '0' && s[1] <= '9' &&
s[2] >= '0' && s[2] <= '9'
}
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID int64 `json:"id"`
@@ -55,76 +68,132 @@ type MemberInfo struct {
LastSeen time.Time `json:"lastSeen"`
}
// CreateUser registers a new user with the given nick.
func (database *Database) CreateUser(
// CreateSession registers a new session and its first client.
func (database *Database) CreateSession(
ctx context.Context,
nick string,
) (int64, string, error) {
) (int64, int64, string, error) {
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return 0, "", err
return 0, 0, "", err
}
now := time.Now()
res, err := database.conn.ExecContext(ctx,
`INSERT INTO users
(nick, token, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
nick, token, now, now)
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("create user: %w", err)
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
userID, _ := res.LastInsertId()
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, created_at, last_seen)
VALUES (?, ?, ?, ?)`,
sessionUUID, nick, now, now)
if err != nil {
_ = transaction.Rollback()
return userID, token, nil
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, token, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit session: %w", err,
)
}
return sessionID, clientID, token, nil
}
// GetUserByToken returns user id and nick for a token.
func (database *Database) GetUserByToken(
// GetSessionByToken returns session id, client id, and
// nick for a client token.
func (database *Database) GetSessionByToken(
ctx context.Context,
token string,
) (int64, string, error) {
var userID int64
var nick string
) (int64, int64, string, error) {
var (
sessionID int64
clientID int64
nick string
)
err := database.conn.QueryRowContext(
ctx,
"SELECT id, nick FROM users WHERE token = ?",
`SELECT s.id, c.id, s.nick
FROM clients c
INNER JOIN sessions s
ON s.id = c.session_id
WHERE c.token = ?`,
token,
).Scan(&userID, &nick)
).Scan(&sessionID, &clientID, &nick)
if err != nil {
return 0, "", fmt.Errorf("get user by token: %w", err)
return 0, 0, "", fmt.Errorf(
"get session by token: %w", err,
)
}
now := time.Now()
_, _ = database.conn.ExecContext(
ctx,
"UPDATE users SET last_seen = ? WHERE id = ?",
time.Now(), userID,
"UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID,
)
return userID, nick, nil
_, _ = database.conn.ExecContext(
ctx,
"UPDATE clients SET last_seen = ? WHERE id = ?",
now, clientID,
)
return sessionID, clientID, nick, nil
}
// GetUserByNick returns user id for a given nick.
func (database *Database) GetUserByNick(
// GetSessionByNick returns session id for a given nick.
func (database *Database) GetSessionByNick(
ctx context.Context,
nick string,
) (int64, error) {
var userID int64
var sessionID int64
err := database.conn.QueryRowContext(
ctx,
"SELECT id FROM users WHERE nick = ?",
"SELECT id FROM sessions WHERE nick = ?",
nick,
).Scan(&userID)
).Scan(&sessionID)
if err != nil {
return 0, fmt.Errorf("get user by nick: %w", err)
return 0, fmt.Errorf(
"get session by nick: %w", err,
)
}
return userID, nil
return sessionID, nil
}
// GetChannelByName returns the channel ID for a name.
@@ -179,16 +248,16 @@ func (database *Database) GetOrCreateChannel(
return channelID, nil
}
// JoinChannel adds a user to a channel.
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, userID int64,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, user_id, joined_at)
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, userID, time.Now())
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
@@ -196,15 +265,15 @@ func (database *Database) JoinChannel(
return nil
}
// PartChannel removes a user from a channel.
// PartChannel removes a session from a channel.
func (database *Database) PartChannel(
ctx context.Context,
channelID, userID int64,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND user_id = ?`,
channelID, userID)
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("part channel: %w", err)
}
@@ -265,18 +334,18 @@ func scanChannels(
return out, nil
}
// ListChannels returns channels the user has joined.
// ListChannels returns channels the session has joined.
func (database *Database) ListChannels(
ctx context.Context,
userID int64,
sessionID int64,
) ([]ChannelInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic
FROM channels c
INNER JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.user_id = ?
ORDER BY c.name`, userID)
WHERE cm.session_id = ?
ORDER BY c.name`, sessionID)
if err != nil {
return nil, fmt.Errorf("list channels: %w", err)
}
@@ -306,12 +375,12 @@ func (database *Database) ChannelMembers(
channelID int64,
) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT u.id, u.nick, u.last_seen
FROM users u
`SELECT s.id, s.nick, s.last_seen
FROM sessions s
INNER JOIN channel_members cm
ON cm.user_id = u.id
ON cm.session_id = s.id
WHERE cm.channel_id = ?
ORDER BY u.nick`, channelID)
ORDER BY s.nick`, channelID)
if err != nil {
return nil, fmt.Errorf(
"query channel members: %w", err,
@@ -349,17 +418,17 @@ func (database *Database) ChannelMembers(
return members, nil
}
// IsChannelMember checks if a user belongs to a channel.
// IsChannelMember checks if a session belongs to a channel.
func (database *Database) IsChannelMember(
ctx context.Context,
channelID, userID int64,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_members
WHERE channel_id = ? AND user_id = ?`,
channelID, userID,
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
@@ -397,13 +466,13 @@ func scanInt64s(rows *sql.Rows) ([]int64, error) {
return ids, nil
}
// GetChannelMemberIDs returns user IDs in a channel.
// GetChannelMemberIDs returns session IDs in a channel.
func (database *Database) GetChannelMemberIDs(
ctx context.Context,
channelID int64,
) ([]int64, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT user_id FROM channel_members
`SELECT session_id FROM channel_members
WHERE channel_id = ?`, channelID)
if err != nil {
return nil, fmt.Errorf(
@@ -414,17 +483,17 @@ func (database *Database) GetChannelMemberIDs(
return scanInt64s(rows)
}
// GetUserChannelIDs returns channel IDs the user is in.
func (database *Database) GetUserChannelIDs(
// GetSessionChannelIDs returns channel IDs for a session.
func (database *Database) GetSessionChannelIDs(
ctx context.Context,
userID int64,
sessionID int64,
) ([]int64, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE user_id = ?`, userID)
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get user channel ids: %w", err,
"get session channel ids: %w", err,
)
}
@@ -435,12 +504,17 @@ func (database *Database) GetUserChannelIDs(
func (database *Database) InsertMessage(
ctx context.Context,
command, from, target string,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) {
msgUUID := uuid.New().String()
now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil {
body = json.RawMessage("[]")
}
@@ -452,10 +526,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages
(uuid, command, msg_from, msg_to,
body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
params, body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target,
string(body), string(meta), now)
string(params), string(body), string(meta), now)
if err != nil {
return 0, "", fmt.Errorf(
"insert message: %w", err,
@@ -467,27 +541,52 @@ func (database *Database) InsertMessage(
return dbID, msgUUID, nil
}
// EnqueueMessage adds a message to a user's queue.
func (database *Database) EnqueueMessage(
// EnqueueToSession adds a message to all clients of a
// session's queues.
func (database *Database) EnqueueToSession(
ctx context.Context,
userID, messageID int64,
sessionID, messageID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues
(user_id, message_id, created_at)
VALUES (?, ?, ?)`,
userID, messageID, time.Now())
(client_id, message_id, created_at)
SELECT c.id, ?, ?
FROM clients c
WHERE c.session_id = ?`,
messageID, time.Now(), sessionID)
if err != nil {
return fmt.Errorf("enqueue message: %w", err)
return fmt.Errorf(
"enqueue to session: %w", err,
)
}
return nil
}
// PollMessages returns queued messages for a user.
// EnqueueToClient adds a message to a specific client's
// queue.
func (database *Database) EnqueueToClient(
ctx context.Context,
clientID, messageID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at)
VALUES (?, ?, ?)`,
clientID, messageID, time.Now())
if err != nil {
return fmt.Errorf(
"enqueue to client: %w", err,
)
}
return nil
}
// PollMessages returns queued messages for a client.
func (database *Database) PollMessages(
ctx context.Context,
userID, afterQueueID int64,
clientID, afterQueueID int64,
limit int,
) ([]IRCMessage, int64, error) {
if limit <= 0 {
@@ -497,13 +596,13 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to,
m.body, m.meta, m.created_at
m.params, m.body, m.meta, m.created_at
FROM client_queues cq
INNER JOIN messages m
ON m.id = cq.message_id
WHERE cq.user_id = ? AND cq.id > ?
WHERE cq.client_id = ? AND cq.id > ?
ORDER BY cq.id ASC LIMIT ?`,
userID, afterQueueID, limit)
clientID, afterQueueID, limit)
if err != nil {
return nil, afterQueueID, fmt.Errorf(
"poll messages: %w", err,
@@ -561,7 +660,7 @@ func (database *Database) queryHistory(
if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
msg_to, params, body, meta, created_at
FROM messages
WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG'
@@ -578,7 +677,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, body, meta, created_at
msg_to, params, body, meta, created_at
FROM messages
WHERE msg_to = ?
AND command = 'PRIVMSG'
@@ -603,16 +702,16 @@ func scanMessages(
for rows.Next() {
var (
msg IRCMessage
qID int64
body, meta string
createdAt time.Time
msg IRCMessage
qID int64
params, body, meta string
createdAt time.Time
)
err := rows.Scan(
&qID, &msg.ID, &msg.Command,
&msg.From, &msg.To,
&body, &meta, &createdAt,
&params, &body, &meta, &createdAt,
)
if err != nil {
return nil, fallbackQID, fmt.Errorf(
@@ -620,12 +719,25 @@ func scanMessages(
)
}
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano)
msg.DBID = qID
lastQID = qID
if isNumericCode(msg.Command) {
code, _ := strconv.Atoi(msg.Command)
msg.Code = code
if name := irc.Name(code); name != "" {
msg.Command = name
}
}
msgs = append(msgs, msg)
}
@@ -649,15 +761,15 @@ func reverseMessages(msgs []IRCMessage) {
}
}
// ChangeNick updates a user's nickname.
// ChangeNick updates a session's nickname.
func (database *Database) ChangeNick(
ctx context.Context,
userID int64,
sessionID int64,
newNick string,
) error {
_, err := database.conn.ExecContext(ctx,
"UPDATE users SET nick = ? WHERE id = ?",
newNick, userID)
"UPDATE sessions SET nick = ? WHERE id = ?",
newNick, sessionID)
if err != nil {
return fmt.Errorf("change nick: %w", err)
}
@@ -681,40 +793,306 @@ func (database *Database) SetTopic(
return nil
}
// DeleteUser removes a user and all their data.
func (database *Database) DeleteUser(
// DeleteSession removes a session and all its data.
func (database *Database) DeleteSession(
ctx context.Context,
userID int64,
sessionID int64,
) error {
_, err := database.conn.ExecContext(
ctx,
"DELETE FROM users WHERE id = ?",
userID,
"DELETE FROM sessions WHERE id = ?",
sessionID,
)
if err != nil {
return fmt.Errorf("delete user: %w", err)
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// GetAllChannelMembershipsForUser returns channels
// a user belongs to.
func (database *Database) GetAllChannelMembershipsForUser(
// DeleteClient removes a single client record by ID.
func (database *Database) DeleteClient(
ctx context.Context,
userID int64,
clientID int64,
) error {
_, err := database.conn.ExecContext(
ctx,
"DELETE FROM clients WHERE id = ?",
clientID,
)
if err != nil {
return fmt.Errorf("delete client: %w", err)
}
return nil
}
// GetUserCount returns the number of active users.
func (database *Database) GetUserCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sessions",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get user count: %w", err,
)
}
return count, nil
}
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
ctx context.Context,
sessionID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
`SELECT COUNT(*) FROM clients
WHERE session_id = ?`,
sessionID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"client count for session: %w", err,
)
}
return count, nil
}
// DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleUsers(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM clients WHERE last_seen < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"delete stale clients: %w", err,
)
}
deleted, _ := res.RowsAffected()
_, err = database.conn.ExecContext(ctx,
`DELETE FROM sessions WHERE id NOT IN
(SELECT DISTINCT session_id FROM clients)`,
)
if err != nil {
return deleted, fmt.Errorf(
"delete orphan sessions: %w", err,
)
}
return deleted, nil
}
// StaleSession holds the id and nick of a session
// whose clients are all stale.
type StaleSession struct {
ID int64
Nick string
}
// GetStaleOrphanSessions returns sessions where every
// client has a last_seen before cutoff.
func (database *Database) GetStaleOrphanSessions(
ctx context.Context,
cutoff time.Time,
) ([]StaleSession, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick
FROM sessions s
WHERE s.id IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen < ?
)
AND s.id NOT IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen >= ?
)`, cutoff, cutoff)
if err != nil {
return nil, fmt.Errorf(
"get stale orphan sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var result []StaleSession
for rows.Next() {
var stale StaleSession
if err := rows.Scan(&stale.ID, &stale.Nick); err != nil {
return nil, fmt.Errorf(
"scan stale session: %w", err,
)
}
result = append(result, stale)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate stale sessions: %w", err,
)
}
return result, nil
}
// GetSessionChannels returns channels a session
// belongs to.
func (database *Database) GetSessionChannels(
ctx context.Context,
sessionID int64,
) ([]ChannelInfo, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic
FROM channels c
INNER JOIN channel_members cm
ON cm.channel_id = c.id
WHERE cm.user_id = ?`, userID)
WHERE cm.session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get memberships: %w", err,
"get session channels: %w", err,
)
}
return scanChannels(rows)
}
// GetChannelCount returns the total number of channels.
func (database *Database) GetChannelCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM channels",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get channel count: %w", err,
)
}
return count, nil
}
// ChannelInfoFull contains extended channel information.
type ChannelInfoFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
MemberCount int64 `json:"memberCount"`
}
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a
// channel.
func (database *Database) GetChannelCreatedAt(
ctx context.Context,
channelID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM channels WHERE id = ?",
channelID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get channel created_at: %w", err,
)
}
return createdAt, nil
}
// GetSessionCreatedAt returns the creation time of a
// session.
func (database *Database) GetSessionCreatedAt(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM sessions WHERE id = ?",
sessionID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session created_at: %w", err,
)
}
return createdAt, nil
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/neoirc/internal/db"
_ "modernc.org/sqlite"
)
@@ -27,70 +27,91 @@ func setupTestDB(t *testing.T) *db.Database {
return database
}
func TestCreateUser(t *testing.T) {
func TestCreateSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
id, token, err := database.CreateUser(ctx, "alice")
sessionID, _, token, err := database.CreateSession(
ctx, "alice",
)
if err != nil {
t.Fatal(err)
}
if id == 0 || token == "" {
if sessionID == 0 || token == "" {
t.Fatal("expected valid id and token")
}
_, _, err = database.CreateUser(ctx, "alice")
if err == nil {
_, _, dupToken, dupErr := database.CreateSession(
ctx, "alice",
)
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
_ = dupToken
}
func TestGetUserByToken(t *testing.T) {
func TestGetSessionByToken(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, token, err := database.CreateUser(ctx, "bob")
_, _, token, err := database.CreateSession(ctx, "bob")
if err != nil {
t.Fatal(err)
}
id, nick, err := database.GetUserByToken(ctx, token)
sessionID, clientID, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if nick != "bob" || id == 0 {
if nick != "bob" || sessionID == 0 || clientID == 0 {
t.Fatalf("expected bob, got %s", nick)
}
_, _, err = database.GetUserByToken(ctx, "badtoken")
if err == nil {
badSID, badCID, badNick, badErr :=
database.GetSessionByToken(ctx, "badtoken")
if badErr == nil {
t.Fatal("expected error for bad token")
}
if badSID != 0 || badCID != 0 || badNick != "" {
t.Fatal("expected zero values on error")
}
}
func TestGetUserByNick(t *testing.T) {
func TestGetSessionByNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, err := database.CreateUser(ctx, "charlie")
charlieID, charlieClientID, charlieToken, err :=
database.CreateSession(ctx, "charlie")
if err != nil {
t.Fatal(err)
}
id, err := database.GetUserByNick(ctx, "charlie")
if charlieID == 0 || charlieClientID == 0 {
t.Fatal("expected valid session/client IDs")
}
if charlieToken == "" {
t.Fatal("expected non-empty token")
}
id, err := database.GetSessionByNick(ctx, "charlie")
if err != nil || id == 0 {
t.Fatal("expected to find charlie")
}
_, err = database.GetUserByNick(ctx, "nobody")
_, err = database.GetSessionByNick(ctx, "nobody")
if err == nil {
t.Fatal("expected error for unknown nick")
}
@@ -129,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "user1")
sid, _, _, err := database.CreateSession(ctx, "user1")
if err != nil {
t.Fatal(err)
}
@@ -139,22 +160,22 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
ids, err := database.GetChannelMemberIDs(ctx, chID)
if err != nil || len(ids) != 1 || ids[0] != uid {
t.Fatal("expected user in channel")
if err != nil || len(ids) != 1 || ids[0] != sid {
t.Fatal("expected session in channel")
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
@@ -178,17 +199,17 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err)
}
uid, _, err := database.CreateUser(ctx, "temp")
sid, _, _, err := database.CreateSession(ctx, "temp")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.PartChannel(ctx, chID, uid)
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
@@ -204,7 +225,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
}
}
func createUserWithChannels(
func createSessionWithChannels(
t *testing.T,
database *db.Database,
nick, ch1Name, ch2Name string,
@@ -213,7 +234,7 @@ func createUserWithChannels(
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, nick)
sid, _, _, err := database.CreateSession(ctx, nick)
if err != nil {
t.Fatal(err)
}
@@ -232,29 +253,29 @@ func createUserWithChannels(
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch1, uid)
err = database.JoinChannel(ctx, ch1, sid)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, ch2, uid)
err = database.JoinChannel(ctx, ch2, sid)
if err != nil {
t.Fatal(err)
}
return uid, ch1, ch2
return sid, ch1, ch2
}
func TestListChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
sid, _, _ := createSessionWithChannels(
t, database, "lister", "#a", "#b",
)
channels, err := database.ListChannels(
t.Context(), uid,
t.Context(), sid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
@@ -295,17 +316,21 @@ func TestChangeNick(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid, token, err := database.CreateUser(ctx, "old")
sid, _, token, err := database.CreateSession(
ctx, "old",
)
if err != nil {
t.Fatal(err)
}
err = database.ChangeNick(ctx, uid, "new")
err = database.ChangeNick(ctx, sid, "new")
if err != nil {
t.Fatal(err)
}
_, nick, err := database.GetUserByToken(ctx, token)
_, _, nick, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
@@ -358,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
)
if err != nil {
t.Fatal(err)
@@ -375,7 +400,16 @@ func TestPollMessages(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "poller")
sid, _, token, err := database.CreateSession(
ctx, "poller",
)
if err != nil {
t.Fatal(err)
}
_, clientID, _, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
@@ -383,13 +417,13 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", body, nil,
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
)
if err != nil {
t.Fatal(err)
}
err = database.EnqueueMessage(ctx, uid, dbID)
err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil {
t.Fatal(err)
}
@@ -397,7 +431,7 @@ func TestPollMessages(t *testing.T) {
const batchSize = 10
msgs, lastQID, err := database.PollMessages(
ctx, uid, 0, batchSize,
ctx, clientID, 0, batchSize,
)
if err != nil {
t.Fatal(err)
@@ -420,7 +454,7 @@ func TestPollMessages(t *testing.T) {
}
msgs, _, _ = database.PollMessages(
ctx, uid, lastQID, batchSize,
ctx, clientID, lastQID, batchSize,
)
if len(msgs) != 0 {
@@ -441,7 +475,7 @@ func TestGetHistory(t *testing.T) {
for range msgCount {
_, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist",
json.RawMessage(`["msg"]`), nil,
nil, json.RawMessage(`["msg"]`), nil,
)
if err != nil {
t.Fatal(err)
@@ -467,13 +501,15 @@ func TestGetHistory(t *testing.T) {
}
}
func TestDeleteUser(t *testing.T) {
func TestDeleteSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
uid, _, err := database.CreateUser(ctx, "deleteme")
sid, _, _, err := database.CreateSession(
ctx, "deleteme",
)
if err != nil {
t.Fatal(err)
}
@@ -485,19 +521,19 @@ func TestDeleteUser(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteUser(ctx, uid)
err = database.DeleteSession(ctx, sid)
if err != nil {
t.Fatal(err)
}
_, err = database.GetUserByNick(ctx, "deleteme")
_, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil {
t.Fatal("user should be deleted")
t.Fatal("session should be deleted")
}
ids, _ := database.GetChannelMemberIDs(ctx, chID)
@@ -512,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
uid1, _, err := database.CreateUser(ctx, "m1")
sid1, _, _, err := database.CreateSession(ctx, "m1")
if err != nil {
t.Fatal(err)
}
uid2, _, err := database.CreateUser(ctx, "m2")
sid2, _, _, err := database.CreateSession(ctx, "m2")
if err != nil {
t.Fatal(err)
}
@@ -529,12 +565,12 @@ func TestChannelMembers(t *testing.T) {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid1)
err = database.JoinChannel(ctx, chID, sid1)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, chID, uid2)
err = database.JoinChannel(ctx, chID, sid2)
if err != nil {
t.Fatal(err)
}
@@ -548,17 +584,17 @@ func TestChannelMembers(t *testing.T) {
}
}
func TestGetAllChannelMembershipsForUser(t *testing.T) {
func TestGetSessionChannels(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
uid, _, _ := createUserWithChannels(
sid, _, _ := createSessionWithChannels(
t, database, "multi", "#m1", "#m2",
)
channels, err :=
database.GetAllChannelMembershipsForUser(
t.Context(), uid,
database.GetSessionChannels(
t.Context(), sid,
)
if err != nil || len(channels) != 2 {
t.Fatalf(
@@ -567,3 +603,51 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) {
)
}
}
func TestEnqueueToClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
_, _, token, err := database.CreateSession(
ctx, "enqclient",
)
if err != nil {
t.Fatal(err)
}
_, clientID, _, err := database.GetSessionByToken(
ctx, token,
)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
)
if err != nil {
t.Fatal(err)
}
err = database.EnqueueToClient(ctx, clientID, dbID)
if err != nil {
t.Fatal(err)
}
const batchSize = 10
msgs, _, err := database.PollMessages(
ctx, clientID, 0, batchSize,
)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1, got %d", len(msgs))
}
}

View File

@@ -1,15 +1,29 @@
-- Chat server schema (pre-1.0 consolidated)
PRAGMA foreign_keys = ON;
-- Users: IRC-style sessions (no passwords, just nick + token)
CREATE TABLE IF NOT EXISTS users (
-- Sessions: each session is a user identity (nick + optional password + signing key)
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions(uuid);
-- Clients: each session can have multiple connected clients
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
CREATE INDEX IF NOT EXISTS idx_clients_token ON clients(token);
CREATE INDEX IF NOT EXISTS idx_clients_session ON clients(session_id);
-- Channels
CREATE TABLE IF NOT EXISTS channels (
@@ -24,9 +38,9 @@ CREATE TABLE IF NOT EXISTS channels (
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, user_id)
UNIQUE(channel_id, session_id)
);
-- Messages: IRC envelope format
@@ -36,6 +50,7 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -46,9 +61,9 @@ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, message_id)
UNIQUE(client_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_client_queues_user ON client_queues(user_id, id);
CREATE INDEX IF NOT EXISTS idx_client_queues_client ON client_queues(client_id, id);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,20 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/handlers"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/chat/internal/middleware"
"git.eeqj.de/sneak/chat/internal/server"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
@@ -84,6 +85,7 @@ func newTestServer(
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
return cfg, nil
},
@@ -115,8 +117,9 @@ func newTestServer(
func newTestGlobals() *globals.Globals {
return &globals.Globals{
Appname: "chat-test",
Version: "test",
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
}
@@ -462,6 +465,22 @@ func findMessage(
return false
}
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return true
}
}
return false
}
// --- Tests ---
func TestCreateSessionValid(t *testing.T) {
@@ -473,6 +492,47 @@ func TestCreateSessionValid(t *testing.T) {
}
}
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t)
tserver.createSession("alice")
@@ -668,11 +728,23 @@ func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd},
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -682,10 +754,10 @@ func TestChannelMessage(t *testing.T) {
bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#chat",
commandKey: joinCmd, toKey: "#test",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#chat",
commandKey: joinCmd, toKey: "#test",
})
_, _ = tserver.pollMessages(aliceToken, 0)
@@ -695,13 +767,13 @@ func TestChannelMessage(t *testing.T) {
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#chat",
toKey: "#test",
bodyKey: []string{"hello world"},
},
)
if status != http.StatusCreated {
if status != http.StatusOK {
t.Fatalf(
"expected 201, got %d: %v", status, result,
"expected 200, got %d: %v", status, result,
)
}
@@ -725,14 +797,25 @@ func TestMessageMissingBody(t *testing.T) {
token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#chat",
commandKey: joinCmd, toKey: "#test",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#chat",
commandKey: privmsgCmd, toKey: "#test",
})
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -740,12 +823,23 @@ func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
bodyKey: []string{"hello"},
})
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -759,6 +853,8 @@ func TestNonMemberCannotSend(t *testing.T) {
commandKey: joinCmd, toKey: "#private",
})
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining.
status, _ := tserver.sendCommand(
aliceToken,
@@ -768,8 +864,17 @@ func TestNonMemberCannotSend(t *testing.T) {
bodyKey: []string{"sneaky"},
},
)
if status != http.StatusForbidden {
t.Fatalf("expected 403, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
@@ -786,9 +891,9 @@ func TestDirectMessage(t *testing.T) {
bodyKey: []string{"hey bob"},
},
)
if status != http.StatusCreated {
if status != http.StatusOK {
t.Fatalf(
"expected 201, got %d: %v", status, result,
"expected 200, got %d: %v", status, result,
)
}
@@ -818,13 +923,24 @@ func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "nobody",
bodyKey: []string{"hello?"},
})
if status != http.StatusNotFound {
t.Fatalf("expected 404, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
@@ -871,12 +987,23 @@ func TestNickCollision(t *testing.T) {
tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"taken_nick"},
})
if status != http.StatusConflict {
t.Fatalf("expected 409, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
}
}
@@ -884,12 +1011,23 @@ func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
bodyKey: []string{"bad nick!"},
})
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
}
}
@@ -897,11 +1035,22 @@ func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "NICK"},
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -938,12 +1087,23 @@ func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC",
bodyKey: []string{"topic"},
})
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -955,11 +1115,22 @@ func TestTopicMissingBody(t *testing.T) {
commandKey: joinCmd, toKey: "#topictest",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest",
})
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
@@ -1027,11 +1198,22 @@ func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"},
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
}
}
@@ -1278,12 +1460,18 @@ func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now()
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url(apiMessages+"?timeout=1"),
tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token,
nil,
)
@@ -1469,6 +1657,310 @@ func TestHealthcheck(t *testing.T) {
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
path string,
payload map[string]string,
expectedStatus int,
) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(path),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != expectedStatus {
t.Fatalf(
"expected %d, got %d",
expectedStatus, resp.StatusCode,
)
}
}
func TestRegisterShortPassword(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
},
http.StatusBadRequest,
)
}
func TestRegisterInvalidNick(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
},
http.StatusBadRequest,
)
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Register first.
regBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp2.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp2.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "loginuser" {
t.Fatalf(
"expected loginuser, got %v",
state["nick"],
)
}
}
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "ghostuser",
"password": "password123",
},
http.StatusUnauthorized,
)
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected token for anonymous session")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state["nick"] != "anon_user" {
t.Fatalf(
"expected anon_user, got %v",
state["nick"],
)
}
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")

196
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,196 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleLogin(writer, request)
}
}
func (hdlr *Handlers) handleLogin(
writer http.ResponseWriter,
request *http.Request,
) {
type loginRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload loginRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if payload.Nick == "" || payload.Password == "" {
hdlr.respondError(
writer, request,
"nick and password required",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.LoginUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid credentials",
http.StatusUnauthorized,
)
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{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusOK)
}

View File

@@ -1,4 +1,4 @@
// Package handlers provides HTTP request handlers for the chat server.
// Package handlers provides HTTP request handlers for the neoirc server.
package handlers
import (
@@ -7,13 +7,15 @@ import (
"errors"
"log/slog"
"net/http"
"time"
"git.eeqj.de/sneak/chat/internal/broker"
"git.eeqj.de/sneak/chat/internal/config"
"git.eeqj.de/sneak/chat/internal/db"
"git.eeqj.de/sneak/chat/internal/globals"
"git.eeqj.de/sneak/chat/internal/healthcheck"
"git.eeqj.de/sneak/chat/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx"
)
@@ -30,12 +32,16 @@ type Params struct {
Healthcheck *healthcheck.Healthcheck
}
const defaultIdleTimeout = 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
cancelCleanup context.CancelFunc
}
// New creates a new Handlers instance.
@@ -43,18 +49,28 @@ func New(
lifecycle fx.Lifecycle,
params Params,
) (*Handlers, error) {
hdlr := &Handlers{
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
resource := params.Config.ServerName
if resource == "" {
resource = "neoirc"
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
OnStart: func(ctx context.Context) error {
hdlr.startCleanup(ctx)
return nil
},
OnStop: func(_ context.Context) error {
hdlr.stopCleanup()
return nil
},
})
@@ -96,3 +112,100 @@ func (hdlr *Handlers) respondError(
status,
)
}
func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" {
return defaultIdleTimeout
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid SESSION_IDLE_TIMEOUT, using default",
"value", raw, "error", err,
)
return defaultIdleTimeout
}
return dur
}
// startCleanup launches the idle-user cleanup goroutine.
// We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx)
}
func (hdlr *Handlers) stopCleanup() {
if hdlr.cancelCleanup != nil {
hdlr.cancelCleanup()
}
}
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
timeout := hdlr.idleTimeout()
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hdlr.runCleanup(ctx, timeout)
case <-ctx.Done():
return
}
}
}
func (hdlr *Handlers) runCleanup(
ctx context.Context,
timeout time.Duration,
) {
cutoff := time.Now().Add(-timeout)
// Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff,
)
if err != nil {
hdlr.log.Error(
"user cleanup failed", "error", err,
)
return
}
if deleted > 0 {
hdlr.log.Info(
"cleaned up stale users",
"deleted", deleted,
)
}
}

View File

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

View File

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

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

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

View File

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

@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/chat/web"
"git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
}
srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled {
@@ -59,48 +60,63 @@ func (srv *Server) SetupRoutes() {
}
// API v1.
srv.router.Route(
"/api/v1",
func(router chi.Router) {
router.Get(
"/server",
srv.handlers.HandleServerInfo(),
)
router.Post(
"/session",
srv.handlers.HandleCreateSession(),
)
router.Get(
"/state",
srv.handlers.HandleState(),
)
router.Get(
"/messages",
srv.handlers.HandleGetMessages(),
)
router.Post(
"/messages",
srv.handlers.HandleSendCommand(),
)
router.Get(
"/history",
srv.handlers.HandleGetHistory(),
)
router.Get(
"/channels",
srv.handlers.HandleListAllChannels(),
)
router.Get(
"/channels/{channel}/members",
srv.handlers.HandleChannelMembers(),
)
},
)
srv.router.Route("/api/v1", srv.setupAPIv1)
// Serve embedded SPA.
srv.setupSPA()
}
func (srv *Server) setupAPIv1(router chi.Router) {
router.Get(
"/server",
srv.handlers.HandleServerInfo(),
)
router.Post(
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),
)
router.Get(
"/state",
srv.handlers.HandleState(),
)
router.Post(
"/logout",
srv.handlers.HandleLogout(),
)
router.Get(
"/users/me",
srv.handlers.HandleUsersMe(),
)
router.Get(
"/messages",
srv.handlers.HandleGetMessages(),
)
router.Post(
"/messages",
srv.handlers.HandleSendCommand(),
)
router.Get(
"/history",
srv.handlers.HandleGetHistory(),
)
router.Get(
"/channels",
srv.handlers.HandleListAllChannels(),
)
router.Get(
"/channels/{channel}/members",
srv.handlers.HandleChannelMembers(),
)
}
func (srv *Server) setupSPA() {
distFS, err := fs.Sub(web.Dist, "dist")
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

464
web/dist/app.js vendored
View File

@@ -1,464 +0,0 @@
(()=>{
// Minimal Preact-like runtime using raw DOM for simplicity and zero build step.
// This replaces the previous Preact SPA with a vanilla JS implementation.
const API = '/api/v1';
let token = localStorage.getItem('chat_token');
let myNick = '';
let myUID = 0;
let lastQueueID = 0;
let pollController = null;
let channels = []; // [{name, topic}]
let activeTab = null; // '#channel' or 'nick' or 'server'
let messages = {}; // target -> [{command,from,to,body,ts,system}]
let unread = {}; // target -> count
let members = {}; // '#channel' -> [{nick}]
function $(sel, parent) { return (parent||document).querySelector(sel); }
function $$(sel, parent) { return [...(parent||document).querySelectorAll(sel)]; }
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k,v]) => {
if (k === 'class') e.className = v;
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
else e.setAttribute(k, v);
});
children.flat(Infinity).forEach(c => {
if (c == null) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
async function api(path, opts = {}) {
const headers = {'Content-Type': 'application/json', ...(opts.headers||{})};
if (token) headers['Authorization'] = `Bearer ${token}`;
const resp = await fetch(API + path, {...opts, headers, signal: opts.signal});
const data = await resp.json().catch(() => null);
if (!resp.ok) throw {status: resp.status, data};
return data;
}
function nickColor(nick) {
let h = 0;
for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360}, 70%, 65%)`;
}
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
function addMessage(target, msg) {
if (!messages[target]) messages[target] = [];
messages[target].push(msg);
if (messages[target].length > 500) messages[target] = messages[target].slice(-400);
if (target !== activeTab) {
unread[target] = (unread[target] || 0) + 1;
renderTabs();
}
if (target === activeTab) renderMessages();
}
function addSystemMessage(target, text) {
addMessage(target, {command: 'SYSTEM', from: '*', body: [text], ts: new Date().toISOString(), system: true});
}
// --- Rendering ---
function renderApp() {
const root = $('#root');
root.innerHTML = '';
root.appendChild(el('div', {class:'app'},
el('div', {class:'tab-bar', id:'tabs'}),
el('div', {class:'content'},
el('div', {class:'messages-pane'},
el('div', {class:'messages', id:'msg-list'}),
el('div', {class:'input-bar', id:'input-bar'},
el('input', {id:'msg-input', placeholder:'Message...', onKeydown: e => { if(e.key==='Enter') sendInput(); }}),
el('button', {onClick: sendInput}, 'Send')
)
),
el('div', {class:'user-list', id:'user-list'})
)
));
renderTabs();
renderMessages();
renderMembers();
$('#msg-input')?.focus();
}
function renderTabs() {
const container = $('#tabs');
if (!container) return;
container.innerHTML = '';
// Server tab
const serverTab = el('div', {class: `tab ${activeTab === 'server' ? 'active' : ''}`, onClick: () => switchTab('server')}, 'Server');
container.appendChild(serverTab);
// Channel tabs
channels.forEach(ch => {
const badge = unread[ch.name] ? ` (${unread[ch.name]})` : '';
const tab = el('div', {class: `tab ${activeTab === ch.name ? 'active' : ''}`},
el('span', {onClick: () => switchTab(ch.name)}, ch.name + badge),
el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); partChannel(ch.name); }}, '×')
);
container.appendChild(tab);
});
// DM tabs
Object.keys(messages).filter(k => !k.startsWith('#') && k !== 'server').forEach(nick => {
const badge = unread[nick] ? ` (${unread[nick]})` : '';
const tab = el('div', {class: `tab ${activeTab === nick ? 'active' : ''}`},
el('span', {onClick: () => switchTab(nick)}, '→' + nick + badge),
el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); delete messages[nick]; delete unread[nick]; if(activeTab===nick) switchTab('server'); else renderTabs(); }}, '×')
);
container.appendChild(tab);
});
// Join input
const joinDiv = el('div', {class:'join-dialog'},
el('input', {id:'join-input', placeholder:'#channel', onKeydown: e => { if(e.key==='Enter') joinFromInput(); }}),
el('button', {onClick: joinFromInput}, 'Join')
);
container.appendChild(joinDiv);
}
function renderMessages() {
const container = $('#msg-list');
if (!container) return;
const msgs = messages[activeTab] || [];
container.innerHTML = '';
msgs.forEach(m => {
const isSystem = m.system || ['JOIN','PART','QUIT','NICK','TOPIC'].includes(m.command);
const bodyText = Array.isArray(m.body) ? m.body.join('\n') : (m.body || '');
let displayText = bodyText;
if (m.command === 'JOIN') displayText = `${m.from} has joined ${m.to}`;
else if (m.command === 'PART') displayText = `${m.from} has left ${m.to}` + (bodyText ? ` (${bodyText})` : '');
else if (m.command === 'QUIT') displayText = `${m.from} has quit` + (bodyText ? ` (${bodyText})` : '');
else if (m.command === 'NICK') displayText = `${m.from} is now known as ${bodyText}`;
else if (m.command === 'TOPIC') displayText = `${m.from} set topic: ${bodyText}`;
const msgEl = el('div', {class: `message ${isSystem ? 'system' : ''}`},
el('span', {class:'timestamp'}, m.ts ? formatTime(m.ts) : ''),
isSystem
? el('span', {class:'nick'}, '*')
: el('span', {class:'nick', style:{color: nickColor(m.from)}}, m.from),
el('span', {class:'content'}, displayText)
);
container.appendChild(msgEl);
});
container.scrollTop = container.scrollHeight;
}
function renderMembers() {
const container = $('#user-list');
if (!container) return;
if (!activeTab || !activeTab.startsWith('#')) {
container.innerHTML = '';
return;
}
const mems = members[activeTab] || [];
container.innerHTML = '';
container.appendChild(el('h3', null, `Users (${mems.length})`));
mems.forEach(m => {
container.appendChild(el('div', {class:'user', style:{color: nickColor(m.nick)}, onClick: () => openDM(m.nick)}, m.nick));
});
}
function switchTab(target) {
activeTab = target;
unread[target] = 0;
renderTabs();
renderMessages();
renderMembers();
if (activeTab?.startsWith('#')) fetchMembers(activeTab);
$('#msg-input')?.focus();
}
// --- Actions ---
async function joinFromInput() {
const input = $('#join-input');
if (!input) return;
let name = input.value.trim();
if (!name) return;
if (!name.startsWith('#')) name = '#' + name;
input.value = '';
try {
await api('/messages', {method:'POST', body: JSON.stringify({command:'JOIN', to: name})});
} catch(e) {
addSystemMessage('server', `Failed to join ${name}: ${e.data?.error || 'error'}`);
}
}
async function partChannel(name) {
try {
await api('/messages', {method:'POST', body: JSON.stringify({command:'PART', to: name})});
} catch(e) {}
channels = channels.filter(c => c.name !== name);
delete members[name];
if (activeTab === name) switchTab('server');
else renderTabs();
}
function openDM(nick) {
if (nick === myNick) return;
if (!messages[nick]) messages[nick] = [];
switchTab(nick);
}
async function sendInput() {
const input = $('#msg-input');
if (!input) return;
const text = input.value.trim();
if (!text) return;
input.value = '';
if (text.startsWith('/')) {
const parts = text.split(' ');
const cmd = parts[0].toLowerCase();
if (cmd === '/join' && parts[1]) { $('#join-input').value = parts[1]; joinFromInput(); return; }
if (cmd === '/part') { if(activeTab?.startsWith('#')) partChannel(activeTab); return; }
if (cmd === '/nick' && parts[1]) {
try {
await api('/messages', {method:'POST', body: JSON.stringify({command:'NICK', body:[parts[1]]})});
} catch(e) {
addSystemMessage(activeTab||'server', `Nick change failed: ${e.data?.error || 'error'}`);
}
return;
}
if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) {
const target = parts[1];
const msg = parts.slice(2).join(' ');
try {
await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: target, body:[msg]})});
openDM(target);
} catch(e) {
addSystemMessage(activeTab||'server', `DM failed: ${e.data?.error || 'error'}`);
}
return;
}
if (cmd === '/quit') {
try { await api('/messages', {method:'POST', body: JSON.stringify({command:'QUIT'})}); } catch(e) {}
localStorage.removeItem('chat_token');
location.reload();
return;
}
addSystemMessage(activeTab||'server', `Unknown command: ${cmd}`);
return;
}
if (!activeTab || activeTab === 'server') {
addSystemMessage('server', 'Select a channel or user to send messages');
return;
}
try {
await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: activeTab, body:[text]})});
} catch(e) {
addSystemMessage(activeTab, `Send failed: ${e.data?.error || 'error'}`);
}
}
async function fetchMembers(channel) {
try {
const name = channel.replace('#','');
const data = await api(`/channels/${name}/members`);
members[channel] = data;
renderMembers();
} catch(e) {}
}
// --- Polling ---
async function pollLoop() {
while (true) {
try {
if (pollController) pollController.abort();
pollController = new AbortController();
const data = await api(`/messages?after=${lastQueueID}&timeout=15`, {signal: pollController.signal});
if (data.last_id) lastQueueID = data.last_id;
for (const msg of (data.messages || [])) {
handleMessage(msg);
}
} catch(e) {
if (e instanceof DOMException && e.name === 'AbortError') continue;
if (e.status === 401) {
localStorage.removeItem('chat_token');
location.reload();
return;
}
await new Promise(r => setTimeout(r, 2000));
}
}
}
function handleMessage(msg) {
const body = Array.isArray(msg.body) ? msg.body : [];
const bodyText = body.join('\n');
switch (msg.command) {
case 'PRIVMSG':
case 'NOTICE': {
let target = msg.to;
// DM: if it's to me, show under sender's nick tab
if (!target.startsWith('#')) {
target = msg.from === myNick ? msg.to : msg.from;
if (!messages[target]) messages[target] = [];
}
addMessage(target, msg);
break;
}
case 'JOIN': {
addMessage(msg.to, msg);
if (msg.from === myNick) {
// We joined a channel
if (!channels.find(c => c.name === msg.to)) {
channels.push({name: msg.to, topic: ''});
}
switchTab(msg.to);
fetchMembers(msg.to);
} else if (activeTab === msg.to) {
fetchMembers(msg.to);
}
break;
}
case 'PART': {
addMessage(msg.to, msg);
if (msg.from === myNick) {
channels = channels.filter(c => c.name !== msg.to);
if (activeTab === msg.to) switchTab('server');
else renderTabs();
} else if (activeTab === msg.to) {
fetchMembers(msg.to);
}
break;
}
case 'QUIT': {
// Show in all channels where this user might be
channels.forEach(ch => {
addMessage(ch.name, msg);
});
break;
}
case 'NICK': {
const newNick = body[0] || '';
if (msg.from === myNick) {
myNick = newNick;
addSystemMessage(activeTab || 'server', `You are now known as ${newNick}`);
} else {
channels.forEach(ch => {
addMessage(ch.name, msg);
});
}
break;
}
case 'TOPIC': {
addMessage(msg.to, msg);
const ch = channels.find(c => c.name === msg.to);
if (ch) ch.topic = bodyText;
break;
}
default:
addSystemMessage('server', `[${msg.command}] ${bodyText}`);
}
}
// --- Login ---
function renderLogin() {
const root = $('#root');
root.innerHTML = '';
let serverName = 'Chat';
let motd = '';
api('/server').then(data => {
if (data.name) { serverName = data.name; $('h1', root).textContent = serverName; }
if (data.motd) { motd = data.motd; const m = $('.motd', root); if(m) m.textContent = motd; }
}).catch(() => {});
const form = el('form', {class:'login-screen', onSubmit: async (e) => {
e.preventDefault();
const nick = $('input', form).value.trim();
if (!nick) return;
const errEl = $('.error', form);
if (errEl) errEl.textContent = '';
try {
const data = await api('/session', {method:'POST', body: JSON.stringify({nick})});
token = data.token;
myNick = data.nick;
myUID = data.id;
localStorage.setItem('chat_token', token);
startApp();
} catch(err) {
const errEl = $('.error', form) || form.appendChild(el('div', {class:'error'}));
errEl.textContent = err.data?.error || 'Connection failed';
}
}},
el('h1', null, serverName),
motd ? el('div', {class:'motd'}, motd) : null,
el('input', {type:'text', placeholder:'Choose a nickname...', maxLength:'32', autofocus:'true'}),
el('button', {type:'submit'}, 'Connect'),
el('div', {class:'error'})
);
root.appendChild(form);
$('input', form)?.focus();
}
async function startApp() {
messages = {server: []};
unread = {};
channels = [];
activeTab = 'server';
lastQueueID = 0;
addSystemMessage('server', `Connected as ${myNick}`);
// Fetch server info
try {
const info = await api('/server');
if (info.motd) addSystemMessage('server', `MOTD: ${info.motd}`);
} catch(e) {}
// Fetch current state (channels we're already in)
try {
const state = await api('/state');
myNick = state.nick;
myUID = state.id;
if (state.channels) {
state.channels.forEach(ch => {
channels.push({name: ch.name, topic: ch.topic});
if (!messages[ch.name]) messages[ch.name] = [];
});
if (channels.length > 0) switchTab(channels[0].name);
}
} catch(e) {}
renderApp();
pollLoop();
}
// --- Init ---
if (token) {
// Try to resume session
api('/state').then(data => {
myNick = data.nick;
myUID = data.id;
startApp();
}).catch(() => {
localStorage.removeItem('chat_token');
token = null;
renderLogin();
});
} else {
renderLogin();
}
})();

13
web/dist/index.html vendored
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>Chat</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>

274
web/dist/style.css vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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