2 Commits

Author SHA1 Message Date
38d222f6a7 Merge branch 'main' into fix/irc-numeric-replies
All checks were successful
check / check (push) Successful in 57s
2026-03-09 22:12:43 +01:00
user
8d91ad852c Replace HTTP status codes with IRC numeric replies in command handlers (closes #54)
All checks were successful
check / check (push) Successful in 2m17s
IRC command handlers now return proper IRC numeric reply codes per
RFC 1459/2812 instead of HTTP status codes:

- 401 ERR_NOSUCHNICK for unknown DM targets
- 403 ERR_NOSUCHCHANNEL for invalid/missing channels
- 411 ERR_NORECIPIENT for missing message recipients
- 412 ERR_NOTEXTTOSEND for missing message body
- 421 ERR_UNKNOWNCOMMAND for unknown/empty commands
- 431 ERR_NONICKNAMEGIVEN for missing nick in NICK command
- 432 ERR_ERRONEUSNICKNAME for invalid nick format
- 433 ERR_NICKNAMEINUSE for taken nicks
- 442 ERR_NOTONCHANNEL for non-member channel actions
- 461 ERR_NEEDMOREPARAMS for missing required parameters

Error responses use the IRC numeric format:
  {"command":"4xx","from":"server","to":"nick","body":["..."],"params":[...]}

HTTP status codes are now reserved for transport-level concerns:
- 400 for malformed HTTP requests (bad JSON)
- 401 for authentication failures
- 500 for internal server errors

Successful message sends changed from 201 to 200 since HTTP
status codes should not encode IRC-level semantics.
2026-03-08 01:16:04 -08:00
44 changed files with 1830 additions and 7004 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,13 +1,3 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02 # golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
@@ -15,9 +5,6 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
# without depending on the web-builder stage (lint should fail fast)
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
RUN make fmt-check RUN make fmt-check
RUN make lint RUN make lint
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test RUN make test

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist .PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := neoircd BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
# ensure-web-dist creates placeholder files so //go:embed dist/* in build:
# web/embed.go resolves without a full Node.js build. The real SPA is
# built by the web-builder Docker stage; these placeholders let
# "make test" and "make build" work outside Docker.
ensure-web-dist:
@if [ ! -d web/dist ]; then \
mkdir -p web/dist && \
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
echo "==> Created placeholder web/dist/ for go:embed"; \
fi
build: ensure-web-dist
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: ensure-web-dist lint:
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@@ -31,7 +20,7 @@ fmt:
fmt-check: fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist test:
go test -timeout 30s -v -race -cover ./... go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes # check runs all validation without making changes

851
README.md

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,8 +13,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
) )
const ( const (
@@ -43,30 +41,13 @@ func NewClient(baseURL string) *Client {
} }
// CreateSession creates a new session on the server. // 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( func (client *Client) CreateSession(
nick string, nick string,
) (*SessionResponse, error) { ) (*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( data, err := client.do(
http.MethodPost, http.MethodPost,
"/api/v1/session", "/api/v1/session",
&SessionRequest{Nick: nick, Hashcash: hashcashStamp}, &SessionRequest{Nick: nick},
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -187,7 +168,7 @@ func (client *Client) PollMessages(
func (client *Client) JoinChannel(channel string) error { func (client *Client) JoinChannel(channel string) error {
return client.SendMessage( return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel, Command: "JOIN", To: channel,
}, },
) )
} }
@@ -196,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
func (client *Client) PartChannel(channel string) error { func (client *Client) PartChannel(channel string) error {
return client.SendMessage( return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdPart, To: channel, Command: "PART", To: channel,
}, },
) )
} }

View File

@@ -5,7 +5,6 @@ import "time"
// SessionRequest is the body for POST /api/v1/session. // SessionRequest is the body for POST /api/v1/session.
type SessionRequest struct { type SessionRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
} }
// SessionResponse is the response from session creation. // SessionResponse is the response from session creation.
@@ -67,7 +66,6 @@ type ServerInfo struct {
Name string `json:"name"` Name string `json:"name"`
MOTD string `json:"motd"` MOTD string `json:"motd"`
Version string `json:"version"` Version string `json:"version"`
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
} }
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.

View File

@@ -1,8 +1,804 @@
// Package main is the entry point for the neoirc-cli client. // Package main is the entry point for the neoirc-cli client.
package main package main
import "git.eeqj.de/sneak/neoirc/internal/cli" import (
"fmt"
"os"
"strings"
"sync"
"time"
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
)
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{}
}
func main() { func main() {
cli.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: "PRIVMSG",
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 "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/window", "/w":
a.cmdWindow(args)
case "/quit":
a.cmdQuit()
case "/help":
a.cmdHelp()
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: "NICK",
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: "PRIVMSG",
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: "TOPIC",
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: "TOPIC",
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) 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: "QUIT"}, //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",
" /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 "PRIVMSG":
a.handlePrivmsgEvent(msg, timestamp, myNick)
case "JOIN":
a.handleJoinEvent(msg, timestamp)
case "PART":
a.handlePartEvent(msg, timestamp)
case "QUIT":
a.handleQuitEvent(msg, timestamp)
case "NICK":
a.handleNickEvent(msg, timestamp, myNick)
case "NOTICE":
a.handleNoticeEvent(msg, timestamp)
case "TOPIC":
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,
))
}
} }

View File

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

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server" "git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -36,7 +35,6 @@ func main() {
server.New, server.New,
middleware.New, middleware.New,
healthcheck.New, healthcheck.New,
stats.New,
), ),
fx.Invoke(func(*server.Server) {}), fx.Invoke(func(*server.Server) {}),
).Run() ).Run()

2
go.mod
View File

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

4
go.sum
View File

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

View File

@@ -1,98 +0,0 @@
package neoircapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
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
}
}
}
// MintChannelHashcash computes a hashcash stamp bound to
// a specific channel and message body. The stamp format
// is 1:bits:YYMMDD:channel:bodyhash:counter where
// bodyhash is the hex-encoded SHA-256 of the message
// body bytes. Delegates to the internal/hashcash package.
func MintChannelHashcash(
bits int,
channel string,
body []byte,
) string {
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(
bits, channel, bodyHash,
)
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / bitsPerByte
remainBits := numBits % bitsPerByte
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(
fullByteMask << (bitsPerByte - remainBits),
)
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
// randomCounter generates a random hex counter string.
func randomCounter() string {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(counterSpace),
)
if err != nil {
// Fallback to timestamp-based counter on error.
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(counterVal.Bytes())
}

View File

@@ -1,912 +0,0 @@
// 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/pkg/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,
))
}
}

View File

