Compare commits
2 Commits
feature/us
...
761bce32de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761bce32de | ||
|
|
0d0d023a50 |
21
Makefile
21
Makefile
@@ -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
|
||||
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
|
||||
|
||||
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||
# 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
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||
|
||||
lint: ensure-web-dist
|
||||
lint:
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
fmt:
|
||||
@@ -31,8 +20,8 @@ fmt:
|
||||
fmt-check:
|
||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||
|
||||
test: ensure-web-dist
|
||||
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
|
||||
test:
|
||||
go test -timeout 30s -v -race -cover ./...
|
||||
|
||||
# check runs all validation without making changes
|
||||
# Used by CI and Docker build — fails if anything is wrong
|
||||
|
||||
@@ -43,30 +43,13 @@ func NewClient(baseURL string) *Client {
|
||||
}
|
||||
|
||||
// CreateSession creates a new session on the server.
|
||||
// If the server requires hashcash proof-of-work, it
|
||||
// automatically fetches the difficulty and computes a
|
||||
// valid stamp.
|
||||
func (client *Client) CreateSession(
|
||||
nick string,
|
||||
) (*SessionResponse, error) {
|
||||
// Fetch server info to check for hashcash requirement.
|
||||
info, err := client.GetServerInfo()
|
||||
|
||||
var hashcashStamp string
|
||||
|
||||
if err == nil && info.HashcashBits > 0 {
|
||||
resource := info.Name
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
||||
}
|
||||
|
||||
data, err := client.do(
|
||||
http.MethodPost,
|
||||
"/api/v1/session",
|
||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
||||
&SessionRequest{Nick: nick},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -5,7 +5,6 @@ import "time"
|
||||
// SessionRequest is the body for POST /api/v1/session.
|
||||
type SessionRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// SessionResponse is the response from session creation.
|
||||
@@ -67,7 +66,6 @@ type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// MessagesResponse wraps polling results.
|
||||
@@ -1,8 +1,911 @@
|
||||
// Package main is the entry point for the neoirc-cli client.
|
||||
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"
|
||||
"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{}
|
||||
}
|
||||
|
||||
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: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(
|
||||
"[red]Send error: " + err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleCommand(text string) {
|
||||
parts := strings.SplitN(text, " ", splitParts)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
a.dispatchCommand(cmd, args)
|
||||
}
|
||||
|
||||
func (a *App) dispatchCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/connect":
|
||||
a.cmdConnect(args)
|
||||
case "/nick":
|
||||
a.cmdNick(args)
|
||||
case "/join":
|
||||
a.cmdJoin(args)
|
||||
case "/part":
|
||||
a.cmdPart(args)
|
||||
case "/msg":
|
||||
a.cmdMsg(args)
|
||||
case "/query":
|
||||
a.cmdQuery(args)
|
||||
case "/topic":
|
||||
a.cmdTopic(args)
|
||||
case "/window", "/w":
|
||||
a.cmdWindow(args)
|
||||
case "/quit":
|
||||
a.cmdQuit()
|
||||
case "/help":
|
||||
a.cmdHelp()
|
||||
default:
|
||||
a.dispatchInfoCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchInfoCommand(cmd, args string) {
|
||||
switch cmd {
|
||||
case "/names":
|
||||
a.cmdNames()
|
||||
case "/list":
|
||||
a.cmdList()
|
||||
case "/motd":
|
||||
a.cmdMotd()
|
||||
case "/who":
|
||||
a.cmdWho(args)
|
||||
case "/whois":
|
||||
a.cmdWhois(args)
|
||||
default:
|
||||
a.ui.AddStatus(
|
||||
"[red]Unknown command: " + cmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdConnect(serverURL string) {
|
||||
if serverURL == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /connect <server-url>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
|
||||
a.ui.AddStatus("Connecting to " + serverURL + "...")
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
client := api.NewClient(serverURL)
|
||||
|
||||
resp, err := client.CreateSession(nick)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Connection failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.client = client
|
||||
a.nick = resp.Nick
|
||||
a.connected = true
|
||||
a.lastQID = 0
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[green]Connected! Nick: %s, Session: %d",
|
||||
resp.Nick, resp.ID,
|
||||
))
|
||||
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||
|
||||
a.stopPoll = make(chan struct{})
|
||||
|
||||
go a.pollLoop()
|
||||
}
|
||||
|
||||
func (a *App) cmdNick(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /nick <name>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.AddStatus(
|
||||
"Nick set to " + nick +
|
||||
" (will be used on connect)",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Nick change failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.nick = nick
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(nick, target, "connected")
|
||||
a.ui.AddStatus("Nick changed to " + nick)
|
||||
}
|
||||
|
||||
func (a *App) cmdJoin(channel string) {
|
||||
if channel == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /join #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.JoinChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Join failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = channel
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(channel)
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Joined "+channel,
|
||||
)
|
||||
a.ui.SetStatus(nick, channel, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdPart(channel string) {
|
||||
a.mu.Lock()
|
||||
if channel == "" {
|
||||
channel = a.target
|
||||
}
|
||||
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus("[red]No channel to part")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.PartChannel(channel)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Part failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(channel,
|
||||
"[yellow]*** Left "+channel,
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.target == channel {
|
||||
a.target = ""
|
||||
}
|
||||
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchBuffer(0)
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdMsg(args string) {
|
||||
parts := strings.SplitN(args, " ", splitParts)
|
||||
if len(parts) < splitParts {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /msg <nick> <text>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, text := parts[0], parts[1]
|
||||
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Send failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format(timeFormat)
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, nick, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdQuery(nick string) {
|
||||
if nick == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /query <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.target = nick
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SwitchToBuffer(nick)
|
||||
a.ui.SetStatus(myNick, nick, "connected")
|
||||
}
|
||||
|
||||
func (a *App) cmdTopic(args string) {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic query failed: %v", err,
|
||||
))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Topic set failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdNames() {
|
||||
a.mu.Lock()
|
||||
target := a.target
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
a.ui.AddStatus("[red]Not in a channel")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := a.client.GetMembers(target)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]Names failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[cyan]*** Members of %s: %s",
|
||||
target, strings.Join(members, " "),
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) cmdList() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := a.client.ListChannels()
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]List failed: %v", err,
|
||||
))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||
|
||||
for _, ch := range channels {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
" %s (%d members) %s",
|
||||
ch.Name, ch.Members, ch.Topic,
|
||||
))
|
||||
}
|
||||
|
||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||
}
|
||||
|
||||
func (a *App) cmdMotd() {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]MOTD failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWho(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := args
|
||||
if channel == "" {
|
||||
channel = target
|
||||
}
|
||||
|
||||
if channel == "" ||
|
||||
!strings.HasPrefix(channel, "#") {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /who #channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWho, To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHO failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWhois(args string) {
|
||||
a.mu.Lock()
|
||||
connected := a.connected
|
||||
a.mu.Unlock()
|
||||
|
||||
if !connected {
|
||||
a.ui.AddStatus("[red]Not connected")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /whois <nick>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWhois, To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[red]WHOIS failed: %v", err,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdWindow(args string) {
|
||||
if args == "" {
|
||||
a.ui.AddStatus(
|
||||
"[red]Usage: /window <number>",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var bufIndex int
|
||||
|
||||
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
|
||||
|
||||
a.ui.SwitchBuffer(bufIndex)
|
||||
|
||||
a.mu.Lock()
|
||||
nick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
|
||||
buf := a.ui.buffers[bufIndex]
|
||||
if buf.Name != "(status)" {
|
||||
a.mu.Lock()
|
||||
a.target = buf.Name
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(
|
||||
nick, buf.Name, "connected",
|
||||
)
|
||||
} else {
|
||||
a.ui.SetStatus(nick, "", "connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cmdQuit() {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
if a.stopPoll != nil {
|
||||
close(a.stopPoll)
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
a.ui.Stop()
|
||||
}
|
||||
|
||||
func (a *App) cmdHelp() {
|
||||
help := []string{
|
||||
"[cyan]*** neoirc-cli commands:",
|
||||
" /connect <url> — Connect to server",
|
||||
" /nick <name> — Change nickname",
|
||||
" /join #channel — Join channel",
|
||||
" /part [#chan] — Leave channel",
|
||||
" /msg <nick> <text> — Send DM",
|
||||
" /query <nick> — Open DM window",
|
||||
" /topic [text] — View/set topic",
|
||||
" /names — List channel members",
|
||||
" /list — List channels",
|
||||
" /who [#channel] — List users in channel",
|
||||
" /whois <nick> — Show user info",
|
||||
" /motd — Show message of the day",
|
||||
" /window <n> — Switch buffer",
|
||||
" /quit — Disconnect and exit",
|
||||
" /help — This help",
|
||||
" Plain text sends to current target.",
|
||||
}
|
||||
|
||||
for _, line := range help {
|
||||
a.ui.AddStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
// pollLoop long-polls for messages in the background.
|
||||
func (a *App) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-a.stopPoll:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
client := a.client
|
||||
lastQID := a.lastQID
|
||||
a.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.PollMessages(
|
||||
lastQID, pollTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
time.Sleep(pollRetry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if result.LastID > 0 {
|
||||
a.mu.Lock()
|
||||
a.lastQID = result.LastID
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
for i := range result.Messages {
|
||||
a.handleServerMessage(&result.Messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleServerMessage(msg *api.Message) {
|
||||
timestamp := a.formatTS(msg)
|
||||
|
||||
a.mu.Lock()
|
||||
myNick := a.nick
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) formatTS(msg *api.Message) string {
|
||||
if msg.TS != "" {
|
||||
return msg.ParseTS().UTC().Format(timeFormat)
|
||||
}
|
||||
|
||||
return time.Now().Format(timeFormat)
|
||||
}
|
||||
|
||||
func (a *App) handlePrivmsgEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if msg.From == myNick {
|
||||
return
|
||||
}
|
||||
|
||||
target := msg.To
|
||||
if !strings.HasPrefix(target, "#") {
|
||||
target = msg.From
|
||||
}
|
||||
|
||||
a.ui.AddLine(target, fmt.Sprintf(
|
||||
"[gray]%s [green]<%s>[white] %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleJoinEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has joined %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handlePartEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s (%s)",
|
||||
timestamp, msg.From, msg.To, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has left %s",
|
||||
timestamp, msg.From, msg.To,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleQuitEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
reason := strings.Join(lines, " ")
|
||||
|
||||
if reason != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit (%s)",
|
||||
timestamp, msg.From, reason,
|
||||
))
|
||||
} else {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s has quit",
|
||||
timestamp, msg.From,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleNickEvent(
|
||||
msg *api.Message, timestamp, myNick string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
|
||||
newNick := ""
|
||||
if len(lines) > 0 {
|
||||
newNick = lines[0]
|
||||
}
|
||||
|
||||
if msg.From == myNick && newNick != "" {
|
||||
a.mu.Lock()
|
||||
a.nick = newNick
|
||||
|
||||
target := a.target
|
||||
a.mu.Unlock()
|
||||
|
||||
a.ui.SetStatus(newNick, target, "connected")
|
||||
}
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [yellow]*** %s is now known as %s",
|
||||
timestamp, msg.From, newNick,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleNoticeEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [magenta]--%s-- %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleTopicEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
if msg.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
a.ui.AddLine(msg.To, fmt.Sprintf(
|
||||
"[gray]%s [cyan]*** %s set topic: %s",
|
||||
timestamp, msg.From, text,
|
||||
))
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultEvent(
|
||||
msg *api.Message, timestamp string,
|
||||
) {
|
||||
lines := msg.BodyLines()
|
||||
text := strings.Join(lines, " ")
|
||||
|
||||
if text != "" {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
"[gray]%s [white][%s] %s",
|
||||
timestamp, msg.Command, text,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,6 @@ func main() {
|
||||
server.New,
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
stats.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
).Run()
|
||||
|
||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/getsentry/sentry-go v0.42.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/chi v1.5.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -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/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||
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/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||
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/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,6 @@ type Config struct {
|
||||
ServerName string
|
||||
FederationKey string
|
||||
SessionIdleTimeout string
|
||||
HashcashBits int
|
||||
OperName string
|
||||
OperPassword string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -79,9 +76,6 @@ func New(
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@@ -107,9 +101,6 @@ func New(
|
||||
ServerName: viper.GetString("SERVER_NAME"),
|
||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
@@ -10,12 +10,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
||||
var bcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// SetBcryptCost overrides the bcrypt cost.
|
||||
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
||||
func SetBcryptCost(cost int) { bcryptCost = cost }
|
||||
const bcryptCost = bcrypt.DefaultCost
|
||||
|
||||
var errNoPassword = errors.New(
|
||||
"account has no password set",
|
||||
@@ -25,12 +20,8 @@ var errNoPassword = errors.New(
|
||||
// and returns session ID, client ID, and token.
|
||||
func (database *Database) RegisterUser(
|
||||
ctx context.Context,
|
||||
nick, password, username, hostname, remoteIP string,
|
||||
nick, password string,
|
||||
) (int64, int64, string, error) {
|
||||
if username == "" {
|
||||
username = nick
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password), bcryptCost,
|
||||
)
|
||||
@@ -59,11 +50,10 @@ func (database *Database) RegisterUser(
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, username, hostname, ip,
|
||||
password_hash, created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, username, hostname,
|
||||
remoteIP, string(hash), now, now)
|
||||
(uuid, nick, password_hash,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, string(hash), now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -78,11 +68,10 @@ func (database *Database) RegisterUser(
|
||||
|
||||
clientRes, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
(uuid, session_id, token,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -107,7 +96,7 @@ func (database *Database) RegisterUser(
|
||||
// client token.
|
||||
func (database *Database) LoginUser(
|
||||
ctx context.Context,
|
||||
nick, password, remoteIP, hostname string,
|
||||
nick, password string,
|
||||
) (int64, int64, string, error) {
|
||||
var (
|
||||
sessionID int64
|
||||
@@ -154,11 +143,10 @@ func (database *Database) LoginUser(
|
||||
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
(uuid, session_id, token,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf(
|
||||
"create login client: %w", err,
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestRegisterUser(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
database.RegisterUser(ctx, "reguser", "password123", "", "", "")
|
||||
database.RegisterUser(ctx, "reguser", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -38,69 +38,6 @@ func TestRegisterUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserWithUserHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.RegisterUser(
|
||||
ctx, "reguhost", "password123",
|
||||
"myident", "example.org", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := database.GetSessionHostInfo(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Username != "myident" {
|
||||
t.Fatalf(
|
||||
"expected myident, got %s", info.Username,
|
||||
)
|
||||
}
|
||||
|
||||
if info.Hostname != "example.org" {
|
||||
t.Fatalf(
|
||||
"expected example.org, got %s",
|
||||
info.Hostname,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserDefaultUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.RegisterUser(
|
||||
ctx, "regdefault", "password123", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := database.GetSessionHostInfo(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Username != "regdefault" {
|
||||
t.Fatalf(
|
||||
"expected regdefault, got %s",
|
||||
info.Username,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -108,7 +45,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
|
||||
database.RegisterUser(ctx, "dupnick", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -118,7 +55,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||
_ = regToken
|
||||
|
||||
dupSID, dupCID, dupToken, dupErr :=
|
||||
database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
|
||||
database.RegisterUser(ctx, "dupnick", "other12345")
|
||||
if dupErr == nil {
|
||||
t.Fatal("expected error for duplicate nick")
|
||||
}
|
||||
@@ -135,7 +72,7 @@ func TestLoginUser(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
|
||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -145,7 +82,7 @@ func TestLoginUser(t *testing.T) {
|
||||
_ = regToken
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
||||
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -166,83 +103,6 @@ func TestLoginUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserStoresClientIPHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err := database.RegisterUser(
|
||||
ctx, "loginipuser", "password123",
|
||||
"", "", "10.0.0.1",
|
||||
)
|
||||
|
||||
_ = regSID
|
||||
_ = regCID
|
||||
_ = regToken
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, clientID, _, err := database.LoginUser(
|
||||
ctx, "loginipuser", "password123",
|
||||
"10.0.0.99", "newhost.example.com",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientInfo, err := database.GetClientHostInfo(
|
||||
ctx, clientID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if clientInfo.IP != "10.0.0.99" {
|
||||
t.Fatalf(
|
||||
"expected client IP 10.0.0.99, got %s",
|
||||
clientInfo.IP,
|
||||
)
|
||||
}
|
||||
|
||||
if clientInfo.Hostname != "newhost.example.com" {
|
||||
t.Fatalf(
|
||||
"expected hostname newhost.example.com, got %s",
|
||||
clientInfo.Hostname,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserStoresSessionIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.RegisterUser(
|
||||
ctx, "regipuser", "password123",
|
||||
"ident", "host.local", "172.16.0.5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := database.GetSessionHostInfo(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.IP != "172.16.0.5" {
|
||||
t.Fatalf(
|
||||
"expected session IP 172.16.0.5, got %s",
|
||||
info.IP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserWrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -250,7 +110,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
regSID, regCID, regToken, err :=
|
||||
database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
|
||||
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -260,7 +120,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
|
||||
_ = regToken
|
||||
|
||||
loginSID, loginCID, loginToken, loginErr :=
|
||||
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
||||
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||
if loginErr == nil {
|
||||
t.Fatal("expected error for wrong password")
|
||||
}
|
||||
@@ -278,7 +138,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
||||
|
||||
// Create anonymous session (no password).
|
||||
anonSID, anonCID, anonToken, err :=
|
||||
database.CreateSession(ctx, "anon", "", "", "")
|
||||
database.CreateSession(ctx, "anon")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -288,7 +148,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
||||
_ = anonToken
|
||||
|
||||
loginSID, loginCID, loginToken, loginErr :=
|
||||
database.LoginUser(ctx, "anon", "anything1", "", "")
|
||||
database.LoginUser(ctx, "anon", "anything1")
|
||||
if loginErr == nil {
|
||||
t.Fatal(
|
||||
"expected error for login on passwordless account",
|
||||
@@ -307,7 +167,7 @@ func TestLoginUserNonexistent(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
loginSID, loginCID, loginToken, err :=
|
||||
database.LoginUser(ctx, "ghost", "password123", "", "")
|
||||
database.LoginUser(ctx, "ghost", "password123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent user")
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(bcrypt.MinCost)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -74,40 +74,14 @@ type ChannelInfo struct {
|
||||
type MemberInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Nick string `json:"nick"`
|
||||
Username string `json:"username"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// Hostmask returns the IRC hostmask in
|
||||
// nick!user@host format.
|
||||
func (m *MemberInfo) Hostmask() string {
|
||||
return FormatHostmask(m.Nick, m.Username, m.Hostname)
|
||||
}
|
||||
|
||||
// FormatHostmask formats a nick, username, and hostname
|
||||
// into a standard IRC hostmask string (nick!user@host).
|
||||
func FormatHostmask(nick, username, hostname string) string {
|
||||
if username == "" {
|
||||
username = nick
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = "*"
|
||||
}
|
||||
|
||||
return nick + "!" + username + "@" + hostname
|
||||
}
|
||||
|
||||
// CreateSession registers a new session and its first client.
|
||||
func (database *Database) CreateSession(
|
||||
ctx context.Context,
|
||||
nick, username, hostname, remoteIP string,
|
||||
nick string,
|
||||
) (int64, int64, string, error) {
|
||||
if username == "" {
|
||||
username = nick
|
||||
}
|
||||
|
||||
sessionUUID := uuid.New().String()
|
||||
clientUUID := uuid.New().String()
|
||||
|
||||
@@ -127,11 +101,9 @@ func (database *Database) CreateSession(
|
||||
|
||||
res, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO sessions
|
||||
(uuid, nick, username, hostname, ip,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
sessionUUID, nick, username, hostname,
|
||||
remoteIP, now, now)
|
||||
(uuid, nick, created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
sessionUUID, nick, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -146,11 +118,10 @@ func (database *Database) CreateSession(
|
||||
|
||||
clientRes, err := transaction.ExecContext(ctx,
|
||||
`INSERT INTO clients
|
||||
(uuid, session_id, token, ip, hostname,
|
||||
(uuid, session_id, token,
|
||||
created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash,
|
||||
remoteIP, hostname, now, now)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
clientUUID, sessionID, tokenHash, now, now)
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
|
||||
@@ -238,135 +209,6 @@ func (database *Database) GetSessionByNick(
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// SessionHostInfo holds the username, hostname, and IP
|
||||
// for a session.
|
||||
type SessionHostInfo struct {
|
||||
Username string
|
||||
Hostname string
|
||||
IP string
|
||||
}
|
||||
|
||||
// GetSessionHostInfo returns the username, hostname,
|
||||
// and IP for a session.
|
||||
func (database *Database) GetSessionHostInfo(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (*SessionHostInfo, error) {
|
||||
var info SessionHostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT username, hostname, ip
|
||||
FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&info.Username, &info.Hostname, &info.IP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get session host info: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ClientHostInfo holds the IP and hostname for a client.
|
||||
type ClientHostInfo struct {
|
||||
IP string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// GetClientHostInfo returns the IP and hostname for a
|
||||
// client.
|
||||
func (database *Database) GetClientHostInfo(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
) (*ClientHostInfo, error) {
|
||||
var info ClientHostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT ip, hostname
|
||||
FROM clients WHERE id = ?`,
|
||||
clientID,
|
||||
).Scan(&info.IP, &info.Hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get client host info: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// SetSessionOper sets the is_oper flag on a session.
|
||||
func (database *Database) SetSessionOper(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
isOper bool,
|
||||
) error {
|
||||
val := 0
|
||||
if isOper {
|
||||
val = 1
|
||||
}
|
||||
|
||||
_, err := database.conn.ExecContext(
|
||||
ctx,
|
||||
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
|
||||
val, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session oper: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSessionOper returns whether the session has oper
|
||||
// status.
|
||||
func (database *Database) IsSessionOper(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
var isOper int
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT is_oper FROM sessions WHERE id = ?`,
|
||||
sessionID,
|
||||
).Scan(&isOper)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"check session oper: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return isOper != 0, nil
|
||||
}
|
||||
|
||||
// GetLatestClientForSession returns the IP and hostname
|
||||
// of the most recently created client for a session.
|
||||
func (database *Database) GetLatestClientForSession(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (*ClientHostInfo, error) {
|
||||
var info ClientHostInfo
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT ip, hostname FROM clients
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
sessionID,
|
||||
).Scan(&info.IP, &info.Hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"get latest client for session: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// GetChannelByName returns the channel ID for a name.
|
||||
func (database *Database) GetChannelByName(
|
||||
ctx context.Context,
|
||||
@@ -546,8 +388,7 @@ func (database *Database) ChannelMembers(
|
||||
channelID int64,
|
||||
) ([]MemberInfo, error) {
|
||||
rows, err := database.conn.QueryContext(ctx,
|
||||
`SELECT s.id, s.nick, s.username,
|
||||
s.hostname, s.last_seen
|
||||
`SELECT s.id, s.nick, s.last_seen
|
||||
FROM sessions s
|
||||
INNER JOIN channel_members cm
|
||||
ON cm.session_id = s.id
|
||||
@@ -567,9 +408,7 @@ func (database *Database) ChannelMembers(
|
||||
var member MemberInfo
|
||||
|
||||
err = rows.Scan(
|
||||
&member.ID, &member.Nick,
|
||||
&member.Username, &member.Hostname,
|
||||
&member.LastSeen,
|
||||
&member.ID, &member.Nick, &member.LastSeen,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -1020,26 +859,6 @@ func (database *Database) GetUserCount(
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetOperCount returns the number of sessions with oper
|
||||
// status.
|
||||
func (database *Database) GetOperCount(
|
||||
ctx context.Context,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
err := database.conn.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT COUNT(*) FROM sessions WHERE is_oper = 1",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"get oper count: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ClientCountForSession returns the number of clients
|
||||
// belonging to a session.
|
||||
func (database *Database) ClientCountForSession(
|
||||
@@ -1447,149 +1266,3 @@ func (database *Database) PruneOldMessages(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, token, err := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
ctx, "alice",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
|
||||
}
|
||||
|
||||
_, _, dupToken, dupErr := database.CreateSession(
|
||||
ctx, "alice", "", "", "",
|
||||
ctx, "alice",
|
||||
)
|
||||
if dupErr == nil {
|
||||
t.Fatal("expected error for duplicate nick")
|
||||
@@ -54,249 +54,13 @@ func TestCreateSession(t *testing.T) {
|
||||
_ = dupToken
|
||||
}
|
||||
|
||||
// assertSessionHostInfo creates a session and verifies
|
||||
// the stored username and hostname match expectations.
|
||||
func assertSessionHostInfo(
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
nick, inputUser, inputHost,
|
||||
expectUser, expectHost string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
t.Context(), nick, inputUser, inputHost, "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := database.GetSessionHostInfo(
|
||||
t.Context(), sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Username != expectUser {
|
||||
t.Fatalf(
|
||||
"expected username %s, got %s",
|
||||
expectUser, info.Username,
|
||||
)
|
||||
}
|
||||
|
||||
if info.Hostname != expectHost {
|
||||
t.Fatalf(
|
||||
"expected hostname %s, got %s",
|
||||
expectHost, info.Hostname,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSessionWithUserHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
|
||||
assertSessionHostInfo(
|
||||
t, database,
|
||||
"hostuser", "myident", "example.com",
|
||||
"myident", "example.com",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCreateSessionDefaultUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
|
||||
// Empty username defaults to nick.
|
||||
assertSessionHostInfo(
|
||||
t, database,
|
||||
"defaultu", "", "host.local",
|
||||
"defaultu", "host.local",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCreateSessionStoresIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, clientID, _, err := database.CreateSession(
|
||||
ctx, "ipuser", "ident", "host.example.com",
|
||||
"192.168.1.42",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := database.GetSessionHostInfo(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.IP != "192.168.1.42" {
|
||||
t.Fatalf(
|
||||
"expected session IP 192.168.1.42, got %s",
|
||||
info.IP,
|
||||
)
|
||||
}
|
||||
|
||||
clientInfo, err := database.GetClientHostInfo(
|
||||
ctx, clientID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if clientInfo.IP != "192.168.1.42" {
|
||||
t.Fatalf(
|
||||
"expected client IP 192.168.1.42, got %s",
|
||||
clientInfo.IP,
|
||||
)
|
||||
}
|
||||
|
||||
if clientInfo.Hostname != "host.example.com" {
|
||||
t.Fatalf(
|
||||
"expected client hostname host.example.com, got %s",
|
||||
clientInfo.Hostname,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientHostInfoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
|
||||
_, err := database.GetClientHostInfo(
|
||||
t.Context(), 99999,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionHostInfoNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
|
||||
_, err := database.GetSessionHostInfo(
|
||||
t.Context(), 99999,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHostmask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := db.FormatHostmask(
|
||||
"nick", "user", "host.com",
|
||||
)
|
||||
if result != "nick!user@host.com" {
|
||||
t.Fatalf(
|
||||
"expected nick!user@host.com, got %s",
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHostmaskDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := db.FormatHostmask("nick", "", "")
|
||||
if result != "nick!nick@*" {
|
||||
t.Fatalf(
|
||||
"expected nick!nick@*, got %s",
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberInfoHostmask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
member := &db.MemberInfo{ //nolint:exhaustruct // test only uses hostmask fields
|
||||
Nick: "alice",
|
||||
Username: "aliceident",
|
||||
Hostname: "alice.example.com",
|
||||
}
|
||||
|
||||
hostmask := member.Hostmask()
|
||||
expected := "alice!aliceident@alice.example.com"
|
||||
|
||||
if hostmask != expected {
|
||||
t.Fatalf(
|
||||
"expected %s, got %s", expected, hostmask,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMembersIncludeUserHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(
|
||||
ctx, "memuser", "myuser", "myhost.net", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chID, err := database.GetOrCreateChannel(
|
||||
ctx, "#hostchan",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
members, err := database.ChannelMembers(ctx, chID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(members) != 1 {
|
||||
t.Fatalf(
|
||||
"expected 1 member, got %d", len(members),
|
||||
)
|
||||
}
|
||||
|
||||
if members[0].Username != "myuser" {
|
||||
t.Fatalf(
|
||||
"expected username myuser, got %s",
|
||||
members[0].Username,
|
||||
)
|
||||
}
|
||||
|
||||
if members[0].Hostname != "myhost.net" {
|
||||
t.Fatalf(
|
||||
"expected hostname myhost.net, got %s",
|
||||
members[0].Hostname,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, token, err := database.CreateSession(ctx, "bob", "", "", "")
|
||||
_, _, token, err := database.CreateSession(ctx, "bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -329,7 +93,7 @@ func TestGetSessionByNick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
charlieID, charlieClientID, charlieToken, err :=
|
||||
database.CreateSession(ctx, "charlie", "", "", "")
|
||||
database.CreateSession(ctx, "charlie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -386,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "")
|
||||
sid, _, _, err := database.CreateSession(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -435,7 +199,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
|
||||
sid, _, _, err := database.CreateSession(ctx, "temp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -470,7 +234,7 @@ func createSessionWithChannels(
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
|
||||
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -553,7 +317,7 @@ func TestChangeNick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "old", "", "", "",
|
||||
ctx, "old",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -637,7 +401,7 @@ func TestPollMessages(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "poller", "", "", "",
|
||||
ctx, "poller",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -744,7 +508,7 @@ func TestDeleteSession(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sid, _, _, err := database.CreateSession(
|
||||
ctx, "deleteme", "", "", "",
|
||||
ctx, "deleteme",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -784,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "")
|
||||
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "")
|
||||
sid2, _, _, err := database.CreateSession(ctx, "m2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -847,7 +611,7 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, token, err := database.CreateSession(
|
||||
ctx, "enqclient", "", "", "",
|
||||
ctx, "enqclient",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -887,133 +651,3 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
t.Fatalf("expected 1, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndCheckSessionOper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "opernick", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initially not oper.
|
||||
isOper, err := database.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if isOper {
|
||||
t.Fatal("expected session not to be oper")
|
||||
}
|
||||
|
||||
// Set oper.
|
||||
err = database.SetSessionOper(ctx, sessionID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
isOper, err = database.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !isOper {
|
||||
t.Fatal("expected session to be oper")
|
||||
}
|
||||
|
||||
// Unset oper.
|
||||
err = database.SetSessionOper(ctx, sessionID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
isOper, err = database.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if isOper {
|
||||
t.Fatal("expected session not to be oper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestClientForSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, "clientnick", "", "", "10.0.0.1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientInfo, err := database.GetLatestClientForSession(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if clientInfo.IP != "10.0.0.1" {
|
||||
t.Fatalf(
|
||||
"expected IP 10.0.0.1, got %s",
|
||||
clientInfo.IP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// Create two sessions.
|
||||
sid1, _, _, err := database.CreateSession(
|
||||
ctx, "user1", "", "", "",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sid2, _, _, err := database.CreateSession(
|
||||
ctx, "user2", "", "", "",
|
||||
)
|
||||
_ = sid2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initially zero opers.
|
||||
count, err := database.GetOperCount(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Fatalf("expected 0 opers, got %d", count)
|
||||
}
|
||||
|
||||
// Set one as oper.
|
||||
err = database.SetSessionOper(ctx, sid1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
count, err = database.GetOperCount(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 oper, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
nick TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
signing_key TEXT NOT NULL DEFAULT '',
|
||||
away_message TEXT NOT NULL DEFAULT '',
|
||||
@@ -24,8 +20,6 @@ CREATE TABLE IF NOT EXISTS clients (
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -39,7 +33,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
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,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -68,14 +61,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_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
|
||||
CREATE TABLE IF NOT EXISTS client_queues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,6 @@ func (hdlr *Handlers) handleRegister(
|
||||
) {
|
||||
type registerRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
@@ -59,20 +58,6 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
username := resolveUsername(
|
||||
payload.Username, payload.Nick,
|
||||
)
|
||||
|
||||
if !validUsernameRe.MatchString(username) {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid username format",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.Password) < minPasswordLength {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
@@ -83,27 +68,11 @@ func (hdlr *Handlers) handleRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.executeRegister(
|
||||
writer, request,
|
||||
payload.Nick, payload.Password, username,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) executeRegister(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, password, username string,
|
||||
) {
|
||||
remoteIP := clientIP(request)
|
||||
|
||||
hostname := resolveHostname(
|
||||
request.Context(), remoteIP,
|
||||
)
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.RegisterUser(
|
||||
request.Context(),
|
||||
nick, password, username, hostname, remoteIP,
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.handleRegisterError(
|
||||
@@ -113,14 +82,11 @@ func (hdlr *Handlers) executeRegister(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrSessions()
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": nick,
|
||||
"nick": payload.Nick,
|
||||
"token": token,
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
@@ -198,18 +164,11 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
remoteIP := clientIP(request)
|
||||
|
||||
hostname := resolveHostname(
|
||||
request.Context(), remoteIP,
|
||||
)
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.LoginUser(
|
||||
request.Context(),
|
||||
payload.Nick,
|
||||
payload.Password,
|
||||
remoteIP, hostname,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
@@ -221,8 +180,6 @@ func (hdlr *Handlers) handleLogin(
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.stats.IncrConnections()
|
||||
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, payload.Nick,
|
||||
)
|
||||
|
||||
@@ -13,10 +13,8 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -31,25 +29,16 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 30 * 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.
|
||||
type Handlers struct {
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
stats *stats.Tracker
|
||||
cancelCleanup context.CancelFunc
|
||||
}
|
||||
|
||||
@@ -58,19 +47,11 @@ func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
resource := params.Config.ServerName
|
||||
if resource == "" {
|
||||
resource = "neoirc"
|
||||
}
|
||||
|
||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
stats: params.Stats,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -292,20 +273,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
resp := hdlr.hc.Healthcheck(request.Context())
|
||||
resp := hdlr.hc.Healthcheck()
|
||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
@@ -22,7 +21,6 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *db.Database
|
||||
Stats *stats.Tracker
|
||||
}
|
||||
|
||||
// Healthcheck tracks server uptime and provides health status.
|
||||
@@ -66,22 +64,11 @@ type Response struct {
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
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.
|
||||
func (hcheck *Healthcheck) Healthcheck(
|
||||
ctx context.Context,
|
||||
) *Response {
|
||||
resp := &Response{
|
||||
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||
return &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
|
||||
Appname: hcheck.params.Globals.Appname,
|
||||
Version: hcheck.params.Globals.Version,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/web"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env 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()
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ const (
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdOper = "OPER"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
|
||||
@@ -132,7 +132,6 @@ const (
|
||||
RplNoTopic IRCMessageType = 331
|
||||
RplTopic IRCMessageType = 332
|
||||
RplTopicWhoTime IRCMessageType = 333
|
||||
RplWhoisActually IRCMessageType = 338
|
||||
RplInviting IRCMessageType = 341
|
||||
RplSummoning IRCMessageType = 342
|
||||
RplInviteList IRCMessageType = 346
|
||||
@@ -296,7 +295,6 @@ var names = map[IRCMessageType]string{
|
||||
RplNoTopic: "RPL_NOTOPIC",
|
||||
RplTopic: "RPL_TOPIC",
|
||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||
RplWhoisActually: "RPL_WHOISACTUALLY",
|
||||
RplInviting: "RPL_INVITING",
|
||||
RplSummoning: "RPL_SUMMONING",
|
||||
RplInviteList: "RPL_INVITELIST",
|
||||
|
||||
@@ -8,56 +8,6 @@ const MEMBER_REFRESH_INTERVAL = 10000;
|
||||
const ACTION_PREFIX = "\x01ACTION ";
|
||||
const ACTION_SUFFIX = "\x01";
|
||||
|
||||
// Hashcash proof-of-work helpers using Web Crypto API.
|
||||
|
||||
function checkLeadingZeros(hashBytes, bits) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < hashBytes.length; i++) {
|
||||
if (hashBytes[i] === 0) {
|
||||
count += 8;
|
||||
continue;
|
||||
}
|
||||
let b = hashBytes[i];
|
||||
while ((b & 0x80) === 0) {
|
||||
count++;
|
||||
b <<= 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return count >= bits;
|
||||
}
|
||||
|
||||
async function mintHashcash(bits, resource) {
|
||||
const encoder = new TextEncoder();
|
||||
const now = new Date();
|
||||
const date =
|
||||
String(now.getUTCFullYear()).slice(2) +
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0") +
|
||||
String(now.getUTCDate()).padStart(2, "0");
|
||||
const prefix = `1:${bits}:${date}:${resource}::`;
|
||||
let nonce = Math.floor(Math.random() * 0x100000);
|
||||
const batchSize = 1024;
|
||||
|
||||
for (;;) {
|
||||
const stamps = [];
|
||||
const hashPromises = [];
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const stamp = prefix + (nonce + i).toString(16);
|
||||
stamps.push(stamp);
|
||||
hashPromises.push(
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
|
||||
);
|
||||
}
|
||||
const hashes = await Promise.all(hashPromises);
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
|
||||
return stamps[i];
|
||||
}
|
||||
}
|
||||
nonce += batchSize;
|
||||
}
|
||||
}
|
||||
|
||||
function api(path, opts = {}) {
|
||||
const token = localStorage.getItem("neoirc_token");
|
||||
const headers = {
|
||||
@@ -110,16 +60,12 @@ function LoginScreen({ onLogin }) {
|
||||
const [motd, setMotd] = useState("");
|
||||
const [serverName, setServerName] = useState("NeoIRC");
|
||||
const inputRef = useRef();
|
||||
const hashcashBitsRef = useRef(0);
|
||||
const hashcashResourceRef = useRef("neoirc");
|
||||
|
||||
useEffect(() => {
|
||||
api("/server")
|
||||
.then((s) => {
|
||||
if (s.name) setServerName(s.name);
|
||||
if (s.motd) setMotd(s.motd);
|
||||
hashcashBitsRef.current = s.hashcash_bits || 0;
|
||||
if (s.name) hashcashResourceRef.current = s.name;
|
||||
})
|
||||
.catch(() => {});
|
||||
const saved = localStorage.getItem("neoirc_token");
|
||||
@@ -135,22 +81,9 @@ function LoginScreen({ onLogin }) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
let hashcashStamp = "";
|
||||
if (hashcashBitsRef.current > 0) {
|
||||
setError("Computing proof-of-work...");
|
||||
hashcashStamp = await mintHashcash(
|
||||
hashcashBitsRef.current,
|
||||
hashcashResourceRef.current,
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
const reqBody = { nick: nick.trim() };
|
||||
if (hashcashStamp) {
|
||||
reqBody.pow_token = hashcashStamp;
|
||||
}
|
||||
const res = await api("/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(reqBody),
|
||||
body: JSON.stringify({ nick: nick.trim() }),
|
||||
});
|
||||
localStorage.setItem("neoirc_token", res.token);
|
||||
onLogin(res.nick);
|
||||
|
||||
Reference in New Issue
Block a user