Compare commits
4 Commits
feat/chi-v
...
18d46b2ac0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18d46b2ac0 | ||
|
|
8854b17ebc | ||
|
|
6fa46f4b75 | ||
|
|
a89393186f |
206
README.md
206
README.md
@@ -987,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 | 1–32 characters, must be unique on the server |
|
||||
| Field | Type | Required | Constraints |
|
||||
|------------|--------|-------------|-------------|
|
||||
| `nick` | string | Yes | 1–32 characters, must be unique on the server |
|
||||
| `hashcash` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
@@ -1016,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
|
||||
```
|
||||
|
||||
@@ -1376,16 +1384,18 @@ Return server metadata. No authentication required.
|
||||
"name": "My NeoIRC Server",
|
||||
"version": "0.1.0",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42
|
||||
"users": 42,
|
||||
"hashcash_bits": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
@@ -1823,6 +1833,7 @@ directory is also loaded automatically via
|
||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
@@ -1834,6 +1845,7 @@ MOTD=Welcome! Be excellent to each other.
|
||||
DEBUG=false
|
||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||
SESSION_IDLE_TIMEOUT=24h
|
||||
NEOIRC_HASHCASH_BITS=20
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2106,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.5–2 seconds on modern hardware.
|
||||
|
||||
### Client Integration
|
||||
|
||||
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||
|
||||
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||
3. Include the stamp in the `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.5–2s |
|
||||
| `24` | Strong protection | ~10–30s |
|
||||
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||
|
||||
Each additional bit doubles the expected work. An attacker creating 1000
|
||||
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||
creating one session pays once and keeps their session.
|
||||
|
||||
### Why Hashcash and Not Rate Limits?
|
||||
|
||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||
The server only needs to verify a hash.
|
||||
The server only needs to verify a single hash.
|
||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||
campuses, corporate networks, Tor exits). Every client computes their own
|
||||
proof independently.
|
||||
@@ -2169,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
|
||||
@@ -2227,7 +2252,7 @@ GET /api/v1/challenge
|
||||
|
||||
### Post-MVP (Planned)
|
||||
|
||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||
@@ -2280,15 +2305,18 @@ neoirc/
|
||||
├── cmd/
|
||||
│ ├── neoircd/ # Server binary entry point
|
||||
│ │ └── main.go
|
||||
│ └── neoirc-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
|
||||
|
||||
@@ -1,911 +1,8 @@
|
||||
// Package main is the entry point for the neoirc-cli client.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
splitParts = 2
|
||||
pollTimeout = 15
|
||||
pollRetry = 2 * time.Second
|
||||
timeFormat = "15:04"
|
||||
)
|
||||
|
||||
// App holds the application state.
|
||||
type App struct {
|
||||
ui *UI
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
target string
|
||||
connected bool
|
||||
lastQID int64
|
||||
stopPoll chan struct{}
|
||||
}
|
||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
||||
|
||||
func main() {
|
||||
app := &App{ //nolint:exhaustruct
|
||||
ui: NewUI(),
|
||||
nick: "guest",
|
||||
}
|
||||
|
||||
app.ui.OnInput(app.handleInput)
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
"[white] to begin, " +
|
||||
"or [yellow]/help[white] for commands",
|
||||
)
|
||||
|
||||
err := app.ui.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleInput(text string) {
|
||||
if strings.HasPrefix(text, "/") {
|
||||
a.handleCommand(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus(
|
||||
"[red]Not connected. Use /connect <url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]No target. " +
|
||||
"Use /join #channel or /query nick",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(
|
||||
"[red]Send error: " + err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleCommand(text string) {
|
||||
parts := strings.SplitN(text, " ", splitParts)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
a.dispatchCommand(cmd, args)
|
||||
}
|
||||
|
||||
func (a *App) dispatchCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/connect":
|
||||
a.cmdConnect(args)
|
||||
case "/nick":
|
||||
a.cmdNick(args)
|
||||
case "/join":
|
||||
a.cmdJoin(args)
|
||||
case "/part":
|
||||
a.cmdPart(args)
|
||||
case "/msg":
|
||||
a.cmdMsg(args)
|
||||
case "/query":
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdConnect(serverURL string) {
|
||||
if serverURL == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /connect <server-url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
client := api.NewClient(serverURL)
|
||||
|
||||
resp, err := client.CreateSession(nick)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Connection failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.client = client
|
||||
a.nick = resp.Nick
|
||||
a.connected = true
|
||||
a.lastQID = 0
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[green]Connected! Nick: %s, Session: %d",
|
||||
resp.Nick, resp.ID,
|
||||
))
|
||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||
|
||||
a.stopPoll = make(chan struct{})
|
||||
|
||||
go a.pollLoop()
|
||||
}
|
||||
|
||||
func (a *App) cmdNick(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /nick <name>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(
|
||||
"Nick set to " + nick +
|
||||
" (will be used on connect)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Nick change failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(nick, target, "connected")
|
||||
a.ui.AddStatus("Nick changed to " + nick)
|
||||
}
|
||||
|
||||
func (a *App) cmdJoin(channel string) {
|
||||
if channel == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /join #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.JoinChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Join failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = channel
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(channel)
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Joined "+channel,
|
||||
)
|
||||
a.ui.SetStatus(nick, channel, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdPart(channel string) {
|
||||
a.mu.Lock()
|
||||
if channel == "" {
|
||||
channel = a.target
|
||||
}
|
||||
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus("[red]No channel to part")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.PartChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Part failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Left "+channel,
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.target == channel {
|
||||
a.target = ""
|
||||
}
|
||||
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchBuffer(0)
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdMsg(args string) {
|
||||
parts := strings.SplitN(args, " ", splitParts)
|
||||
if len(parts) < splitParts {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /msg <nick> <text>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, text := parts[0], parts[1]
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Send failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdQuery(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /query <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = nick
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(nick)
|
||||
a.ui.SetStatus(myNick, nick, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdTopic(args string) {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic query failed: %v", err,
|
||||
))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic set failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdNames() {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := a.client.GetMembers(target)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Names failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[cyan]*** Members of %s: %s",
|
||||
target, strings.Join(members, " "),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdList() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := a.client.ListChannels()
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]List failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||
|
||||
for _, ch := range channels {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
" %s (%d members) %s",
|
||||
ch.Name, ch.Members, ch.Topic,
|
||||
))
|
||||
}
|
||||
|
||||
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(
|
||||
"[red]Usage: /window <number>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var bufIndex int
|
||||
|
||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||
|
||||
a.ui.SwitchBuffer(bufIndex)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||
buf := a.ui.buffers[bufIndex]
|
||||
if buf.Name != "(status)" {
|
||||
a.mu.Lock()
|
||||
a.target = buf.Name
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(
|
||||
nick, buf.Name, "connected",
|
||||
)
|
||||
} else {
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdQuit() {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
if a.stopPoll != nil {
|
||||
close(a.stopPoll)
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
a.ui.Stop()
|
||||
}
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
" /part [#chan] — Leave channel",
|
||||
" /msg <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /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",
|
||||
" Plain text sends to current target.",
|
||||
}
|
||||
|
||||
for _, line := range help {
|
||||
a.ui.AddStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
// pollLoop long-polls for messages in the background.
|
||||
func (a *App) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopPoll:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
client := a.client
|
||||
lastQID := a.lastQID
|
||||
a.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.PollMessages(
|
||||
lastQID, pollTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
time.Sleep(pollRetry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if result.LastID > 0 {
|
||||
a.mu.Lock()
|
||||
a.lastQID = result.LastID
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
for i := range result.Messages {
|
||||
a.handleServerMessage(&result.Messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServerMessage(msg *api.Message) {
|
||||
timestamp := a.formatTS(msg)
|
||||
|
||||
a.mu.Lock()
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) formatTS(msg *api.Message) string {
|
||||
if msg.TS != "" {
|
||||
return msg.ParseTS().UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
return time.Now().Format(timeFormat)
|
||||
}
|
||||
|
||||
func (a *App) handlePrivmsgEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if msg.From == myNick {
|
||||
return
|
||||
}
|
||||
|
||||
target := msg.To
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
target = msg.From
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleJoinEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has joined %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handlePartEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||
timestamp, msg.From, msg.To, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleQuitEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||
timestamp, msg.From, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit",
|
||||
timestamp, msg.From,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleNickEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
|
||||
newNick := ""
|
||||
if len(lines) > 0 {
|
||||
newNick = lines[0]
|
||||
}
|
||||
|
||||
if msg.From == myNick && newNick != "" {
|
||||
a.mu.Lock()
|
||||
a.nick = newNick
|
||||
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(newNick, target, "connected")
|
||||
}
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s is now known as %s",
|
||||
timestamp, msg.From, newNick,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleNoticeEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [magenta]--%s-- %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleTopicEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [cyan]*** %s set topic: %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if text != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [white][%s] %s",
|
||||
timestamp, msg.Command, text,
|
||||
))
|
||||
}
|
||||
cli.Run()
|
||||
}
|
||||
|
||||
@@ -43,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
|
||||
@@ -261,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
|
||||
|
||||
@@ -293,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)
|
||||
79
internal/cli/api/hashcash.go
Normal file
79
internal/cli/api/hashcash.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package neoircapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
// counterSpace is the range for random counter seeds.
|
||||
counterSpace = 1 << 48
|
||||
)
|
||||
|
||||
// MintHashcash computes a hashcash stamp with the given
|
||||
// difficulty (leading zero bits) and resource string.
|
||||
func MintHashcash(bits int, resource string) string {
|
||||
date := time.Now().UTC().Format("060102")
|
||||
prefix := fmt.Sprintf(
|
||||
"1:%d:%s:%s::", bits, date, resource,
|
||||
)
|
||||
|
||||
for {
|
||||
counter := randomCounter()
|
||||
stamp := prefix + counter
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
|
||||
if hasLeadingZeroBits(hash[:], bits) {
|
||||
return stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if hash has at least numBits
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(
|
||||
hash []byte,
|
||||
numBits int,
|
||||
) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// randomCounter generates a random hex counter string.
|
||||
func randomCounter() string {
|
||||
counterVal, err := rand.Int(
|
||||
rand.Reader, big.NewInt(counterSpace),
|
||||
)
|
||||
if err != nil {
|
||||
// Fallback to timestamp-based counter on error.
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
return hex.EncodeToString(counterVal.Bytes())
|
||||
}
|
||||
@@ -4,7 +4,8 @@ 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.
|
||||
@@ -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.
|
||||
912
internal/cli/app.go
Normal file
912
internal/cli/app.go
Normal file
@@ -0,0 +1,912 @@
|
||||
// Package cli implements the neoirc-cli terminal client.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
splitParts = 2
|
||||
pollTimeout = 15
|
||||
pollRetry = 2 * time.Second
|
||||
timeFormat = "15:04"
|
||||
)
|
||||
|
||||
// App holds the application state.
|
||||
type App struct {
|
||||
ui *UI
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
target string
|
||||
connected bool
|
||||
lastQID int64
|
||||
stopPoll chan struct{}
|
||||
}
|
||||
|
||||
// Run creates and runs the CLI application.
|
||||
func Run() {
|
||||
app := &App{ //nolint:exhaustruct
|
||||
ui: NewUI(),
|
||||
nick: "guest",
|
||||
}
|
||||
|
||||
app.ui.OnInput(app.handleInput)
|
||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||
|
||||
app.ui.AddStatus(
|
||||
"Welcome to neoirc-cli — an IRC-style client",
|
||||
)
|
||||
app.ui.AddStatus(
|
||||
"Type [yellow]/connect <server-url>" +
|
||||
"[white] to begin, " +
|
||||
"or [yellow]/help[white] for commands",
|
||||
)
|
||||
|
||||
err := app.ui.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleInput(text string) {
|
||||
if strings.HasPrefix(text, "/") {
|
||||
a.handleCommand(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus(
|
||||
"[red]Not connected. Use /connect <url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]No target. " +
|
||||
"Use /join #channel or /query nick",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(
|
||||
"[red]Send error: " + err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleCommand(text string) {
|
||||
parts := strings.SplitN(text, " ", splitParts)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
a.dispatchCommand(cmd, args)
|
||||
}
|
||||
|
||||
func (a *App) dispatchCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/connect":
|
||||
a.cmdConnect(args)
|
||||
case "/nick":
|
||||
a.cmdNick(args)
|
||||
case "/join":
|
||||
a.cmdJoin(args)
|
||||
case "/part":
|
||||
a.cmdPart(args)
|
||||
case "/msg":
|
||||
a.cmdMsg(args)
|
||||
case "/query":
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdConnect(serverURL string) {
|
||||
if serverURL == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /connect <server-url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
client := api.NewClient(serverURL)
|
||||
|
||||
resp, err := client.CreateSession(nick)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Connection failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.client = client
|
||||
a.nick = resp.Nick
|
||||
a.connected = true
|
||||
a.lastQID = 0
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[green]Connected! Nick: %s, Session: %d",
|
||||
resp.Nick, resp.ID,
|
||||
))
|
||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||
|
||||
a.stopPoll = make(chan struct{})
|
||||
|
||||
go a.pollLoop()
|
||||
}
|
||||
|
||||
func (a *App) cmdNick(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /nick <name>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(
|
||||
"Nick set to " + nick +
|
||||
" (will be used on connect)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Nick change failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(nick, target, "connected")
|
||||
a.ui.AddStatus("Nick changed to " + nick)
|
||||
}
|
||||
|
||||
func (a *App) cmdJoin(channel string) {
|
||||
if channel == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /join #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.JoinChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Join failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = channel
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(channel)
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Joined "+channel,
|
||||
)
|
||||
a.ui.SetStatus(nick, channel, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdPart(channel string) {
|
||||
a.mu.Lock()
|
||||
if channel == "" {
|
||||
channel = a.target
|
||||
}
|
||||
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus("[red]No channel to part")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.PartChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Part failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Left "+channel,
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.target == channel {
|
||||
a.target = ""
|
||||
}
|
||||
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchBuffer(0)
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdMsg(args string) {
|
||||
parts := strings.SplitN(args, " ", splitParts)
|
||||
if len(parts) < splitParts {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /msg <nick> <text>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, text := parts[0], parts[1]
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Send failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdQuery(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /query <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = nick
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(nick)
|
||||
a.ui.SetStatus(myNick, nick, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdTopic(args string) {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic query failed: %v", err,
|
||||
))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic set failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdNames() {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := a.client.GetMembers(target)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Names failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[cyan]*** Members of %s: %s",
|
||||
target, strings.Join(members, " "),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdList() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := a.client.ListChannels()
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]List failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||
|
||||
for _, ch := range channels {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
" %s (%d members) %s",
|
||||
ch.Name, ch.Members, ch.Topic,
|
||||
))
|
||||
}
|
||||
|
||||
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(
|
||||
"[red]Usage: /window <number>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var bufIndex int
|
||||
|
||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||
|
||||
a.ui.SwitchBuffer(bufIndex)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||
buf := a.ui.buffers[bufIndex]
|
||||
if buf.Name != "(status)" {
|
||||
a.mu.Lock()
|
||||
a.target = buf.Name
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(
|
||||
nick, buf.Name, "connected",
|
||||
)
|
||||
} else {
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdQuit() {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
if a.stopPoll != nil {
|
||||
close(a.stopPoll)
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
a.ui.Stop()
|
||||
}
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
" /part [#chan] — Leave channel",
|
||||
" /msg <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /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",
|
||||
" Plain text sends to current target.",
|
||||
}
|
||||
|
||||
for _, line := range help {
|
||||
a.ui.AddStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
// pollLoop long-polls for messages in the background.
|
||||
func (a *App) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopPoll:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
client := a.client
|
||||
lastQID := a.lastQID
|
||||
a.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.PollMessages(
|
||||
lastQID, pollTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
time.Sleep(pollRetry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if result.LastID > 0 {
|
||||
a.mu.Lock()
|
||||
a.lastQID = result.LastID
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
for i := range result.Messages {
|
||||
a.handleServerMessage(&result.Messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServerMessage(msg *api.Message) {
|
||||
timestamp := a.formatTS(msg)
|
||||
|
||||
a.mu.Lock()
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) formatTS(msg *api.Message) string {
|
||||
if msg.TS != "" {
|
||||
return msg.ParseTS().UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
return time.Now().Format(timeFormat)
|
||||
}
|
||||
|
||||
func (a *App) handlePrivmsgEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if msg.From == myNick {
|
||||
return
|
||||
}
|
||||
|
||||
target := msg.To
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
target = msg.From
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleJoinEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has joined %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handlePartEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||
timestamp, msg.From, msg.To, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleQuitEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||
timestamp, msg.From, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit",
|
||||
timestamp, msg.From,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleNickEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
|
||||
newNick := ""
|
||||
if len(lines) > 0 {
|
||||
newNick = lines[0]
|
||||
}
|
||||
|
||||
if msg.From == myNick && newNick != "" {
|
||||
a.mu.Lock()
|
||||
a.nick = newNick
|
||||
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(newNick, target, "connected")
|
||||
}
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s is now known as %s",
|
||||
timestamp, msg.From, newNick,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleNoticeEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [magenta]--%s-- %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleTopicEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [cyan]*** %s set topic: %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if text != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [white][%s] %s",
|
||||
timestamp, msg.Command, text,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -44,6 +44,7 @@ type Config struct {
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -74,6 +75,7 @@ func New(
|
||||
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 {
|
||||
@@ -98,6 +100,7 @@ func New(
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
@@ -145,7 +145,8 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
request *http.Request,
|
||||
) {
|
||||
type createRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"hashcash,omitempty"`
|
||||
}
|
||||
|
||||
var payload createRequest
|
||||
@@ -161,6 +162,32 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
return
|
||||
}
|
||||
|
||||
// Validate hashcash proof-of-work if configured.
|
||||
if hdlr.params.Config.HashcashBits > 0 {
|
||||
if payload.Hashcash == "" {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"hashcash proof-of-work required",
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = hdlr.hashcashVal.Validate(
|
||||
payload.Hashcash, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid hashcash stamp: "+err.Error(),
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||
|
||||
if !validNickRe.MatchString(payload.Nick) {
|
||||
@@ -2391,11 +2418,19 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
resp := map[string]any{
|
||||
"name": hdlr.params.Config.ServerName,
|
||||
"version": hdlr.params.Globals.Version,
|
||||
"motd": hdlr.params.Config.MOTD,
|
||||
"users": users,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
if hdlr.params.Config.HashcashBits > 0 {
|
||||
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
|
||||
}
|
||||
|
||||
hdlr.respondJSON(
|
||||
writer, request, resp, http.StatusOK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func newTestServer(
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
cfg.HashcashBits = 0
|
||||
|
||||
return cfg, nil
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
@@ -39,6 +40,7 @@ type Handlers struct {
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -47,11 +49,17 @@ func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
resource := params.Config.ServerName
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
|
||||
277
internal/hashcash/hashcash.go
Normal file
277
internal/hashcash/hashcash.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Package hashcash implements SHA-256-based hashcash
|
||||
// proof-of-work validation for abuse prevention.
|
||||
//
|
||||
// Stamp format: 1:bits:YYMMDD:resource::counter.
|
||||
//
|
||||
// The SHA-256 hash of the entire stamp string must have
|
||||
// at least `bits` leading zero bits.
|
||||
package hashcash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// stampVersion is the only supported hashcash version.
|
||||
stampVersion = "1"
|
||||
// stampFields is the number of fields in a stamp.
|
||||
stampFields = 6
|
||||
// maxStampAge is how old a stamp can be before
|
||||
// rejection.
|
||||
maxStampAge = 48 * time.Hour
|
||||
// maxFutureSkew allows stamps slightly in the future.
|
||||
maxFutureSkew = 1 * time.Hour
|
||||
// pruneInterval controls how often expired stamps are
|
||||
// removed from the spent set.
|
||||
pruneInterval = 10 * time.Minute
|
||||
// dateFormatShort is the YYMMDD date layout.
|
||||
dateFormatShort = "060102"
|
||||
// dateFormatLong is the YYMMDDHHMMSS date layout.
|
||||
dateFormatLong = "060102150405"
|
||||
// dateShortLen is the length of YYMMDD.
|
||||
dateShortLen = 6
|
||||
// dateLongLen is the length of YYMMDDHHMMSS.
|
||||
dateLongLen = 12
|
||||
// bitsPerByte is the number of bits in a byte.
|
||||
bitsPerByte = 8
|
||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
||||
fullByteMask = 0xFF
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidFields = errors.New("invalid stamp field count")
|
||||
errBadVersion = errors.New("unsupported stamp version")
|
||||
errInsufficientBits = errors.New("insufficient difficulty")
|
||||
errWrongResource = errors.New("wrong resource")
|
||||
errStampExpired = errors.New("stamp expired")
|
||||
errStampFuture = errors.New("stamp date in future")
|
||||
errProofFailed = errors.New("proof-of-work failed")
|
||||
errStampReused = errors.New("stamp already used")
|
||||
errBadDateFormat = errors.New("unrecognized date format")
|
||||
)
|
||||
|
||||
// Validator checks hashcash stamps for validity and
|
||||
// prevents replay attacks via an in-memory spent set.
|
||||
type Validator struct {
|
||||
resource string
|
||||
mu sync.Mutex
|
||||
spent map[string]time.Time
|
||||
}
|
||||
|
||||
// NewValidator creates a Validator for the given resource.
|
||||
func NewValidator(resource string) *Validator {
|
||||
validator := &Validator{
|
||||
resource: resource,
|
||||
mu: sync.Mutex{},
|
||||
spent: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
go validator.pruneLoop()
|
||||
|
||||
return validator
|
||||
}
|
||||
|
||||
// Validate checks a hashcash stamp. It returns nil if the
|
||||
// stamp is valid and has not been seen before.
|
||||
func (v *Validator) Validate(
|
||||
stamp string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
if requiredBits <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(stamp, ":")
|
||||
if len(parts) != stampFields {
|
||||
return fmt.Errorf(
|
||||
"%w: expected %d, got %d",
|
||||
errInvalidFields, stampFields, len(parts),
|
||||
)
|
||||
}
|
||||
|
||||
version := parts[0]
|
||||
bitsStr := parts[1]
|
||||
dateStr := parts[2]
|
||||
resource := parts[3]
|
||||
|
||||
if err := v.validateHeader(
|
||||
version, bitsStr, resource, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stampTime, err := parseStampDate(dateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateTime(stampTime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProof(
|
||||
stamp, requiredBits,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return v.checkAndRecordStamp(stamp, stampTime)
|
||||
}
|
||||
|
||||
func (v *Validator) validateHeader(
|
||||
version, bitsStr, resource string,
|
||||
requiredBits int,
|
||||
) error {
|
||||
if version != stampVersion {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", errBadVersion, version,
|
||||
)
|
||||
}
|
||||
|
||||
claimedBits, err := strconv.Atoi(bitsStr)
|
||||
if err != nil || claimedBits < requiredBits {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d bits",
|
||||
errInsufficientBits, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
if resource != v.resource {
|
||||
return fmt.Errorf(
|
||||
"%w: got %q, want %q",
|
||||
errWrongResource, resource, v.resource,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTime(stampTime time.Time) error {
|
||||
now := time.Now()
|
||||
|
||||
if now.Sub(stampTime) > maxStampAge {
|
||||
return errStampExpired
|
||||
}
|
||||
|
||||
if stampTime.Sub(now) > maxFutureSkew {
|
||||
return errStampFuture
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProof(stamp string, requiredBits int) error {
|
||||
hash := sha256.Sum256([]byte(stamp))
|
||||
if !hasLeadingZeroBits(hash[:], requiredBits) {
|
||||
return fmt.Errorf(
|
||||
"%w: need %d leading zero bits",
|
||||
errProofFailed, requiredBits,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Validator) checkAndRecordStamp(
|
||||
stamp string,
|
||||
stampTime time.Time,
|
||||
) error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if _, ok := v.spent[stamp]; ok {
|
||||
return errStampReused
|
||||
}
|
||||
|
||||
v.spent[stamp] = stampTime
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLeadingZeroBits checks if the hash has at least n
|
||||
// leading zero bits.
|
||||
func hasLeadingZeroBits(hash []byte, numBits int) bool {
|
||||
fullBytes := numBits / bitsPerByte
|
||||
remainBits := numBits % bitsPerByte
|
||||
|
||||
for idx := range fullBytes {
|
||||
if hash[idx] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if remainBits > 0 && fullBytes < len(hash) {
|
||||
mask := byte(
|
||||
fullByteMask << (bitsPerByte - remainBits),
|
||||
)
|
||||
|
||||
if hash[fullBytes]&mask != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// parseStampDate parses a hashcash date stamp.
|
||||
// Supports YYMMDD and YYMMDDHHMMSS formats.
|
||||
func parseStampDate(dateStr string) (time.Time, error) {
|
||||
switch len(dateStr) {
|
||||
case dateShortLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatShort, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
case dateLongLen:
|
||||
parsed, err := time.Parse(
|
||||
dateFormatLong, dateStr,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"parse date: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"%w: %q", errBadDateFormat, dateStr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneLoop periodically removes expired stamps from the
|
||||
// spent set.
|
||||
func (v *Validator) pruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
v.prune()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) prune() {
|
||||
cutoff := time.Now().Add(-maxStampAge)
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
for stamp, stampTime := range v.spent {
|
||||
if stampTime.Before(cutoff) {
|
||||
delete(v.spent, stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
||||
const ACTION_PREFIX = "\x01ACTION ";
|
||||
const ACTION_SUFFIX = "\x01";
|
||||
|
||||
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||
|
||||
function checkLeadingZeros(hashBytes, bits) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < hashBytes.length; i++) {
|
||||
if (hashBytes[i] === 0) {
|
||||
count += 8;
|
||||
continue;
|
||||
}
|
||||
let b = hashBytes[i];
|
||||
while ((b & 0x80) === 0) {
|
||||
count++;
|
||||
b <<= 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return count >= bits;
|
||||
}
|
||||
|
||||
async function mintHashcash(bits, resource) {
|
||||
const encoder = new TextEncoder();
|
||||
const now = new Date();
|
||||
const date =
|
||||
String(now.getUTCFullYear()).slice(2) +
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||
String(now.getUTCDate()).padStart(2, "0");
|
||||
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||
let nonce = Math.floor(Math.random() * 0x100000);
|
||||
const batchSize = 1024;
|
||||
|
||||
for (;;) {
|
||||
const stamps = [];
|
||||
const hashPromises = [];
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const stamp = prefix + (nonce + i).toString(16);
|
||||
stamps.push(stamp);
|
||||
hashPromises.push(
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||
);
|
||||
}
|
||||
const hashes = await Promise.all(hashPromises);
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||
return stamps[i];
|
||||
}
|
||||
}
|
||||
nonce += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
function api(path, opts = {}) {
|
||||
const token = localStorage.getItem("neoirc_token");
|
||||
const headers = {
|
||||
@@ -60,12 +110,16 @@ function LoginScreen({ onLogin }) {
|
||||
const [motd, setMotd] = useState("");
|
||||
const [serverName, setServerName] = useState("NeoIRC");
|
||||
const inputRef = useRef();
|
||||
const hashcashBitsRef = useRef(0);
|
||||
const hashcashResourceRef = useRef("neoirc");
|
||||
|
||||
useEffect(() => {
|
||||
api("/server")
|
||||
.then((s) => {
|
||||
if (s.name) setServerName(s.name);
|
||||
if (s.motd) setMotd(s.motd);
|
||||
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||
if (s.name) hashcashResourceRef.current = s.name;
|
||||
})
|
||||
.catch(() => {});
|
||||
const saved = localStorage.getItem("neoirc_token");
|
||||
@@ -81,9 +135,22 @@ function LoginScreen({ onLogin }) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
let hashcashStamp = "";
|
||||
if (hashcashBitsRef.current > 0) {
|
||||
setError("Computing proof-of-work...");
|
||||
hashcashStamp = await mintHashcash(
|
||||
hashcashBitsRef.current,
|
||||
hashcashResourceRef.current,
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
const reqBody = { nick: nick.trim() };
|
||||
if (hashcashStamp) {
|
||||
reqBody.hashcash = hashcashStamp;
|
||||
}
|
||||
const res = await api("/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ nick: nick.trim() }),
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
localStorage.setItem("neoirc_token", res.token);
|
||||
onLogin(res.nick);
|
||||
|
||||
Reference in New Issue
Block a user