@@ -13,14 +13,6 @@ import (
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
| '_ \ / _ \/ _ \ | | '__/ __|
| | | | __/ (_) || | | | (__
|_| |_|\___|\___/ |_|_| \___|
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -38,14 +30,12 @@ type Config struct {
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string SentryDSN string
MessageMaxAge string MaxHistory int
MaxMessageSize int MaxMessageSize int
QueueMaxAge string
MOTD string MOTD string
ServerName string ServerName string
FederationKey string FederationKey string
SessionIdleTimeout string SessionIdleTimeout string
HashcashBits int
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -70,14 +60,12 @@ func New(
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MESSAGE_MAX_AGE", "720h") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("QUEUE_MAX_AGE", "720h") viper.SetDefault("MOTD", "")
viper.SetDefault("MOTD", defaultMOTD)
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -96,14 +84,12 @@ func New(
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MaxHistory: viper.GetInt("MAX_HISTORY"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
log: log, log: log,
params: &params, params: &params,
} }

View File

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

View File

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

View File

@@ -3,15 +3,12 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -32,37 +29,18 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages. // IRCMessage is the IRC envelope for all messages.
type IRCMessage struct { type IRCMessage struct {
ID string `json:"id"` ID string `json:"id"`
Command string `json:"command"` Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Body json.RawMessage `json:"body,omitempty"` Body json.RawMessage `json:"body,omitempty"`
TS string `json:"ts"` TS string `json:"ts"`
Meta json.RawMessage `json:"meta,omitempty"` Meta json.RawMessage `json:"meta,omitempty"`
DBID int64 `json:"-"` DBID int64 `json:"-"`
} }
// isNumericCode returns true if s is exactly a 3-digit
// IRC numeric reply code.
func isNumericCode(s string) bool {
return len(s) == 3 &&
s[0] >= '0' && s[0] <= '9' &&
s[1] >= '0' && s[1] <= '9' &&
s[2] >= '0' && s[2] <= '9'
}
// ChannelInfo is a lightweight channel representation. // ChannelInfo is a lightweight channel representation.
type ChannelInfo struct { type ChannelInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -114,14 +92,12 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -154,8 +130,6 @@ func (database *Database) GetSessionByToken(
nick string nick string
) )
tokenHash := hashToken(token)
err := database.conn.QueryRowContext( err := database.conn.QueryRowContext(
ctx, ctx,
`SELECT s.id, c.id, s.nick `SELECT s.id, c.id, s.nick
@@ -163,7 +137,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s INNER JOIN sessions s
ON s.id = c.session_id ON s.id = c.session_id
WHERE c.token = ?`, WHERE c.token = ?`,
tokenHash, token,
).Scan(&sessionID, &clientID, &nick) ).Scan(&sessionID, &clientID, &nick)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
@@ -517,17 +491,12 @@ func (database *Database) GetSessionChannelIDs(
func (database *Database) InsertMessage( func (database *Database) InsertMessage(
ctx context.Context, ctx context.Context,
command, from, target string, command, from, target string,
params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
if params == nil {
params = json.RawMessage("[]")
}
if body == nil { if body == nil {
body = json.RawMessage("[]") body = json.RawMessage("[]")
} }
@@ -539,10 +508,10 @@ func (database *Database) InsertMessage(
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
params, body, meta, created_at) body, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
string(params), string(body), string(meta), now) string(body), string(meta), now)
if err != nil { if err != nil {
return 0, "", fmt.Errorf( return 0, "", fmt.Errorf(
"insert message: %w", err, "insert message: %w", err,
@@ -609,7 +578,7 @@ func (database *Database) PollMessages(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT cq.id, m.uuid, m.command, `SELECT cq.id, m.uuid, m.command,
m.msg_from, m.msg_to, m.msg_from, m.msg_to,
m.params, m.body, m.meta, m.created_at m.body, m.meta, m.created_at
FROM client_queues cq FROM client_queues cq
INNER JOIN messages m INNER JOIN messages m
ON m.id = cq.message_id ON m.id = cq.message_id
@@ -673,7 +642,7 @@ func (database *Database) queryHistory(
if beforeID > 0 { if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at msg_to, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -690,7 +659,7 @@ func (database *Database) queryHistory(
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at msg_to, body, meta, created_at
FROM messages FROM messages
WHERE msg_to = ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
@@ -717,14 +686,14 @@ func scanMessages(
var ( var (
msg IRCMessage msg IRCMessage
qID int64 qID int64
params, body, meta string body, meta string
createdAt time.Time createdAt time.Time
) )
err := rows.Scan( err := rows.Scan(
&qID, &msg.ID, &msg.Command, &qID, &msg.ID, &msg.Command,
&msg.From, &msg.To, &msg.From, &msg.To,
&params, &body, &meta, &createdAt, &body, &meta, &createdAt,
) )
if err != nil { if err != nil {
return nil, fallbackQID, fmt.Errorf( return nil, fallbackQID, fmt.Errorf(
@@ -732,25 +701,12 @@ func scanMessages(
) )
} }
if params != "" && params != "[]" {
msg.Params = json.RawMessage(params)
}
msg.Body = json.RawMessage(body) msg.Body = json.RawMessage(body)
msg.Meta = json.RawMessage(meta) msg.Meta = json.RawMessage(meta)
msg.TS = createdAt.Format(time.RFC3339Nano) msg.TS = createdAt.Format(time.RFC3339Nano)
msg.DBID = qID msg.DBID = qID
lastQID = qID lastQID = qID
if isNumericCode(msg.Command) {
code, _ := strconv.Atoi(msg.Command)
msg.Code = code
if mt, err := irc.FromInt(code); err == nil {
msg.Command = mt.Name()
}
}
msgs = append(msgs, msg) msgs = append(msgs, msg)
} }
@@ -987,428 +943,3 @@ func (database *Database) GetSessionChannels(
return scanChannels(rows) return scanChannels(rows)
} }
// GetChannelCount returns the total number of channels.
func (database *Database) GetChannelCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM channels",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get channel count: %w", err,
)
}
return count, nil
}
// ChannelInfoFull contains extended channel information.
type ChannelInfoFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
MemberCount int64 `json:"memberCount"`
}
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a
// channel.
func (database *Database) GetChannelCreatedAt(
ctx context.Context,
channelID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM channels WHERE id = ?",
channelID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get channel created_at: %w", err,
)
}
return createdAt, nil
}
// GetSessionCreatedAt returns the creation time of a
// session.
func (database *Database) GetSessionCreatedAt(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM sessions WHERE id = ?",
sessionID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session created_at: %w", err,
)
}
return createdAt, nil
}
// SetAway sets the away message for a session.
// An empty message clears the away status.
func (database *Database) SetAway(
ctx context.Context,
sessionID int64,
message string,
) error {
_, err := database.conn.ExecContext(ctx,
"UPDATE sessions SET away_message = ? WHERE id = ?",
message, sessionID)
if err != nil {
return fmt.Errorf("set away: %w", err)
}
return nil
}
// GetAway returns the away message for a session.
// Returns an empty string if the user is not away.
func (database *Database) GetAway(
ctx context.Context,
sessionID int64,
) (string, error) {
var msg string
err := database.conn.QueryRowContext(ctx,
"SELECT away_message FROM sessions WHERE id = ?",
sessionID,
).Scan(&msg)
if err != nil {
return "", fmt.Errorf("get away: %w", err)
}
return msg, nil
}
// SetTopicMeta sets the topic along with who set it and
// when.
func (database *Database) SetTopicMeta(
ctx context.Context,
channelName, topic, setBy string,
) error {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET topic = ?, topic_set_by = ?,
topic_set_at = ?, updated_at = ?
WHERE name = ?`,
topic, setBy, now, now, channelName)
if err != nil {
return fmt.Errorf("set topic meta: %w", err)
}
return nil
}
// TopicMeta holds topic metadata for a channel.
type TopicMeta struct {
SetBy string
SetAt time.Time
}
// GetTopicMeta returns who set the topic and when.
func (database *Database) GetTopicMeta(
ctx context.Context,
channelID int64,
) (*TopicMeta, error) {
var (
setBy string
setAt sql.NullTime
)
err := database.conn.QueryRowContext(ctx,
`SELECT topic_set_by, topic_set_at
FROM channels WHERE id = ?`,
channelID,
).Scan(&setBy, &setAt)
if err != nil {
return nil, fmt.Errorf(
"get topic meta: %w", err,
)
}
if setBy == "" || !setAt.Valid {
return nil, nil //nolint:nilnil
}
return &TopicMeta{
SetBy: setBy,
SetAt: setAt.Time,
}, nil
}
// GetSessionLastSeen returns the last_seen time for a
// session.
func (database *Database) GetSessionLastSeen(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var lastSeen time.Time
err := database.conn.QueryRowContext(ctx,
"SELECT last_seen FROM sessions WHERE id = ?",
sessionID,
).Scan(&lastSeen)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session last_seen: %w", err,
)
}
return lastSeen, nil
}
// PruneOldQueueEntries deletes client output queue entries
// older than cutoff and returns the number of rows removed.
func (database *Database) PruneOldQueueEntries(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM client_queues WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old client output queue entries: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// PruneOldMessages deletes messages older than cutoff and
// returns the number of rows removed.
func (database *Database) PruneOldMessages(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM messages WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune old messages: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}
// GetClientCount returns the total number of clients.
func (database *Database) GetClientCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM clients",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get client count: %w", err,
)
}
return count, nil
}
// GetQueueEntryCount returns the total number of entries
// in the client output queues.
func (database *Database) GetQueueEntryCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM client_queues",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get queue entry count: %w", err,
)
}
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits(
ctx context.Context,
channelID int64,
) (int, error) {
var bits int
err := database.conn.QueryRowContext(
ctx,
"SELECT hashcash_bits FROM channels WHERE id = ?",
channelID,
).Scan(&bits)
if err != nil {
return 0, fmt.Errorf(
"get channel hashcash bits: %w", err,
)
}
return bits, nil
}
// SetChannelHashcashBits sets the hashcash difficulty
// requirement for a channel. A value of 0 disables the
// requirement.
func (database *Database) SetChannelHashcashBits(
ctx context.Context,
channelID int64,
bits int,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET hashcash_bits = ?, updated_at = ?
WHERE id = ?`,
bits, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel hashcash bits: %w", err,
)
}
return nil
}
// RecordSpentHashcash stores a spent hashcash stamp hash
// for replay prevention.
func (database *Database) RecordSpentHashcash(
ctx context.Context,
stampHash string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO spent_hashcash
(stamp_hash, created_at)
VALUES (?, ?)`,
stampHash, time.Now())
if err != nil {
return fmt.Errorf(
"record spent hashcash: %w", err,
)
}
return nil
}
// IsHashcashSpent checks whether a hashcash stamp hash
// has already been used.
func (database *Database) IsHashcashSpent(
ctx context.Context,
stampHash string,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM spent_hashcash
WHERE stamp_hash = ?`,
stampHash,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check spent hashcash: %w", err,
)
}
return count > 0, nil
}
// PruneSpentHashcash deletes spent hashcash tokens older
// than the cutoff and returns the number of rows removed.
func (database *Database) PruneSpentHashcash(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM spent_hashcash WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune spent hashcash: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}

View File

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

View File

@@ -8,7 +8,6 @@ CREATE TABLE IF NOT EXISTS sessions (
nick TEXT NOT NULL UNIQUE, nick TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -31,9 +30,6 @@ CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL DEFAULT '', topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -54,7 +50,6 @@ CREATE TABLE IF NOT EXISTS messages (
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '',
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -62,14 +57,6 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL)
CREATE TABLE IF NOT EXISTS spent_hashcash (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_hash TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
-- Per-client message queues for fan-out delivery -- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues ( CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,10 +13,8 @@ import (
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "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/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -31,15 +29,9 @@ type Params struct {
Config *config.Config Config *config.Config
Database *db.Database Database *db.Database
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
} }
const defaultIdleTimeout = 30 * 24 * time.Hour const defaultIdleTimeout = 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
@@ -47,9 +39,6 @@ type Handlers struct {
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -58,19 +47,11 @@ func New(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
params Params, params Params,
) (*Handlers, error) { ) (*Handlers, error) {
resource := params.Config.ServerName
if resource == "" {
resource = "neoirc"
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -219,93 +200,4 @@ func (hdlr *Handlers) runCleanup(
"deleted", deleted, "deleted", deleted,
) )
} }
hdlr.pruneQueuesAndMessages(ctx)
}
// parseDurationConfig parses a Go duration string,
// returning zero on empty input and logging on error.
func (hdlr *Handlers) parseDurationConfig(
name, raw string,
) time.Duration {
if raw == "" {
return 0
}
dur, err := time.ParseDuration(raw)
if err != nil {
hdlr.log.Error(
"invalid duration config, skipping",
"name", name, "value", raw, "error", err,
)
return 0
}
return dur
}
// pruneQueuesAndMessages removes old client output queue
// entries per QUEUE_MAX_AGE and old messages per
// MESSAGE_MAX_AGE.
func (hdlr *Handlers) pruneQueuesAndMessages(
ctx context.Context,
) {
queueMaxAge := hdlr.parseDurationConfig(
"QUEUE_MAX_AGE",
hdlr.params.Config.QueueMaxAge,
)
if queueMaxAge > 0 {
queueCutoff := time.Now().Add(-queueMaxAge)
pruned, err := hdlr.params.Database.
PruneOldQueueEntries(ctx, queueCutoff)
if err != nil {
hdlr.log.Error(
"client output queue pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old client output queue entries",
"deleted", pruned,
)
}
}
messageMaxAge := hdlr.parseDurationConfig(
"MESSAGE_MAX_AGE",
hdlr.params.Config.MessageMaxAge,
)
if messageMaxAge > 0 {
msgCutoff := time.Now().Add(-messageMaxAge)
pruned, err := hdlr.params.Database.
PruneOldMessages(ctx, msgCutoff)
if err != nil {
hdlr.log.Error(
"message pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned old messages",
"deleted", pruned,
)
}
}
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
} }

View File

@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
resp := hdlr.hc.Healthcheck(request.Context()) resp := hdlr.hc.Healthcheck()
hdlr.respondJSON(writer, request, resp, httpStatusOK) hdlr.respondJSON(writer, request, resp, httpStatusOK)
} }
} }

View File

@@ -1,186 +0,0 @@
package hashcash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errBodyHashMismatch = errors.New(
"body hash mismatch",
)
errBodyHashMissing = errors.New(
"body hash missing",
)
)
// ChannelValidator checks hashcash stamps for
// per-channel PRIVMSG validation. It verifies that
// stamps are bound to a specific channel and message
// body. Replay prevention is handled externally via
// the database spent_hashcash table for persistence
// across server restarts (1-year TTL).
type ChannelValidator struct{}
// NewChannelValidator creates a ChannelValidator.
func NewChannelValidator() *ChannelValidator {
return &ChannelValidator{}
}
// BodyHash computes the hex-encoded SHA-256 hash of a
// message body for use in hashcash stamp validation.
func BodyHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// ValidateStamp checks a channel hashcash stamp. It
// verifies the stamp format, difficulty, date, channel
// binding, body hash binding, and proof-of-work. Replay
// detection is NOT performed here — callers must check
// the spent_hashcash table separately.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func (cv *ChannelValidator) ValidateStamp(
stamp string,
requiredBits int,
channel string,
bodyHash string,
) 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]
stampBodyHash := parts[4]
headerErr := validateChannelHeader(
version, bitsStr, resource,
requiredBits, channel,
)
if headerErr != nil {
return headerErr
}
stampTime, parseErr := parseStampDate(dateStr)
if parseErr != nil {
return parseErr
}
timeErr := validateTime(stampTime)
if timeErr != nil {
return timeErr
}
bodyErr := validateBodyHash(
stampBodyHash, bodyHash,
)
if bodyErr != nil {
return bodyErr
}
return validateProof(stamp, requiredBits)
}
// StampHash returns a deterministic hash of a stamp
// string for use as a spent-token key.
func StampHash(stamp string) string {
hash := sha256.Sum256([]byte(stamp))
return hex.EncodeToString(hash[:])
}
func validateChannelHeader(
version, bitsStr, resource string,
requiredBits int,
channel string,
) 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 != channel {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, channel,
)
}
return nil
}
func validateBodyHash(
stampBodyHash, expectedBodyHash string,
) error {
if stampBodyHash == "" {
return errBodyHashMissing
}
if stampBodyHash != expectedBodyHash {
return fmt.Errorf(
"%w: got %q, want %q",
errBodyHashMismatch,
stampBodyHash, expectedBodyHash,
)
}
return nil
}
// MintChannelStamp computes a channel hashcash stamp
// with the given difficulty, channel name, and body hash.
// This is intended for clients to generate stamps before
// sending PRIVMSG to hashcash-protected channels.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func MintChannelStamp(
bits int,
channel string,
bodyHash string,
) string {
date := time.Now().UTC().Format(dateFormatShort)
prefix := fmt.Sprintf(
"1:%d:%s:%s:%s:",
bits, date, channel, bodyHash,
)
counter := uint64(0)
for {
stamp := prefix + strconv.FormatUint(counter, 16)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
counter++
}
}

View File

@@ -1,244 +0,0 @@
package hashcash_test
import (
"crypto/sha256"
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
testChannel = "#general"
testBodyText = `["hello world"]`
)
func testBodyHash() string {
hash := sha256.Sum256([]byte(testBodyText))
return hex.EncodeToString(hash[:])
}
func TestChannelValidateHappyPath(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("valid channel stamp rejected: %v", err)
}
}
func TestChannelValidateWrongChannel(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, "#other", bodyHash,
)
if err == nil {
t.Fatal("expected channel mismatch error")
}
}
func TestChannelValidateWrongBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
wrongHash := sha256.Sum256([]byte("different body"))
wrongBodyHash := hex.EncodeToString(wrongHash[:])
err := validator.ValidateStamp(
stamp, testBits, testChannel, wrongBodyHash,
)
if err == nil {
t.Fatal("expected body hash mismatch error")
}
}
func TestChannelValidateInsufficientBits(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with 2 bits but require 4.
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, 4, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestChannelValidateZeroBitsSkips(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"garbage", 0, "#ch", "abc",
)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestChannelValidateBadFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"not:valid", testBits, testChannel, "abc",
)
if err == nil {
t.Fatal("expected bad format error")
}
}
func TestChannelValidateBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestChannelValidateExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with a very old date by manually constructing.
stamp := mintStampWithDate(
t, testBits, testChannel, "200101",
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestChannelValidateMissingBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Construct a stamp with empty body hash field.
stamp := mintStampWithDate(
t, testBits, testChannel, todayDate(),
)
// This uses the session-style stamp which has empty
// ext field — body hash is missing.
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected missing body hash error")
}
}
func TestBodyHash(t *testing.T) {
t.Parallel()
body := []byte(`["hello world"]`)
bodyHash := hashcash.BodyHash(body)
if len(bodyHash) != 64 {
t.Fatalf(
"expected 64-char hex hash, got %d",
len(bodyHash),
)
}
// Same input should produce same hash.
bodyHash2 := hashcash.BodyHash(body)
if bodyHash != bodyHash2 {
t.Fatal("body hash not deterministic")
}
// Different input should produce different hash.
bodyHash3 := hashcash.BodyHash([]byte("different"))
if bodyHash == bodyHash3 {
t.Fatal("different inputs produced same hash")
}
}
func TestStampHash(t *testing.T) {
t.Parallel()
hash1 := hashcash.StampHash("stamp1")
hash2 := hashcash.StampHash("stamp2")
if hash1 == hash2 {
t.Fatal("different stamps produced same hash")
}
// Same input should be deterministic.
hash1b := hashcash.StampHash("stamp1")
if hash1 != hash1b {
t.Fatal("stamp hash not deterministic")
}
}
func TestMintChannelStamp(t *testing.T) {
t.Parallel()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
if stamp == "" {
t.Fatal("expected non-empty stamp")
}
// Validate the minted stamp.
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("minted stamp failed validation: %v", err)
}
}

View File

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

View File

@@ -1,261 +0,0 @@
package hashcash_test
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const testBits = 2
// mintStampWithDate creates a valid hashcash stamp using
// the given date string.
func mintStampWithDate(
tb testing.TB,
bits int,
resource string,
date string,
) string {
tb.Helper()
prefix := fmt.Sprintf(
"1:%d:%s:%s::", bits, date, resource,
)
for {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(1<<48),
)
if err != nil {
tb.Fatalf("random counter: %v", err)
}
stamp := prefix + hex.EncodeToString(
counterVal.Bytes(),
)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
}
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits. Duplicated here for test minting.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / 8
remainBits := numBits % 8
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(0xFF << (8 - remainBits))
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
func todayDate() string {
return time.Now().UTC().Format("060102")
}
func TestMintAndValidate(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
func TestReplayDetection(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("first use failed: %v", err)
}
err = validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
}
func TestResourceMismatch(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("correct-resource")
stamp := mintStampWithDate(
t, testBits, "wrong-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected resource mismatch error")
}
}
func TestInvalidStampFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate(
"not:a:valid:stamp", testBits,
)
if err == nil {
t.Fatal("expected error for bad format")
}
}
func TestBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"2:%d:%s:%s::abc123",
testBits, todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestInsufficientDifficulty(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
// Claimed bits=1, but we require testBits=2.
stamp := fmt.Sprintf(
"1:1:%s:%s::counter",
todayDate(), "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
oldDate := time.Now().Add(-72 * time.Hour).
UTC().Format("060102")
stamp := mintStampWithDate(
t, testBits, "test-resource", oldDate,
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestZeroBitsSkipsValidation(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestLongDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
longDate := time.Now().UTC().Format("060102150405")
stamp := mintStampWithDate(
t, testBits, "test-resource", longDate,
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("long date stamp rejected: %v", err)
}
}
func TestBadDateFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := fmt.Sprintf(
"1:%d:BADDATE:%s::counter",
testBits, "test-resource",
)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad date error")
}
}
func TestMultipleUniqueStamps(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
for range 5 {
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("unique stamp rejected: %v", err)
}
}
}
func TestHigherBitsStillValid(t *testing.T) {
t.Parallel()
// Mint with bits=4 but validate requiring only 2.
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, 4, "test-resource", todayDate(),
)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf(
"higher-difficulty stamp rejected: %v",
err,
)
}
}

View File

@@ -10,7 +10,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -22,7 +21,6 @@ type Params struct {
Config *config.Config Config *config.Config
Logger *logger.Logger Logger *logger.Logger
Database *db.Database Database *db.Database
Stats *stats.Tracker
} }
// Healthcheck tracks server uptime and provides health status. // Healthcheck tracks server uptime and provides health status.
@@ -66,22 +64,11 @@ type Response struct {
Version string `json:"version"` Version string `json:"version"`
Appname string `json:"appname"` Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"` Maintenance bool `json:"maintenanceMode"`
// Runtime statistics.
Sessions int64 `json:"sessions"`
Clients int64 `json:"clients"`
QueuedLines int64 `json:"queuedLines"`
Channels int64 `json:"channels"`
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
} }
// Healthcheck returns the current health status of the server. // Healthcheck returns the current health status of the server.
func (hcheck *Healthcheck) Healthcheck( func (hcheck *Healthcheck) Healthcheck() *Response {
ctx context.Context, return &Response{
) *Response {
resp := &Response{
Status: "ok", Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano), Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(hcheck.uptime().Seconds()), UptimeSeconds: int64(hcheck.uptime().Seconds()),
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
Appname: hcheck.params.Globals.Appname, Appname: hcheck.params.Globals.Appname,
Version: hcheck.params.Globals.Version, Version: hcheck.params.Globals.Version,
Maintenance: hcheck.params.Config.MaintenanceMode, Maintenance: hcheck.params.Config.MaintenanceMode,
Sessions: 0,
Clients: 0,
QueuedLines: 0,
Channels: 0,
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
}
hcheck.populateDBStats(ctx, resp)
return resp
}
// populateDBStats fills in database-derived counters.
func (hcheck *Healthcheck) populateDBStats(
ctx context.Context,
resp *Response,
) {
sessions, err := hcheck.params.Database.GetUserCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: session count failed",
"error", err,
)
} else {
resp.Sessions = sessions
}
clients, err := hcheck.params.Database.GetClientCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: client count failed",
"error", err,
)
} else {
resp.Clients = clients
}
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: queue entry count failed",
"error", err,
)
} else {
resp.QueuedLines = queued
}
channels, err := hcheck.params.Database.GetChannelCount(ctx)
if err != nil {
hcheck.log.Error(
"healthcheck: channel count failed",
"error", err,
)
} else {
resp.Channels = channels
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
// Package stats tracks runtime statistics since server boot.
package stats
import (
"sync/atomic"
)
// Tracker holds atomic counters for runtime statistics
// that accumulate since the server started.
type Tracker struct {
connectionsSinceBoot atomic.Int64
sessionsSinceBoot atomic.Int64
messagesSinceBoot atomic.Int64
}
// New creates a new Tracker with all counters at zero.
func New() *Tracker {
return &Tracker{} //nolint:exhaustruct // atomic fields have zero-value defaults
}
// IncrConnections increments the total connection count.
func (t *Tracker) IncrConnections() {
t.connectionsSinceBoot.Add(1)
}
// IncrSessions increments the total session count.
func (t *Tracker) IncrSessions() {
t.sessionsSinceBoot.Add(1)
}
// IncrMessages increments the total PRIVMSG/NOTICE count.
func (t *Tracker) IncrMessages() {
t.messagesSinceBoot.Add(1)
}
// ConnectionsSinceBoot returns the total number of
// client connections since boot.
func (t *Tracker) ConnectionsSinceBoot() int64 {
return t.connectionsSinceBoot.Load()
}
// SessionsSinceBoot returns the total number of sessions
// created since boot.
func (t *Tracker) SessionsSinceBoot() int64 {
return t.sessionsSinceBoot.Load()
}
// MessagesSinceBoot returns the total number of
// PRIVMSG/NOTICE messages sent since boot.
func (t *Tracker) MessagesSinceBoot() int64 {
return t.messagesSinceBoot.Load()
}

View File

@@ -1,117 +0,0 @@
package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
)
func TestNew(t *testing.T) {
t.Parallel()
tracker := stats.New()
if tracker == nil {
t.Fatal("expected non-nil tracker")
}
if tracker.ConnectionsSinceBoot() != 0 {
t.Errorf(
"expected 0 connections, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 0 {
t.Errorf(
"expected 0 sessions, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 0 {
t.Errorf(
"expected 0 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}
func TestIncrConnections(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrConnections()
tracker.IncrConnections()
got := tracker.ConnectionsSinceBoot()
if got != 3 {
t.Errorf(
"expected 3 connections, got %d", got,
)
}
}
func TestIncrSessions(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrSessions()
tracker.IncrSessions()
got := tracker.SessionsSinceBoot()
if got != 2 {
t.Errorf(
"expected 2 sessions, got %d", got,
)
}
}
func TestIncrMessages(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrMessages()
got := tracker.MessagesSinceBoot()
if got != 1 {
t.Errorf(
"expected 1 message, got %d", got,
)
}
}
func TestCountersAreIndependent(t *testing.T) {
t.Parallel()
tracker := stats.New()
tracker.IncrConnections()
tracker.IncrSessions()
tracker.IncrMessages()
tracker.IncrMessages()
if tracker.ConnectionsSinceBoot() != 1 {
t.Errorf(
"expected 1 connection, got %d",
tracker.ConnectionsSinceBoot(),
)
}
if tracker.SessionsSinceBoot() != 1 {
t.Errorf(
"expected 1 session, got %d",
tracker.SessionsSinceBoot(),
)
}
if tracker.MessagesSinceBoot() != 2 {
t.Errorf(
"expected 2 messages, got %d",
tracker.MessagesSinceBoot(),
)
}
}

View File

@@ -1,22 +0,0 @@
package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAway = "AWAY"
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTopic = "TOPIC"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)

View File

@@ -1,391 +0,0 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
import (
"errors"
"fmt"
)
// IRCMessageType represents an IRC numeric reply or error code.
type IRCMessageType int //nolint:revive // Name requested by project owner.
// Name returns the standard IRC name for this numeric code
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
// Returns an empty string if the code is unknown.
func (t IRCMessageType) Name() string {
return names[t]
}
// String returns the name and numeric code in angle brackets
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
// If the code is unknown, returns "UNKNOWN <NNN>".
func (t IRCMessageType) String() string {
n := names[t]
if n == "" {
n = "UNKNOWN"
}
return fmt.Sprintf("%s <%03d>", n, int(t))
}
// Code returns the three-digit zero-padded string representation
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
func (t IRCMessageType) Code() string {
return fmt.Sprintf("%03d", int(t))
}
// Int returns the bare integer value of the numeric code.
func (t IRCMessageType) Int() int {
return int(t)
}
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
// FromInt converts an integer to an IRCMessageType, returning an error
// if the numeric code is not a known IRC reply or error code.
func FromInt(n int) (IRCMessageType, error) {
t := IRCMessageType(n)
if _, ok := names[t]; !ok {
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
}
return t, nil
}
// Connection registration replies (001-005).
const (
RplWelcome IRCMessageType = 1
RplYourHost IRCMessageType = 2
RplCreated IRCMessageType = 3
RplMyInfo IRCMessageType = 4
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
)
// Command responses (200-399).
const (
// RFC 2812 trace/stats/links replies (200-219).
RplTraceLink IRCMessageType = 200
RplTraceConnecting IRCMessageType = 201
RplTraceHandshake IRCMessageType = 202
RplTraceUnknown IRCMessageType = 203
RplTraceOperator IRCMessageType = 204
RplTraceUser IRCMessageType = 205
RplTraceServer IRCMessageType = 206
RplTraceService IRCMessageType = 207
RplTraceNewType IRCMessageType = 208
RplTraceClass IRCMessageType = 209
RplStatsLinkInfo IRCMessageType = 211
RplStatsCommands IRCMessageType = 212
RplStatsCLine IRCMessageType = 213
RplStatsNLine IRCMessageType = 214
RplStatsILine IRCMessageType = 215
RplStatsKLine IRCMessageType = 216
RplStatsQLine IRCMessageType = 217
RplStatsYLine IRCMessageType = 218
RplEndOfStats IRCMessageType = 219
RplUmodeIs IRCMessageType = 221
RplServList IRCMessageType = 234
RplServListEnd IRCMessageType = 235
RplStatsLLine IRCMessageType = 241
RplStatsUptime IRCMessageType = 242
RplStatsOLine IRCMessageType = 243
RplStatsHLine IRCMessageType = 244
RplLuserClient IRCMessageType = 251
RplLuserOp IRCMessageType = 252
RplLuserUnknown IRCMessageType = 253
RplLuserChannels IRCMessageType = 254
RplLuserMe IRCMessageType = 255
RplAdminMe IRCMessageType = 256
RplAdminLoc1 IRCMessageType = 257
RplAdminLoc2 IRCMessageType = 258
RplAdminEmail IRCMessageType = 259
RplTraceLog IRCMessageType = 261
RplTraceEnd IRCMessageType = 262
RplTryAgain IRCMessageType = 263
RplAway IRCMessageType = 301
RplUserHost IRCMessageType = 302
RplIson IRCMessageType = 303
RplUnaway IRCMessageType = 305
RplNowAway IRCMessageType = 306
RplWhoisUser IRCMessageType = 311
RplWhoisServer IRCMessageType = 312
RplWhoisOperator IRCMessageType = 313
RplWhoWasUser IRCMessageType = 314
RplEndOfWho IRCMessageType = 315
RplWhoisIdle IRCMessageType = 317
RplEndOfWhois IRCMessageType = 318
RplWhoisChannels IRCMessageType = 319
RplListStart IRCMessageType = 321
RplList IRCMessageType = 322
RplListEnd IRCMessageType = 323
RplChannelModeIs IRCMessageType = 324
RplUniqOpIs IRCMessageType = 325
RplCreationTime IRCMessageType = 329
RplNoTopic IRCMessageType = 331
RplTopic IRCMessageType = 332
RplTopicWhoTime IRCMessageType = 333
RplInviting IRCMessageType = 341
RplSummoning IRCMessageType = 342
RplInviteList IRCMessageType = 346
RplEndOfInviteList IRCMessageType = 347
RplExceptList IRCMessageType = 348
RplEndOfExceptList IRCMessageType = 349
RplVersion IRCMessageType = 351
RplWhoReply IRCMessageType = 352
RplNamReply IRCMessageType = 353
RplLinks IRCMessageType = 364
RplEndOfLinks IRCMessageType = 365
RplEndOfNames IRCMessageType = 366
RplBanList IRCMessageType = 367
RplEndOfBanList IRCMessageType = 368
RplEndOfWhowas IRCMessageType = 369
RplInfo IRCMessageType = 371
RplMotd IRCMessageType = 372
RplEndOfInfo IRCMessageType = 374
RplMotdStart IRCMessageType = 375
RplEndOfMotd IRCMessageType = 376
RplYoureOper IRCMessageType = 381
RplRehashing IRCMessageType = 382
RplYoureService IRCMessageType = 383
RplTime IRCMessageType = 391
RplUsersStart IRCMessageType = 392
RplUsers IRCMessageType = 393
RplEndOfUsers IRCMessageType = 394
RplNoUsers IRCMessageType = 395
)
// Error replies (400-599).
const (
ErrNoSuchNick IRCMessageType = 401
ErrNoSuchServer IRCMessageType = 402
ErrNoSuchChannel IRCMessageType = 403
ErrCannotSendToChan IRCMessageType = 404
ErrTooManyChannels IRCMessageType = 405
ErrWasNoSuchNick IRCMessageType = 406
ErrTooManyTargets IRCMessageType = 407
ErrNoSuchService IRCMessageType = 408
ErrNoOrigin IRCMessageType = 409
ErrNoRecipient IRCMessageType = 411
ErrNoTextToSend IRCMessageType = 412
ErrNoTopLevel IRCMessageType = 413
ErrWildTopLevel IRCMessageType = 414
ErrBadMask IRCMessageType = 415
ErrUnknownCommand IRCMessageType = 421
ErrNoMotd IRCMessageType = 422
ErrNoAdminInfo IRCMessageType = 423
ErrFileError IRCMessageType = 424
ErrNoNicknameGiven IRCMessageType = 431
ErrErroneusNickname IRCMessageType = 432
ErrNicknameInUse IRCMessageType = 433
ErrNickCollision IRCMessageType = 436
ErrUnavailResource IRCMessageType = 437
ErrUserNotInChannel IRCMessageType = 441
ErrNotOnChannel IRCMessageType = 442
ErrUserOnChannel IRCMessageType = 443
ErrNoLogin IRCMessageType = 444
ErrSummonDisabled IRCMessageType = 445
ErrUsersDisabled IRCMessageType = 446
ErrNotRegistered IRCMessageType = 451
ErrNeedMoreParams IRCMessageType = 461
ErrAlreadyRegistered IRCMessageType = 462
ErrNoPermForHost IRCMessageType = 463
ErrPasswdMismatch IRCMessageType = 464
ErrYoureBannedCreep IRCMessageType = 465
ErrYouWillBeBanned IRCMessageType = 466
ErrKeySet IRCMessageType = 467
ErrChannelIsFull IRCMessageType = 471
ErrUnknownMode IRCMessageType = 472
ErrInviteOnlyChan IRCMessageType = 473
ErrBannedFromChan IRCMessageType = 474
ErrBadChannelKey IRCMessageType = 475
ErrBadChanMask IRCMessageType = 476
ErrNoChanModes IRCMessageType = 477
ErrBanListFull IRCMessageType = 478
ErrNoPrivileges IRCMessageType = 481
ErrChanOpPrivsNeeded IRCMessageType = 482
ErrCantKillServer IRCMessageType = 483
ErrRestricted IRCMessageType = 484
ErrUniqOpPrivsNeeded IRCMessageType = 485
ErrNoOperHost IRCMessageType = 491
ErrUmodeUnknownFlag IRCMessageType = 501
ErrUsersDoNotMatch IRCMessageType = 502
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[IRCMessageType]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplBounce: "RPL_BOUNCE",
RplTraceLink: "RPL_TRACELINK",
RplTraceConnecting: "RPL_TRACECONNECTING",
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
RplTraceUnknown: "RPL_TRACEUNKNOWN",
RplTraceOperator: "RPL_TRACEOPERATOR",
RplTraceUser: "RPL_TRACEUSER",
RplTraceServer: "RPL_TRACESERVER",
RplTraceService: "RPL_TRACESERVICE",
RplTraceNewType: "RPL_TRACENEWTYPE",
RplTraceClass: "RPL_TRACECLASS",
RplStatsLinkInfo: "RPL_STATSLINKINFO",
RplStatsCommands: "RPL_STATSCOMMANDS",
RplStatsCLine: "RPL_STATSCLINE",
RplStatsNLine: "RPL_STATSNLINE",
RplStatsILine: "RPL_STATSILINE",
RplStatsKLine: "RPL_STATSKLINE",
RplStatsQLine: "RPL_STATSQLINE",
RplStatsYLine: "RPL_STATSYLINE",
RplEndOfStats: "RPL_ENDOFSTATS",
RplUmodeIs: "RPL_UMODEIS",
RplServList: "RPL_SERVLIST",
RplServListEnd: "RPL_SERVLISTEND",
RplStatsLLine: "RPL_STATSLLINE",
RplStatsUptime: "RPL_STATSUPTIME",
RplStatsOLine: "RPL_STATSOLINE",
RplStatsHLine: "RPL_STATSHLINE",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAdminMe: "RPL_ADMINME",
RplAdminLoc1: "RPL_ADMINLOC1",
RplAdminLoc2: "RPL_ADMINLOC2",
RplAdminEmail: "RPL_ADMINEMAIL",
RplTraceLog: "RPL_TRACELOG",
RplTraceEnd: "RPL_TRACEEND",
RplTryAgain: "RPL_TRYAGAIN",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplWhoWasUser: "RPL_WHOWASUSER",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplListStart: "RPL_LISTSTART",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplUniqOpIs: "RPL_UNIQOPIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplSummoning: "RPL_SUMMONING",
RplInviteList: "RPL_INVITELIST",
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
RplExceptList: "RPL_EXCEPTLIST",
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
RplVersion: "RPL_VERSION",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplLinks: "RPL_LINKS",
RplEndOfLinks: "RPL_ENDOFLINKS",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
RplInfo: "RPL_INFO",
RplMotd: "RPL_MOTD",
RplEndOfInfo: "RPL_ENDOFINFO",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
RplYoureOper: "RPL_YOUREOPER",
RplRehashing: "RPL_REHASHING",
RplYoureService: "RPL_YOURESERVICE",
RplTime: "RPL_TIME",
RplUsersStart: "RPL_USERSSTART",
RplUsers: "RPL_USERS",
RplEndOfUsers: "RPL_ENDOFUSERS",
RplNoUsers: "RPL_NOUSERS",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
ErrNoSuchService: "ERR_NOSUCHSERVICE",
ErrNoOrigin: "ERR_NOORIGIN",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrNoTopLevel: "ERR_NOTOPLEVEL",
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
ErrBadMask: "ERR_BADMASK",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoMotd: "ERR_NOMOTD",
ErrNoAdminInfo: "ERR_NOADMININFO",
ErrFileError: "ERR_FILEERROR",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrNickCollision: "ERR_NICKCOLLISION",
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrUserOnChannel: "ERR_USERONCHANNEL",
ErrNoLogin: "ERR_NOLOGIN",
ErrSummonDisabled: "ERR_SUMMONDISABLED",
ErrUsersDisabled: "ERR_USERSDISABLED",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrNoPermForHost: "ERR_NOPERMFORHOST",
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
ErrKeySet: "ERR_KEYSET",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrUnknownMode: "ERR_UNKNOWNMODE",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrBadChanMask: "ERR_BADCHANMASK",
ErrNoChanModes: "ERR_NOCHANMODES",
ErrBanListFull: "ERR_BANLISTFULL",
ErrNoPrivileges: "ERR_NOPRIVILEGES",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
ErrCantKillServer: "ERR_CANTKILLSERVER",
ErrRestricted: "ERR_RESTRICTED",
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
ErrNoOperHost: "ERR_NOOPERHOST",
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
//
// Deprecated: Use IRCMessageType.Name() instead.
func Name(code IRCMessageType) string {
return names[code]
}

View File

@@ -1,163 +0,0 @@
package irc_test
import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
func TestName(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME"},
{irc.RplBounce, "RPL_BOUNCE"},
{irc.RplLuserOp, "RPL_LUSEROP"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
}
for _, tc := range tests {
if got := tc.numeric.Name(); got != tc.want {
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestString(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "RPL_WELCOME <001>"},
{irc.RplBounce, "RPL_BOUNCE <005>"},
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
}
for _, tc := range tests {
if got := tc.numeric.String(); got != tc.want {
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestCode(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want string
}{
{irc.RplWelcome, "001"},
{irc.RplBounce, "005"},
{irc.RplLuserOp, "252"},
{irc.ErrCannotSendToChan, "404"},
}
for _, tc := range tests {
if got := tc.numeric.Code(); got != tc.want {
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
}
}
}
func TestInt(t *testing.T) {
t.Parallel()
tests := []struct {
numeric irc.IRCMessageType
want int
}{
{irc.RplWelcome, 1},
{irc.RplBounce, 5},
{irc.RplLuserOp, 252},
{irc.ErrCannotSendToChan, 404},
}
for _, tc := range tests {
if got := tc.numeric.Int(); got != tc.want {
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
}
}
}
func TestFromInt_Known(t *testing.T) {
t.Parallel()
tests := []struct {
code int
want irc.IRCMessageType
}{
{1, irc.RplWelcome},
{5, irc.RplBounce},
{252, irc.RplLuserOp},
{404, irc.ErrCannotSendToChan},
{433, irc.ErrNicknameInUse},
}
for _, test := range tests {
got, err := irc.FromInt(test.code)
if err != nil {
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
continue
}
if got != test.want {
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
}
}
}
func TestFromInt_Unknown(t *testing.T) {
t.Parallel()
unknowns := []int{0, 999, 600, -1}
for _, code := range unknowns {
_, err := irc.FromInt(code)
if err == nil {
t.Errorf("FromInt(%d) expected error, got nil", code)
continue
}
if !errors.Is(err, irc.ErrUnknownNumeric) {
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
}
}
}
func TestUnknownNumeric_Name(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
if got := unknown.Name(); got != "" {
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
}
}
func TestUnknownNumeric_String(t *testing.T) {
t.Parallel()
unknown := irc.IRCMessageType(999)
want := "UNKNOWN <999>"
if got := unknown.String(); got != want {
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
}
}
func TestDeprecatedNameFunc(t *testing.T) {
t.Parallel()
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
}
}

2
web/dist/app.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/dist/index.html vendored Normal file
View File

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

466
web/dist/style.css vendored Normal file
View File

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

View File

@@ -8,56 +8,6 @@ const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION "; const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01"; 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 = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token"); const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
@@ -110,22 +60,18 @@ function LoginScreen({ onLogin }) {
const [motd, setMotd] = useState(""); const [motd, setMotd] = useState("");
const [serverName, setServerName] = useState("NeoIRC"); const [serverName, setServerName] = useState("NeoIRC");
const inputRef = useRef(); const inputRef = useRef();
const hashcashBitsRef = useRef(0);
const hashcashResourceRef = useRef("neoirc");
useEffect(() => { useEffect(() => {
api("/server") api("/server")
.then((s) => { .then((s) => {
if (s.name) setServerName(s.name); if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd); if (s.motd) setMotd(s.motd);
hashcashBitsRef.current = s.hashcash_bits || 0;
if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
const saved = localStorage.getItem("neoirc_token"); const saved = localStorage.getItem("neoirc_token");
if (saved) { if (saved) {
api("/state?initChannelState=1") api("/state")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick))
.catch(() => localStorage.removeItem("neoirc_token")); .catch(() => localStorage.removeItem("neoirc_token"));
} }
inputRef.current?.focus(); inputRef.current?.focus();
@@ -135,22 +81,9 @@ function LoginScreen({ onLogin }) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { 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.pow_token = hashcashStamp;
}
const res = await api("/session", { const res = await api("/session", {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify({ nick: nick.trim() }),
}); });
localStorage.setItem("neoirc_token", res.token); localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
@@ -400,24 +333,7 @@ function App() {
case "JOIN": { case "JOIN": {
const text = `${msg.from} has joined ${msg.to}`; const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) { if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
// Create a tab when the current user joins a channel
// (including JOINs from initChannelState on reconnect).
if (msg.from === nickRef.current) {
setTabs((prev) => {
if (
prev.find(
(t) => t.type === "channel" && t.name === msg.to,
)
)
return prev;
return [...prev, { type: "channel", name: msg.to }];
});
}
refreshMembers(msg.to);
}
break; break;
} }
@@ -503,100 +419,6 @@ function App() {
break; break;
} }
case "322": {
// RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0];
const count = msg.params[1];
const chTopic = body || "";
addMessage("Server", {
...base,
text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true,
});
}
break;
}
case "323":
addMessage("Server", {
...base,
text: body || "End of channel list",
system: true,
});
break;
case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0];
const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage("Server", {
...base,
text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true,
});
}
break;
}
case "315":
addMessage("Server", {
...base,
text: body || "End of /WHO list",
system: true,
});
break;
case "311": {
// RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} (${body})`,
system: true,
});
}
break;
}
case "312": {
// RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
const wiServer = msg.params[1];
addMessage("Server", {
...base,
text: `${wiNick} on ${wiServer}`,
system: true,
});
}
break;
}
case "319": {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} is on: ${body}`,
system: true,
});
}
break;
}
case "318":
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
system: true,
});
break;
case "375": case "375":
case "372": case "372":
case "376": case "376":
@@ -675,31 +497,6 @@ function App() {
inputRef.current?.focus(); inputRef.current?.focus();
}, [activeTab]); }, [activeTab]);
// Global keyboard handler — capture '/' to prevent
// Firefox quick search and redirect focus to the input.
useEffect(() => {
const handleGlobalKeyDown = (e) => {
if (
e.key === "/" &&
document.activeElement !== inputRef.current &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey
) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleGlobalKeyDown);
// Also focus input on initial mount.
inputRef.current?.focus();
return () =>
document.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
// Fetch topic for active channel. // Fetch topic for active channel.
useEffect(() => { useEffect(() => {
if (!loggedIn) return; if (!loggedIn) return;
@@ -715,31 +512,10 @@ function App() {
}, [loggedIn, activeTab, tabs]); }, [loggedIn, activeTab, tabs]);
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick) => {
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
// get it automatically from the server during
// creation). Channel state is initialized by the
// server via the message queue
// (?initChannelState=1), so we do not need to
// re-JOIN channels here.
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "MOTD" }),
});
} catch (e) {
// MOTD is non-critical.
}
return;
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse( const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]", localStorage.getItem("neoirc_channels") || "[]",
); );
@@ -1015,125 +791,16 @@ function App() {
break; break;
} }
case "/motd": {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "MOTD" }),
});
} catch (err) {
addSystemMessage(
"Server",
`Failed to request MOTD: ${err.data?.error || "error"}`,
);
}
break;
}
case "/query": {
if (parts[1]) {
const target = parts[1];
openDM(target);
const msgText = parts.slice(2).join(" ");
if (msgText) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "PRIVMSG",
to: target,
body: [msgText],
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Message failed: ${err.data?.error || "error"}`,
);
}
}
} else {
addSystemMessage("Server", "Usage: /query <nick> [message]");
}
break;
}
case "/list": {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "LIST" }),
});
} catch (err) {
addSystemMessage(
"Server",
`Failed to list channels: ${err.data?.error || "error"}`,
);
}
break;
}
case "/who": {
const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
if (whoTarget) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHO", to: whoTarget }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHO failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /who #channel");
}
break;
}
case "/whois": {
if (parts[1]) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHOIS failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /whois <nick>");
}
break;
}
case "/clear": {
const clearTarget = tab.name;
setMessages((prev) => ({ ...prev, [clearTarget]: [] }));
break;
}
case "/help": { case "/help": {
const helpLines = [ const helpLines = [
"Available commands:", "Available commands:",
" /join #channel — Join a channel", " /join #channel — Join a channel",
" /part [reason] — Part the current channel", " /part [reason] — Part the current channel",
" /msg nick message — Send a private message", " /msg nick message — Send a private message",
" /query nick [message] — Open a DM tab (optionally send a message)",
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode +/-flags — Set channel modes", " /mode +/-flags — Set channel modes",
" /motd — Display the message of the day",
" /list — List all channels",
" /who [#channel] — List users in a channel",
" /whois nick — Show info about a user",
" /clear — Clear messages in the current tab",
" /quit [reason] — Disconnect from server", " /quit [reason] — Disconnect from server",
" /help — Show this help", " /help — Show this help",
]; ];

View File

@@ -70,14 +70,14 @@ body,
} }
.login-box .motd { .login-box .motd {
color: var(--accent); color: var(--text-dim);
font-size: 11px; font-size: 12px;
margin-bottom: 20px; margin-bottom: 20px;
text-align: left; text-align: left;
white-space: pre; white-space: pre-wrap;
font-family: inherit; font-family: inherit;
line-height: 1.2; border-left: 2px solid var(--border);
overflow-x: auto; padding-left: 12px;
} }
.login-box form { .login-box form {