diff --git a/README.md b/README.md index e2c5e72..060f1a3 100644 --- a/README.md +++ b/README.md @@ -987,7 +987,18 @@ the format: Create a new user session. This is the entry point for all clients. -**Request:** +If the server requires hashcash proof-of-work (see +[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a +valid stamp in the `X-Hashcash` request header. The required difficulty is +advertised via `GET /api/v1/server` in the `hashcash_bits` field. + +**Request Headers:** + +| Header | Required | Description | +|--------------|----------|-------------| +| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | + +**Request Body:** ```json {"nick": "alice"} ``` @@ -1016,12 +1027,15 @@ Create a new user session. This is the entry point for all clients. | Status | Error | When | |--------|-------|------| | 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled | +| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | | 409 | `nick already taken` | Another active session holds this nick | **curl example:** ```bash TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ -H 'Content-Type: application/json' \ + -H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \ -d '{"nick":"alice"}' | jq -r .token) echo $TOKEN ``` @@ -1363,15 +1377,17 @@ Return server metadata. No authentication required. { "name": "My NeoIRC Server", "motd": "Welcome! Be nice.", - "users": 42 + "users": 42, + "hashcash_bits": 20 } ``` -| Field | Type | Description | -|---------|---------|-------------| -| `name` | string | Server display name | -| `motd` | string | Message of the day | -| `users` | integer | Number of currently active user sessions | +| Field | Type | Description | +|-----------------|---------|-------------| +| `name` | string | Server display name | +| `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 @@ -1805,6 +1821,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 @@ -1816,6 +1833,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 ``` --- @@ -2098,62 +2116,102 @@ Clients should handle these message commands from the queue: ## Rate Limiting & Abuse Prevention -Session creation (`POST /api/v1/session`) will require a +### Hashcash Proof-of-Work + +Session creation (`POST /api/v1/session`) requires a [hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token. This is the primary defense against resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate limits that punish shared networks. ### How It Works -1. Client requests a challenge: `GET /api/v1/challenge` - ```json - → {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"} - ``` -2. Server returns a nonce and a required difficulty (number of leading zero - bits in the SHA-256 hash) -3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)` - has the required number of leading zero bits: - ``` - SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good) - SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good) - ... - SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!) - ``` -4. Client submits the proof with the session request: - ```json - POST /api/v1/session - {"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}} - ``` -5. Server verifies: - - Nonce was issued by this server and hasn't expired - - Nonce hasn't been used before (prevent replay) - - `SHA-256(nonce || ":" || counter)` has the required leading zeros - - If valid, create the session normally +1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits` + field (e.g., `20`) indicating the required difficulty. +2. Client computes a hashcash stamp: find a counter value such that the + SHA-256 hash of the stamp string has the required number of leading zero + bits. +3. Client includes the stamp in the `X-Hashcash` request header when creating + a session: `POST /api/v1/session`. +4. Server validates the stamp: + - Version is `1` + - Claimed bits ≥ required bits + - Resource matches the server name + - Date is within 48 hours (not expired, not too far in the future) + - SHA-256 hash has the required leading zero bits + - Stamp has not been used before (replay prevention) -### Adaptive Difficulty +### Stamp Format -The required difficulty scales with server load. Under normal conditions, the -cost is negligible (a few milliseconds of CPU). As concurrent sessions or -session creation rate increases, difficulty rises — making bulk session creation -exponentially more expensive for attackers while remaining cheap for legitimate -single-user connections. +Standard hashcash format: -| Server Load | Difficulty (bits) | Approx. Client CPU | -|--------------------|-------------------|--------------------| -| Normal (< 100/min) | 16 | ~1ms | -| Elevated | 20 | ~15ms | -| High | 24 | ~250ms | -| Under attack | 28+ | ~4s+ | +``` +1:bits:date:resource::counter +``` -Each additional bit of difficulty doubles the expected work. An attacker -creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate -user creating one session needs ~4 seconds once and never again for the -duration of their session. +| Field | Description | +|------------|-------------| +| `1` | Version (always `1`) | +| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | +| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | +| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) | +| (empty) | Extension field (unused) | +| `counter` | Hex counter value found by the client to satisfy the PoW | + +**Example stamp:** `1:20:260310:neoirc::3a2f1b` + +The SHA-256 hash of this entire string must have at least 20 leading zero bits. + +### Computing a Stamp + +```bash +# Pseudocode +bits = 20 +resource = "neoirc" +date = "260310" # YYMMDD in UTC +counter = 0 + +loop: + stamp = "1:{bits}:{date}:{resource}::{hex(counter)}" + hash = SHA-256(stamp) + if leading_zero_bits(hash) >= bits: + return stamp + counter++ +``` + +At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on +average, taking roughly 0.5–2 seconds on modern hardware. + +### Client Integration + +Both the embedded web SPA and the CLI client automatically handle hashcash: + +1. Fetch `GET /api/v1/server` to read `hashcash_bits` +2. If `hashcash_bits > 0`, compute a valid stamp +3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session` + +The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 +computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. + +### Configuration + +Set `NEOIRC_HASHCASH_BITS` to control difficulty: + +| Value | Effect | Approx. Client CPU | +|-------|--------|---------------------| +| `0` | Disabled (no proof-of-work required) | — | +| `16` | Light protection | ~1ms | +| `20` | Default — good balance | ~0.5–2s | +| `24` | Strong protection | ~10–30s | +| `28` | Very strong (may frustrate users) | ~2–10min | + +Each additional bit doubles the expected work. An attacker creating 1000 +sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user +creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? - **No state to track**: No IP tables, no token buckets, no sliding windows. - The server only needs to verify a hash. + The server only needs to verify a single hash. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. @@ -2161,36 +2219,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 @@ -2219,7 +2250,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` diff --git a/cmd/neoirc-cli/api/client.go b/cmd/neoirc-cli/api/client.go index 8f7fdcf..dc37426 100644 --- a/cmd/neoirc-cli/api/client.go +++ b/cmd/neoirc-cli/api/client.go @@ -43,13 +43,34 @@ func NewClient(baseURL string) *Client { } // CreateSession creates a new session on the server. +// If the server requires hashcash proof-of-work, it +// automatically fetches the difficulty and computes a +// valid stamp. func (client *Client) CreateSession( nick string, ) (*SessionResponse, error) { - data, err := client.do( + // Fetch server info to check for hashcash requirement. + info, err := client.GetServerInfo() + + var headers map[string]string + + if err == nil && info.HashcashBits > 0 { + resource := info.Name + if resource == "" { + resource = "neoirc" + } + + stamp := MintHashcash(info.HashcashBits, resource) + headers = map[string]string{ + "X-Hashcash": stamp, + } + } + + data, err := client.doWithHeaders( http.MethodPost, "/api/v1/session", &SessionRequest{Nick: nick}, + headers, ) if err != nil { return nil, err @@ -261,6 +282,16 @@ func (client *Client) GetServerInfo() ( func (client *Client) do( method, path string, body any, +) ([]byte, error) { + return client.doWithHeaders( + method, path, body, nil, + ) +} + +func (client *Client) doWithHeaders( + method, path string, + body any, + extraHeaders map[string]string, ) ([]byte, error) { var bodyReader io.Reader @@ -293,6 +324,10 @@ func (client *Client) do( ) } + for key, val := range extraHeaders { + request.Header.Set(key, val) + } + resp, err := client.HTTPClient.Do(request) if err != nil { return nil, fmt.Errorf("http: %w", err) diff --git a/cmd/neoirc-cli/api/hashcash.go b/cmd/neoirc-cli/api/hashcash.go new file mode 100644 index 0000000..c064007 --- /dev/null +++ b/cmd/neoirc-cli/api/hashcash.go @@ -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()) +} diff --git a/cmd/neoirc-cli/api/types.go b/cmd/neoirc-cli/api/types.go index 96a0dc6..707ae82 100644 --- a/cmd/neoirc-cli/api/types.go +++ b/cmd/neoirc-cli/api/types.go @@ -63,9 +63,10 @@ type Channel struct { // ServerInfo is the response from GET /api/v1/server. type ServerInfo struct { - Name string `json:"name"` - MOTD string `json:"motd"` - Version string `json:"version"` + Name string `json:"name"` + MOTD string `json:"motd"` + Version string `json:"version"` + HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle } // MessagesResponse wraps polling results. diff --git a/internal/config/config.go b/internal/config/config.go index 468ca75..982e7f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a71e31b..803fa11 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -144,6 +144,33 @@ func (hdlr *Handlers) handleCreateSession( writer http.ResponseWriter, request *http.Request, ) { + // Validate hashcash proof-of-work if configured. + if hdlr.params.Config.HashcashBits > 0 { + stamp := request.Header.Get("X-Hashcash") + if stamp == "" { + hdlr.respondError( + writer, request, + "hashcash proof-of-work required", + http.StatusPaymentRequired, + ) + + return + } + + err := hdlr.hashcashVal.Validate( + stamp, hdlr.params.Config.HashcashBits, + ) + if err != nil { + hdlr.respondError( + writer, request, + "invalid hashcash stamp: "+err.Error(), + http.StatusPaymentRequired, + ) + + return + } + } + type createRequest struct { Nick string `json:"nick"` } @@ -2335,10 +2362,18 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { return } - hdlr.respondJSON(writer, request, map[string]any{ + resp := map[string]any{ "name": hdlr.params.Config.ServerName, "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, + ) } } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index d8eb7c8..a07cb9f 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -85,6 +85,7 @@ func newTestServer( cfg.DBURL = dbURL cfg.Port = 0 + cfg.HashcashBits = 0 return cfg, nil }, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 72ef994..986b648 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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{ diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go new file mode 100644 index 0000000..345c20b --- /dev/null +++ b/internal/hashcash/hashcash.go @@ -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) + } + } +} diff --git a/web/dist/app.js b/web/dist/app.js index 65d6ad9..af39a5c 100644 --- a/web/dist/app.js +++ b/web/dist/app.js @@ -1,2 +1,2 @@ -var ie,T,He,pt,G,Pe,Ue,De,Re,ve,me,he,mt,ee={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,_e=Array.isArray;function J(e,t){for(var n in t)e[n]=t[n];return e}function be(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var o,s,a,_={};for(a in t)a=="key"?o=t[a]:a=="ref"?s=t[a]:_[a]=t[a];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(a in e.defaultProps)_[a]===void 0&&(_[a]=e.defaultProps[a]);return ae(e,_,o,s,null)}function ae(e,t,n,o,s){var a={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++He,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(a),a}function le(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;tu&&G.sort(De),e=G.shift(),u=G.length,e.__d&&(n=void 0,o=void 0,s=(o=(t=e).__v).__e,a=[],_=[],t.__P&&((n=J({},o)).__v=o.__v+1,T.vnode&&T.vnode(n),ke(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[s]:null,a,s??Y(o),!!(32&o.__u),_),n.__v=o.__v,n.__.__k[n.__i]=n,je(a,n,_),o.__e=o.__=null,n.__e!=s&&We(n)));ce.__r=0}function Fe(e,t,n,o,s,a,_,u,y,f,b){var c,h,v,C,E,A,g,w=o&&o.__k||Le,U=t.length;for(y=yt(n,t,w,y,U),c=0;c0?_=e.__k[a]=ae(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[a]=_,y=a+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=vt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>b?h--:sy?h--:h++,_.__u|=4))):e.__k[a]=null;if(c)for(a=0;a(b?1:0)){for(s=n-1,a=n+1;s>=0||a=0?s--:a++])!=null&&(2&f.__u)==0&&u==f.key&&y==f.type)return _}return-1}function Ee(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function oe(e,t,n,o,s){var a,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")a=t!=(t=t.replace(Re,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?o?n.u=o.u:(n.u=ve,e.addEventListener(t,a?he:me,a)):e.removeEventListener(t,a?he:me,a);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ve++;else if(t.t0?e:_e(e)?e.map(Ve):J({},e)}function bt(e,t,n,o,s,a,_,u,y){var f,b,c,h,v,C,E,A=n.props||ee,g=t.props,w=t.type;if(w=="svg"?s="http://www.w3.org/2000/svg":w=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),a!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function $(e){return ne=1,gt(ot,e)}function gt(e,t,n){var o=Te(te++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(u){var y=o.__N?o.__N[0]:o.__[0],f=o.t(y,u);y!==f&&(o.__N=[f,o.__[1]],o.__c.setState({}))}],o.__c=I,!I.__f)){var s=function(u,y,f){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(h){return!!h.__c});if(b.every(function(h){return!h.__N}))return!a||a.call(this,u,y,f);var c=o.__c.props!==u;return b.forEach(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),a&&a.call(this,u,y,f)||c};I.__f=!0;var a=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var b=a;a=void 0,s(u,y,f),a=b}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return o.__N||o.__}function H(e,t){var n=Te(te++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function L(e){return ne=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(te++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return ne=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(ue),e.__H.__h.forEach(we),e.__H.__h=[]}catch(t){e.__H.__h=[],O.__e(t,e.__v)}}O.__b=function(e){I=null,Ge&&Ge(e)},O.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},O.__r=function(e){ze&&ze(e),te=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(ue),t.__h.forEach(we),t.__h=[],te=0)),Se=I},O.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ke===O.requestAnimationFrame||((Ke=O.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),Se=I=null},O.__c=function(e,t){t.some(function(n){try{n.__h.forEach(ue),n.__h=n.__h.filter(function(o){return!o.__||we(o)})}catch(o){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(o,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{ue(o)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function ue(e){var t=I,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),I=t}function we(e){var t=I;e.__c=e.__(),I=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Tt="/api/v1",xt=15,Ct=3e3,It=1e4,Ce="ACTION ",Ie="";function x(e,t={}){let n=localStorage.getItem("neoirc_token"),o={"Content-Type":"application/json",...t.headers||{}};n&&(o.Authorization=`Bearer ${n}`);let{signal:s,...a}=t;return fetch(Tt+e,{...a,headers:o,signal:s}).then(async _=>{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function xe(e){let t=0;for(let o=0;o{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state").then(h=>e(h.nick,!0)).catch(()=>localStorage.removeItem("neoirc_token")),f.current?.focus()},[]),m("div",{class:"login-screen"},m("div",{class:"login-box"},m("h1",null,u),a&&m("pre",{class:"motd"},a),m("form",{onSubmit:async c=>{c.preventDefault(),s("");try{let h=await x("/session",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("neoirc_token",h.token),e(h.nick)}catch(h){s(h.data?.error||"Connection failed")}}},m("label",null,"Nickname:"),m("input",{ref:f,type:"text",placeholder:"Enter nickname",value:t,onInput:c=>n(c.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),o&&m("div",{class:"error"},o)))}function $t({msg:e,myNick:t}){let n=At(e.ts);return e.system?m("div",{class:"message system-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"system-text"}," * ",e.text)):e.isAction?m("div",{class:"message action-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"action-text"}," ","* ",m("span",{style:{color:xe(e.from)}},e.from)," ",e.text)):m("div",{class:"message"},m("span",{class:"timestamp"},"[",n,"]")," ",m("span",{class:"nick",style:{color:xe(e.from)}},"<",e.from,">")," ",m("span",{class:"content"},e.text))}function Et({members:e,onNickClick:t}){let n=[],o=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?o.push(u):s.push(u)}let a=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(a),o.sort(a),s.sort(a);let _=(u,y)=>m("div",{class:"nick-entry",onClick:()=>t(u.nick),title:u.nick},m("span",{class:"nick-prefix"},y),m("span",{class:"nick-name",style:{color:xe(u.nick)}},u.nick));return m("div",{class:"user-list"},m("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),m("div",{class:"user-list-entries"},n.map(u=>_(u,"@")),o.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Mt(){let[e,t]=$(!1),[n,o]=$(""),[s,a]=$([{type:"server",name:"Server"}]),[_,u]=$(0),[y,f]=$({Server:[]}),[b,c]=$({}),[h,v]=$({}),[C,E]=$({}),[A,g]=$(""),[w,U]=$(!0),[M,z]=$([]),[R,B]=$(-1),j=L(0),V=L(new Set),Q=L(null),P=L(s),W=L(_),re=L(n),Ae=L(),Z=L();H(()=>{P.current=s},[s]),H(()=>{W.current=_},[_]),H(()=>{re.current=n},[n]),H(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),H(()=>{let r=s[_];r&&E(i=>({...i,[r.name]:0}))},[_,s]);let N=X((r,i)=>{if(i.id&&V.current.has(i.id))return;i.id&&V.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let d=P.current[W.current];(!d||d.name!==r)&&E(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=X((r,i)=>{f(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=X(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(d=>{c(l=>({...l,[r]:d}))}).catch(()=>{})},[]),fe=X(r=>{let i=Array.isArray(r.body)?r.body.join(` +var ie,T,He,pt,G,Pe,Ue,De,Re,ve,me,he,mt,ee={},Le=[],ht=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,_e=Array.isArray;function J(e,t){for(var n in t)e[n]=t[n];return e}function be(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var o,s,a,_={};for(a in t)a=="key"?o=t[a]:a=="ref"?s=t[a]:_[a]=t[a];if(arguments.length>2&&(_.children=arguments.length>3?ie.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(a in e.defaultProps)_[a]===void 0&&(_[a]=e.defaultProps[a]);return ae(e,_,o,s,null)}function ae(e,t,n,o,s){var a={type:e,props:t,key:n,ref:o,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++He,__i:-1,__u:0};return s==null&&T.vnode!=null&&T.vnode(a),a}function le(e){return e.children}function se(e,t){this.props=e,this.context=t}function Y(e,t){if(t==null)return e.__?Y(e.__,e.__i+1):null;for(var n;tu&&G.sort(De),e=G.shift(),u=G.length,e.__d&&(n=void 0,o=void 0,s=(o=(t=e).__v).__e,a=[],_=[],t.__P&&((n=J({},o)).__v=o.__v+1,T.vnode&&T.vnode(n),ke(t.__P,n,o,t.__n,t.__P.namespaceURI,32&o.__u?[s]:null,a,s??Y(o),!!(32&o.__u),_),n.__v=o.__v,n.__.__k[n.__i]=n,je(a,n,_),o.__e=o.__=null,n.__e!=s&&We(n)));ce.__r=0}function Fe(e,t,n,o,s,a,_,u,y,f,b){var c,h,v,C,E,A,g,w=o&&o.__k||Le,U=t.length;for(y=yt(n,t,w,y,U),c=0;c0?_=e.__k[a]=ae(_.type,_.props,_.key,_.ref?_.ref:null,_.__v):e.__k[a]=_,y=a+h,_.__=e,_.__b=e.__b+1,u=null,(f=_.__i=vt(_,n,y,c))!=-1&&(c--,(u=n[f])&&(u.__u|=2)),u==null||u.__v==null?(f==-1&&(s>b?h--:sy?h--:h++,_.__u|=4))):e.__k[a]=null;if(c)for(a=0;a(b?1:0)){for(s=n-1,a=n+1;s>=0||a=0?s--:a++])!=null&&(2&f.__u)==0&&u==f.key&&y==f.type)return _}return-1}function Ee(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||ht.test(t)?n:n+"px"}function oe(e,t,n,o,s){var a,_;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof o=="string"&&(e.style.cssText=o=""),o)for(t in o)n&&t in n||Ee(e.style,t,"");if(n)for(t in n)o&&n[t]==o[t]||Ee(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")a=t!=(t=t.replace(Re,"$1")),_=t.toLowerCase(),t=_ in e||t=="onFocusOut"||t=="onFocusIn"?_.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?o?n.u=o.u:(n.u=ve,e.addEventListener(t,a?he:me,a)):e.removeEventListener(t,a?he:me,a);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Me(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ve++;else if(t.t0?e:_e(e)?e.map(Ve):J({},e)}function bt(e,t,n,o,s,a,_,u,y){var f,b,c,h,v,C,E,A=n.props||ee,g=t.props,w=t.type;if(w=="svg"?s="http://www.w3.org/2000/svg":w=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),a!=null){for(f=0;f=n.__.length&&n.__.push({}),n.__[e]}function $(e){return ne=1,gt(ot,e)}function gt(e,t,n){var o=Te(te++,2);if(o.t=e,!o.__c&&(o.__=[n?n(t):ot(void 0,t),function(u){var y=o.__N?o.__N[0]:o.__[0],f=o.t(y,u);y!==f&&(o.__N=[f,o.__[1]],o.__c.setState({}))}],o.__c=I,!I.__f)){var s=function(u,y,f){if(!o.__c.__H)return!0;var b=o.__c.__H.__.filter(function(h){return!!h.__c});if(b.every(function(h){return!h.__N}))return!a||a.call(this,u,y,f);var c=o.__c.props!==u;return b.forEach(function(h){if(h.__N){var v=h.__[0];h.__=h.__N,h.__N=void 0,v!==h.__[0]&&(c=!0)}}),a&&a.call(this,u,y,f)||c};I.__f=!0;var a=I.shouldComponentUpdate,_=I.componentWillUpdate;I.componentWillUpdate=function(u,y,f){if(this.__e){var b=a;a=void 0,s(u,y,f),a=b}_&&_.call(this,u,y,f)},I.shouldComponentUpdate=s}return o.__N||o.__}function H(e,t){var n=Te(te++,3);!O.__s&&rt(n.__H,t)&&(n.__=e,n.u=t,I.__H.__h.push(n))}function L(e){return ne=5,nt(function(){return{current:e}},[])}function nt(e,t){var n=Te(te++,7);return rt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function X(e,t){return ne=8,nt(function(){return e},t)}function St(){for(var e;e=tt.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(ue),e.__H.__h.forEach(we),e.__H.__h=[]}catch(t){e.__H.__h=[],O.__e(t,e.__v)}}O.__b=function(e){I=null,Ge&&Ge(e)},O.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),Ze&&Ze(e,t)},O.__r=function(e){ze&&ze(e),te=0;var t=(I=e.__c).__H;t&&(Se===I?(t.__h=[],I.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(ue),t.__h.forEach(we),t.__h=[],te=0)),Se=I},O.diffed=function(e){Qe&&Qe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(tt.push(t)!==1&&Ke===O.requestAnimationFrame||((Ke=O.requestAnimationFrame)||wt)(St)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),Se=I=null},O.__c=function(e,t){t.some(function(n){try{n.__h.forEach(ue),n.__h=n.__h.filter(function(o){return!o.__||we(o)})}catch(o){t.some(function(s){s.__h&&(s.__h=[])}),t=[],O.__e(o,n.__v)}}),Ye&&Ye(e,t)},O.unmount=function(e){Xe&&Xe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(o){try{ue(o)}catch(s){t=s}}),n.__H=void 0,t&&O.__e(t,n.__v))};var et=typeof requestAnimationFrame=="function";function wt(e){var t,n=function(){clearTimeout(o),et&&cancelAnimationFrame(t),setTimeout(e)},o=setTimeout(n,35);et&&(t=requestAnimationFrame(n))}function ue(e){var t=I,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),I=t}function we(e){var t=I;e.__c=e.__(),I=t}function rt(e,t){return!e||e.length!==t.length||t.some(function(n,o){return n!==e[o]})}function ot(e,t){return typeof t=="function"?t(e):t}var Tt="/api/v1",xt=15,Ct=3e3,It=1e4,Ce="ACTION ",Ie="";function Vt(e,t){let n=0;for(let o=0;o=t}async function Bt(e,t){let n=new TextEncoder,o=new Date,s=String(o.getUTCFullYear()).slice(2)+String(o.getUTCMonth()+1).padStart(2,"0")+String(o.getUTCDate()).padStart(2,"0"),a=`1:${e}:${s}:${t}::`,_=Math.floor(Math.random()*1048576),u=1024;for(;;){let y=[],f=[];for(let b=0;b{let u=await _.json().catch(()=>null);if(!_.ok)throw{status:_.status,data:u};return u})}function At(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1})}function xe(e){let t=0;for(let o=0;o{x("/server").then(h=>{h.name&&y(h.name),h.motd&&_(h.motd),hcB.current=h.hashcash_bits||0,h.name&&(hcR.current=h.name)}).catch(()=>{}),localStorage.getItem("neoirc_token")&&x("/state").then(h=>e(h.nick,!0)).catch(()=>localStorage.removeItem("neoirc_token")),f.current?.focus()},[]),m("div",{class:"login-screen"},m("div",{class:"login-box"},m("h1",null,u),a&&m("pre",{class:"motd"},a),m("form",{onSubmit:async c=>{c.preventDefault(),s("");try{let hd={};if(hcB.current>0){s("Computing proof-of-work...");let stamp=await Bt(hcB.current,hcR.current);hd["X-Hashcash"]=stamp;s("")}let h=await x("/session",{method:"POST",body:JSON.stringify({nick:t.trim()}),headers:hd});localStorage.setItem("neoirc_token",h.token),e(h.nick)}catch(h){s(h.data?.error||"Connection failed")}}},m("label",null,"Nickname:"),m("input",{ref:f,type:"text",placeholder:"Enter nickname",value:t,onInput:c=>n(c.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),o&&m("div",{class:"error"},o)))}function $t({msg:e,myNick:t}){let n=At(e.ts);return e.system?m("div",{class:"message system-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"system-text"}," * ",e.text)):e.isAction?m("div",{class:"message action-message"},m("span",{class:"timestamp"},"[",n,"]"),m("span",{class:"action-text"}," ","* ",m("span",{style:{color:xe(e.from)}},e.from)," ",e.text)):m("div",{class:"message"},m("span",{class:"timestamp"},"[",n,"]")," ",m("span",{class:"nick",style:{color:xe(e.from)}},"<",e.from,">")," ",m("span",{class:"content"},e.text))}function Et({members:e,onNickClick:t}){let n=[],o=[],s=[];for(let u of e){let y=u.mode||"";y==="o"?n.push(u):y==="v"?o.push(u):s.push(u)}let a=(u,y)=>u.nick.toLowerCase().localeCompare(y.nick.toLowerCase());n.sort(a),o.sort(a),s.sort(a);let _=(u,y)=>m("div",{class:"nick-entry",onClick:()=>t(u.nick),title:u.nick},m("span",{class:"nick-prefix"},y),m("span",{class:"nick-name",style:{color:xe(u.nick)}},u.nick));return m("div",{class:"user-list"},m("div",{class:"user-list-header"},e.length," user",e.length!==1?"s":""),m("div",{class:"user-list-entries"},n.map(u=>_(u,"@")),o.map(u=>_(u,"+")),s.map(u=>_(u,""))))}function Mt(){let[e,t]=$(!1),[n,o]=$(""),[s,a]=$([{type:"server",name:"Server"}]),[_,u]=$(0),[y,f]=$({Server:[]}),[b,c]=$({}),[h,v]=$({}),[C,E]=$({}),[A,g]=$(""),[w,U]=$(!0),[M,z]=$([]),[R,B]=$(-1),j=L(0),V=L(new Set),Q=L(null),P=L(s),W=L(_),re=L(n),Ae=L(),Z=L();H(()=>{P.current=s},[s]),H(()=>{W.current=_},[_]),H(()=>{re.current=n},[n]),H(()=>{let r=s.filter(i=>i.type==="channel").map(i=>i.name);localStorage.setItem("neoirc_channels",JSON.stringify(r))},[s]),H(()=>{let r=s[_];r&&E(i=>({...i,[r.name]:0}))},[_,s]);let N=X((r,i)=>{if(i.id&&V.current.has(i.id))return;i.id&&V.current.add(i.id),f(l=>({...l,[r]:[...l[r]||[],i]}));let d=P.current[W.current];(!d||d.name!==r)&&E(l=>({...l,[r]:(l[r]||0)+1}))},[]),S=X((r,i)=>{f(d=>({...d,[r]:[...d[r]||[],{id:"sys-"+Date.now()+"-"+Math.random(),ts:new Date().toISOString(),text:i,system:!0}]}))},[]),K=X(r=>{let i=r.replace("#","");x(`/channels/${i}/members`).then(d=>{c(l=>({...l,[r]:d}))}).catch(()=>{})},[]),fe=X(r=>{let i=Array.isArray(r.body)?r.body.join(` `):"",d={id:r.id,ts:r.ts,from:r.from,to:r.to,command:r.command};switch(r.command){case"PRIVMSG":case"NOTICE":{let l=i,p=!1;Ot(l)&&(l=Nt(l),p=!0);let k={...d,text:l,system:!1,isAction:p},F=r.to;if(F&&F.startsWith("#"))N(F,k);else{let D=r.from===re.current?r.to:r.from;a(pe=>pe.find(Ne=>Ne.type==="dm"&&Ne.name===D)?pe:[...pe,{type:"dm",name:D}]),N(D,k)}break}case"JOIN":{let l=`${r.from} has joined ${r.to}`;r.to&&N(r.to,{...d,text:l,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"PART":{let l=i?" ("+i+")":"",p=`${r.from} has parted ${r.to}${l}`;r.to&&N(r.to,{...d,text:p,system:!0}),r.to&&r.to.startsWith("#")&&K(r.to);break}case"QUIT":{let l=i?" ("+i+")":"",p=`${r.from} has quit${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})});break}case"NICK":{let l=Array.isArray(r.body)?r.body[0]:i,p=`${r.from} is now known as ${l}`;P.current.forEach(k=>{k.type==="channel"&&N(k.name,{...d,text:p,system:!0})}),r.from===re.current&&l&&o(l),P.current.forEach(k=>{k.type==="channel"&&K(k.name)});break}case"TOPIC":{let l=`${r.from} has changed the topic to: ${i}`;r.to&&(N(r.to,{...d,text:l,system:!0}),v(p=>({...p,[r.to]:i})));break}case"353":{if(Array.isArray(r.params)&&r.params.length>=2&&r.body){let l=r.params[1],F=(Array.isArray(r.body)?r.body[0]:String(r.body)).split(/\s+/).filter(Boolean).map(D=>D.startsWith("@")?{nick:D.slice(1),mode:"o"}:D.startsWith("+")?{nick:D.slice(1),mode:"v"}:{nick:D,mode:""});c(D=>({...D,[l]:F}))}break}case"332":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0],p=Array.isArray(r.body)?r.body[0]:i;p&&v(k=>({...k,[l]:p}))}break}case"322":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} (${p} users): ${(i||"").trim()}`,system:!0})}break}case"323":N("Server",{...d,text:i||"End of channel list",system:!0});break;case"352":{if(Array.isArray(r.params)&&r.params.length>=5){let l=r.params[0],p=r.params[4],k=r.params.length>5?r.params[5]:"";N("Server",{...d,text:`${l} ${p} ${k}`,system:!0})}break}case"315":N("Server",{...d,text:i||"End of /WHO list",system:!0});break;case"311":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} (${i})`,system:!0})}break}case"312":{if(Array.isArray(r.params)&&r.params.length>=2){let l=r.params[0],p=r.params[1];N("Server",{...d,text:`${l} on ${p}`,system:!0})}break}case"319":{if(Array.isArray(r.params)&&r.params.length>=1){let l=r.params[0];N("Server",{...d,text:`${l} is on: ${i}`,system:!0})}break}case"318":N("Server",{...d,text:i||"End of /WHOIS list",system:!0});break;case"375":case"372":case"376":N("Server",{...d,text:i,system:!0});break;default:i&&N("Server",{...d,text:i,system:!0})}},[N,K]);H(()=>{if(!e)return;let r=!0;return(async()=>{for(;r;)try{let d=new AbortController;Q.current=d;let l=await x(`/messages?after=${j.current}&timeout=${xt}`,{signal:d.signal});if(!r)break;if(U(!0),l.messages)for(let p of l.messages)fe(p);l.last_id>j.current&&(j.current=l.last_id)}catch(d){if(!r)break;if(d.name==="AbortError")continue;U(!1),await new Promise(l=>setTimeout(l,Ct))}})(),()=>{r=!1,Q.current?.abort()}},[e,fe]),H(()=>{if(!e)return;let r=s[_];if(!r||r.type!=="channel")return;K(r.name);let i=setInterval(()=>K(r.name),It);return()=>clearInterval(i)},[e,_,s,K]),H(()=>{Ae.current?.scrollIntoView({behavior:"smooth"})},[y,_]),H(()=>{Z.current?.focus()},[_]),H(()=>{let r=i=>{i.key==="/"&&document.activeElement!==Z.current&&!i.ctrlKey&&!i.altKey&&!i.metaKey&&(i.preventDefault(),Z.current?.focus())};return document.addEventListener("keydown",r),Z.current?.focus(),()=>document.removeEventListener("keydown",r)},[]),H(()=>{if(!e)return;let r=s[_];!r||r.type!=="channel"||x("/channels").then(i=>{let d=i.find(l=>l.name===r.name);d&&d.topic&&v(l=>({...l,[r.name]:d.topic}))}).catch(()=>{})},[e,_,s]);let at=X(async(r,i)=>{if(o(r),t(!0),S("Server",`Connected as ${r}`),i)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch{}let d=JSON.parse(localStorage.getItem("neoirc_channels")||"[]");for(let l of d)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:l})}),a(p=>p.find(k=>k.type==="channel"&&k.name===l)?p:[...p,{type:"channel",name:l}])}catch{}},[S]),st=async r=>{if(r){r=r.trim(),r.startsWith("#")||(r="#"+r);try{await x("/messages",{method:"POST",body:JSON.stringify({command:"JOIN",to:r})}),a(d=>d.find(l=>l.type==="channel"&&l.name===r)?d:[...d,{type:"channel",name:r}]);let i=P.current.length;u(i);try{let d=await x(`/history?target=${encodeURIComponent(r)}&limit=50`);if(Array.isArray(d))for(let l of d)fe(l)}catch{}}catch(i){S("Server",`Failed to join ${r}: ${i.data?.error||"error"}`)}}},Oe=async(r,i)=>{try{await x("/messages",{method:"POST",body:JSON.stringify(i?{command:"PART",to:r,body:[i]}:{command:"PART",to:r})})}catch{}a(d=>d.filter(l=>!(l.type==="channel"&&l.name===r))),u(0)},ct=r=>{let i=s[r];i.type==="channel"?Oe(i.name):i.type==="dm"&&(a(d=>d.filter((l,p)=>p!==r)),_>=r&&u(Math.max(0,_-1)))},de=r=>{if(r===re.current)return;a(d=>d.find(l=>l.type==="dm"&&l.name===r)?d:[...d,{type:"dm",name:r}]);let i=s.findIndex(d=>d.type==="dm"&&d.name===r);u(i>=0?i:s.length)},it=async r=>{let i=r.split(" "),d=i[0].toLowerCase(),l=s[_];switch(d){case"/join":{i[1]?st(i[1]):S("Server","Usage: /join #channel");break}case"/part":{if(l.type==="channel"){let p=i.slice(1).join(" ")||void 0;Oe(l.name,p)}else S("Server","You are not in a channel");break}case"/msg":{if(i[1]&&i.slice(2).join(" ")){let p=i[1],k=i.slice(2).join(" ");try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})}),de(p)}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /msg ");break}case"/me":{if(l.type==="server"){S("Server","Cannot use /me in server window");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:l.name,body:[Ce+p+Ie]})})}catch(k){S(l.name,`Action failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /me ");break}case"/nick":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"NICK",body:[i[1]]})})}catch(p){S("Server",`Nick change failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /nick ");break}case"/topic":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1).join(" ");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"TOPIC",to:l.name,body:[p]})})}catch(k){S("Server",`Topic change failed: ${k.data?.error||"error"}`)}else S("Server",`Current topic for ${l.name}: ${h[l.name]||"(none)"}`);break}case"/mode":{if(l.type!=="channel"){S("Server","You are not in a channel");break}let p=i.slice(1);if(p.length>0)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MODE",to:l.name,params:p})})}catch(k){S("Server",`Mode change failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /mode <+/-mode> [params]");break}case"/quit":{let p=i.slice(1).join(" ")||void 0;try{await x("/messages",{method:"POST",body:JSON.stringify(p?{command:"QUIT",body:[p]}:{command:"QUIT"})})}catch{}localStorage.removeItem("neoirc_token"),localStorage.removeItem("neoirc_channels"),window.location.reload();break}case"/motd":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"MOTD"})})}catch(p){S("Server",`Failed to request MOTD: ${p.data?.error||"error"}`)}break}case"/query":{if(i[1]){let p=i[1];de(p);let k=i.slice(2).join(" ");if(k)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:p,body:[k]})})}catch(F){S("Server",`Message failed: ${F.data?.error||"error"}`)}}else S("Server","Usage: /query [message]");break}case"/list":{try{await x("/messages",{method:"POST",body:JSON.stringify({command:"LIST"})})}catch(p){S("Server",`Failed to list channels: ${p.data?.error||"error"}`)}break}case"/who":{let p=i[1]||(l.type==="channel"?l.name:"");if(p)try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHO",to:p})})}catch(k){S("Server",`WHO failed: ${k.data?.error||"error"}`)}else S("Server","Usage: /who #channel");break}case"/whois":{if(i[1])try{await x("/messages",{method:"POST",body:JSON.stringify({command:"WHOIS",to:i[1]})})}catch(p){S("Server",`WHOIS failed: ${p.data?.error||"error"}`)}else S("Server","Usage: /whois ");break}case"/clear":{let p=l.name;f(k=>({...k,[p]:[]}));break}case"/help":{let p=["Available commands:"," /join #channel \u2014 Join a channel"," /part [reason] \u2014 Part the current channel"," /msg nick message \u2014 Send a private message"," /query nick [message] \u2014 Open a DM tab (optionally send a message)"," /me action \u2014 Send an action"," /nick newnick \u2014 Change your nickname"," /topic [text] \u2014 View or set channel topic"," /mode +/-flags \u2014 Set channel modes"," /motd \u2014 Display the message of the day"," /list \u2014 List all channels"," /who [#channel] \u2014 List users in a channel"," /whois nick \u2014 Show info about a user"," /clear \u2014 Clear messages in the current tab"," /quit [reason] \u2014 Disconnect from server"," /help \u2014 Show this help"];for(let k of p)S("Server",k);break}default:S("Server",`Unknown command: ${d}`)}},_t=async()=>{let r=A.trim();if(!r)return;z(d=>{let l=[...d,r];return l.length>100&&l.shift(),l}),B(-1),g("");let i=s[_];if(i){if(r.startsWith("/")){await it(r);return}if(i.type==="server"){S("Server","Cannot send messages to the server window. Use /join #channel first.");return}try{await x("/messages",{method:"POST",body:JSON.stringify({command:"PRIVMSG",to:i.name,body:[r]})})}catch(d){S(i.name,`Send failed: ${d.data?.error||"error"}`)}}},lt=r=>{if(r.key==="Enter")_t();else if(r.key==="ArrowUp"){if(r.preventDefault(),M.length>0){let i=R===-1?M.length-1:Math.max(0,R-1);B(i),g(M[i])}}else if(r.key==="ArrowDown"&&(r.preventDefault(),R>=0)){let i=R+1;i>=M.length?(B(-1),g("")):(B(i),g(M[i]))}};if(!e)return m(Pt,{onLogin:at});let q=s[_]||s[0],ut=y[q.name]||[],ft=b[q.name]||[],dt=h[q.name]||"";return m("div",{class:"irc-app"},m("div",{class:"tab-bar"},m("div",{class:"tabs"},s.map((r,i)=>m("div",{class:`tab ${i===_?"active":""} ${C[r.name]>0&&i!==_?"has-unread":""}`,onClick:()=>u(i),key:r.name},m("span",{class:"tab-label"},(r.type==="dm",r.name)),C[r.name]>0&&i!==_&&m("span",{class:"unread-count"},"(",C[r.name],")"),r.type!=="server"&&m("span",{class:"tab-close",onClick:d=>{d.stopPropagation(),ct(i)}},"\xD7")))),m("div",{class:"status-area"},!w&&m("span",{class:"status-warn"},"\u25CF Reconnecting"),m("span",{class:"status-nick"},n))),q.type==="channel"&&m("div",{class:"topic-bar"},m("span",{class:"topic-label"},"Topic:")," ",m("span",{class:"topic-text"},dt||"(no topic set)")),m("div",{class:"main-area"},m("div",{class:"messages-panel"},m("div",{class:"messages-scroll"},ut.map(r=>m($t,{msg:r,myNick:n,key:r.id})),m("div",{ref:Ae}))),q.type==="channel"&&m(Et,{members:ft,onNickClick:de})),m("div",{class:"input-line"},m("span",{class:"input-prompt"},"[",n,"]",q.type!=="server"?` ${q.name}`:""," >"),m("input",{ref:Z,type:"text",value:A,onInput:r=>g(r.target.value),onKeyDown:lt,placeholder:q.type==="server"?"Type /help for commands":"",spellCheck:!1,autoComplete:"off"})))}Be(m(Mt,null),document.getElementById("root"));