Compare commits
1 Commits
feature/us
...
fix/29-spl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b42620749 |
@@ -1,8 +1,8 @@
|
|||||||
.git
|
.git
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
neoircd
|
chatd
|
||||||
neoirc-cli
|
chat-cli
|
||||||
data.db
|
data.db
|
||||||
data.db-wal
|
data.db-wal
|
||||||
data.db-shm
|
data.db-shm
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,8 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
web/dist/
|
/chatd
|
||||||
/neoircd
|
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -35,5 +34,5 @@ vendor/
|
|||||||
# Project
|
# Project
|
||||||
data.db
|
data.db
|
||||||
debug.log
|
debug.log
|
||||||
/neoirc-cli
|
/chat-cli
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
1. **Format**: `gofmt -s -w .` and `goimports -w .`
|
||||||
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues
|
||||||
3. **Test**: `go test -race ./...` — all passing
|
3. **Test**: `go test -race ./...` — all passing
|
||||||
4. **Build**: `go build ./cmd/neoircd` — compiles clean
|
4. **Build**: `go build ./cmd/chatd` — compiles clean
|
||||||
|
|
||||||
No commit lands on main with lint errors, test failures, or formatting issues.
|
No commit lands on main with lint errors, test failures, or formatting issues.
|
||||||
|
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -1,59 +1,42 @@
|
|||||||
# Web build stage — compile SPA from source
|
|
||||||
# node:22-alpine, 2026-03-09
|
|
||||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
|
||||||
WORKDIR /web
|
|
||||||
COPY web/package.json web/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY web/src/ src/
|
|
||||||
COPY web/build.sh build.sh
|
|
||||||
RUN sh build.sh
|
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
|
||||||
# without depending on the web-builder stage (lint should fail fast)
|
|
||||||
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
|
|
||||||
# Build stage
|
# Build stage — tests and compilation
|
||||||
# golang:1.24-alpine, 2026-02-26
|
# golang:1.24-alpine, 2026-02-26
|
||||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache git build-base make
|
RUN apk add --no-cache git build-base make
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
# Force BuildKit to run the lint stage by creating a stage dependency
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist/ web/dist/
|
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# alpine:3.21, 2026-02-26
|
# alpine:3.21, 2026-02-26
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
RUN apk add --no-cache ca-certificates \
|
RUN apk add --no-cache ca-certificates \
|
||||||
&& addgroup -S neoirc && adduser -S neoirc -G neoirc \
|
&& addgroup -S chat && adduser -S chat -G chat
|
||||||
&& mkdir -p /var/lib/neoirc \
|
COPY --from=builder /chatd /usr/local/bin/chatd
|
||||||
&& chown neoirc:neoirc /var/lib/neoirc
|
|
||||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
|
||||||
|
|
||||||
USER neoirc
|
USER chat
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||||
ENTRYPOINT ["neoircd"]
|
ENTRYPOINT ["chatd"]
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
||||||
|
|
||||||
BINARY := neoircd
|
BINARY := chatd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
BUILDARCH := $(shell go env GOARCH)
|
BUILDARCH := $(shell go env GOARCH)
|
||||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||||
@@ -8,7 +8,7 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/chatd
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -27,7 +27,7 @@ test:
|
|||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
check: test lint fmt-check
|
check: test lint fmt-check
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/chatd
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
@@ -37,10 +37,10 @@ debug: build
|
|||||||
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/ neoircd
|
rm -rf bin/ chatd data.db
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t neoirc .
|
docker build -t chat .
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-03-09
|
last_modified: 2026-02-22
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,13 +98,6 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
|
||||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
|
||||||
should generate these at build time. Notable exception: Go protobuf generated
|
|
||||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
|
||||||
downloads code but does not execute code generation.
|
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -151,14 +144,8 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
Post-1.0.0: add new migration files.
|
||||||
tracking table itself. Nothing else.
|
|
||||||
- `001_schema.sql` — the full application schema.
|
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
||||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
||||||
Never edit existing migrations after release.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package neoircapi provides a client for the neoirc server API.
|
// Package chatapi provides a client for the chat server API.
|
||||||
package neoircapi
|
package chatapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,7 +23,7 @@ const (
|
|||||||
|
|
||||||
var errHTTP = errors.New("HTTP error")
|
var errHTTP = errors.New("HTTP error")
|
||||||
|
|
||||||
// Client wraps HTTP calls to the neoirc server API.
|
// Client wraps HTTP calls to the chat server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
Token string
|
||||||
@@ -43,30 +41,13 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession creates a new session on the server.
|
// CreateSession creates a new session on the server.
|
||||||
// If the server requires hashcash proof-of-work, it
|
|
||||||
// automatically fetches the difficulty and computes a
|
|
||||||
// valid stamp.
|
|
||||||
func (client *Client) CreateSession(
|
func (client *Client) CreateSession(
|
||||||
nick string,
|
nick string,
|
||||||
) (*SessionResponse, error) {
|
) (*SessionResponse, error) {
|
||||||
// Fetch server info to check for hashcash requirement.
|
|
||||||
info, err := client.GetServerInfo()
|
|
||||||
|
|
||||||
var hashcashStamp string
|
|
||||||
|
|
||||||
if err == nil && info.HashcashBits > 0 {
|
|
||||||
resource := info.Name
|
|
||||||
if resource == "" {
|
|
||||||
resource = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
hashcashStamp = MintHashcash(info.HashcashBits, resource)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := client.do(
|
data, err := client.do(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/session",
|
"/api/v1/session",
|
||||||
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
|
&SessionRequest{Nick: nick},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -187,7 +168,7 @@ func (client *Client) PollMessages(
|
|||||||
func (client *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdJoin, To: channel,
|
Command: "JOIN", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
|
|||||||
func (client *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdPart, To: channel,
|
Command: "PART", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package neoircapi
|
package chatapi
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// SessionRequest is the body for POST /api/v1/session.
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
type SessionRequest struct {
|
type SessionRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionResponse is the response from session creation.
|
// SessionResponse is the response from session creation.
|
||||||
@@ -22,7 +21,7 @@ type StateResponse struct {
|
|||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a neoirc message envelope.
|
// Message represents a chat message envelope.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
@@ -67,7 +66,6 @@ type ServerInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MOTD string `json:"motd"`
|
MOTD string `json:"motd"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesResponse wraps polling results.
|
// MessagesResponse wraps polling results.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package cli implements the neoirc-cli terminal client.
|
// Package main is the entry point for the chat-cli client.
|
||||||
package cli
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
api "git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,8 +31,7 @@ type App struct {
|
|||||||
stopPoll chan struct{}
|
stopPoll chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run creates and runs the CLI application.
|
func main() {
|
||||||
func Run() {
|
|
||||||
app := &App{ //nolint:exhaustruct
|
app := &App{ //nolint:exhaustruct
|
||||||
ui: NewUI(),
|
ui: NewUI(),
|
||||||
nick: "guest",
|
nick: "guest",
|
||||||
@@ -43,7 +41,7 @@ func Run() {
|
|||||||
app.ui.SetStatus(app.nick, "", "disconnected")
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Welcome to neoirc-cli — an IRC-style client",
|
"Welcome to chat-cli — an IRC-style client",
|
||||||
)
|
)
|
||||||
app.ui.AddStatus(
|
app.ui.AddStatus(
|
||||||
"Type [yellow]/connect <server-url>" +
|
"Type [yellow]/connect <server-url>" +
|
||||||
@@ -88,7 +86,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -140,29 +138,16 @@ func (a *App) dispatchCommand(cmd, args string) {
|
|||||||
a.cmdQuery(args)
|
a.cmdQuery(args)
|
||||||
case "/topic":
|
case "/topic":
|
||||||
a.cmdTopic(args)
|
a.cmdTopic(args)
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
case "/window", "/w":
|
case "/window", "/w":
|
||||||
a.cmdWindow(args)
|
a.cmdWindow(args)
|
||||||
case "/quit":
|
case "/quit":
|
||||||
a.cmdQuit()
|
a.cmdQuit()
|
||||||
case "/help":
|
case "/help":
|
||||||
a.cmdHelp()
|
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:
|
default:
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
"[red]Unknown command: " + cmd,
|
"[red]Unknown command: " + cmd,
|
||||||
@@ -243,7 +228,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdNick,
|
Command: "NICK",
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -378,7 +363,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -436,7 +421,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -449,7 +434,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -525,96 +510,6 @@ func (a *App) cmdList() {
|
|||||||
a.ui.AddStatus("[cyan]*** End of channel list")
|
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) {
|
func (a *App) cmdWindow(args string) {
|
||||||
if args == "" {
|
if args == "" {
|
||||||
a.ui.AddStatus(
|
a.ui.AddStatus(
|
||||||
@@ -655,7 +550,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +564,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
func (a *App) cmdHelp() {
|
func (a *App) cmdHelp() {
|
||||||
help := []string{
|
help := []string{
|
||||||
"[cyan]*** neoirc-cli commands:",
|
"[cyan]*** chat-cli commands:",
|
||||||
" /connect <url> — Connect to server",
|
" /connect <url> — Connect to server",
|
||||||
" /nick <name> — Change nickname",
|
" /nick <name> — Change nickname",
|
||||||
" /join #channel — Join channel",
|
" /join #channel — Join channel",
|
||||||
@@ -679,9 +574,6 @@ func (a *App) cmdHelp() {
|
|||||||
" /topic [text] — View/set topic",
|
" /topic [text] — View/set topic",
|
||||||
" /names — List channel members",
|
" /names — List channel members",
|
||||||
" /list — List channels",
|
" /list — List channels",
|
||||||
" /who [#channel] — List users in channel",
|
|
||||||
" /whois <nick> — Show user info",
|
|
||||||
" /motd — Show message of the day",
|
|
||||||
" /window <n> — Switch buffer",
|
" /window <n> — Switch buffer",
|
||||||
" /quit — Disconnect and exit",
|
" /quit — Disconnect and exit",
|
||||||
" /help — This help",
|
" /help — This help",
|
||||||
@@ -740,19 +632,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case irc.CmdPrivmsg:
|
case "PRIVMSG":
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdJoin:
|
case "JOIN":
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case irc.CmdPart:
|
case "PART":
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case irc.CmdQuit:
|
case "QUIT":
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case irc.CmdNick:
|
case "NICK":
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdNotice:
|
case "NOTICE":
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case irc.CmdTopic:
|
case "TOPIC":
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package cli
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
41
cmd/chatd/main.go
Normal file
41
cmd/chatd/main.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Package main is the entry point for the chatd server.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||||
|
"git.eeqj.de/sneak/chat/internal/server"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Appname is the application name, set at build time.
|
||||||
|
Appname = "chat" //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// Version is the application version, set at build time.
|
||||||
|
Version string //nolint:gochecknoglobals
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
globals.Appname = Appname
|
||||||
|
globals.Version = Version
|
||||||
|
|
||||||
|
fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
config.New,
|
||||||
|
db.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
logger.New,
|
||||||
|
server.New,
|
||||||
|
middleware.New,
|
||||||
|
healthcheck.New,
|
||||||
|
),
|
||||||
|
fx.Invoke(func(*server.Server) {}),
|
||||||
|
).Run()
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cli.Run()
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Package main is the entry point for the neoircd server.
|
|
||||||
package main
|
|
||||||
|
|
||||||
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/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Appname is the application name, set at build time.
|
|
||||||
Appname = "neoirc" //nolint:gochecknoglobals
|
|
||||||
|
|
||||||
// Version is the application version, set at build time.
|
|
||||||
Version string //nolint:gochecknoglobals
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
|
||||||
|
|
||||||
fx.New(
|
|
||||||
fx.Provide(
|
|
||||||
config.New,
|
|
||||||
db.New,
|
|
||||||
globals.New,
|
|
||||||
handlers.New,
|
|
||||||
logger.New,
|
|
||||||
server.New,
|
|
||||||
middleware.New,
|
|
||||||
healthcheck.New,
|
|
||||||
stats.New,
|
|
||||||
),
|
|
||||||
fx.Invoke(func(*server.Server) {}),
|
|
||||||
).Run()
|
|
||||||
}
|
|
||||||
4
go.mod
4
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/neoirc
|
module git.eeqj.de/sneak/chat
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
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/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"git.eeqj.de/sneak/chat/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package neoircapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// bitsPerByte is the number of bits in a byte.
|
|
||||||
bitsPerByte = 8
|
|
||||||
// fullByteMask is 0xFF, a mask for all bits in a byte.
|
|
||||||
fullByteMask = 0xFF
|
|
||||||
// counterSpace is the range for random counter seeds.
|
|
||||||
counterSpace = 1 << 48
|
|
||||||
)
|
|
||||||
|
|
||||||
// MintHashcash computes a hashcash stamp with the given
|
|
||||||
// difficulty (leading zero bits) and resource string.
|
|
||||||
func MintHashcash(bits int, resource string) string {
|
|
||||||
date := time.Now().UTC().Format("060102")
|
|
||||||
prefix := fmt.Sprintf(
|
|
||||||
"1:%d:%s:%s::", bits, date, resource,
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
counter := randomCounter()
|
|
||||||
stamp := prefix + counter
|
|
||||||
hash := sha256.Sum256([]byte(stamp))
|
|
||||||
|
|
||||||
if hasLeadingZeroBits(hash[:], bits) {
|
|
||||||
return stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if hash has at least numBits
|
|
||||||
// leading zero bits.
|
|
||||||
func hasLeadingZeroBits(
|
|
||||||
hash []byte,
|
|
||||||
numBits int,
|
|
||||||
) bool {
|
|
||||||
fullBytes := numBits / bitsPerByte
|
|
||||||
remainBits := numBits % bitsPerByte
|
|
||||||
|
|
||||||
for idx := range fullBytes {
|
|
||||||
if hash[idx] != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remainBits > 0 && fullBytes < len(hash) {
|
|
||||||
mask := byte(
|
|
||||||
fullByteMask << (bitsPerByte - remainBits),
|
|
||||||
)
|
|
||||||
|
|
||||||
if hash[fullBytes]&mask != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomCounter generates a random hex counter string.
|
|
||||||
func randomCounter() string {
|
|
||||||
counterVal, err := rand.Int(
|
|
||||||
rand.Reader, big.NewInt(counterSpace),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to timestamp-based counter on error.
|
|
||||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(counterVal.Bytes())
|
|
||||||
}
|
|
||||||
@@ -5,22 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
|
||||||
| '_ \ / _ \/ _ \ | | '__/ __|
|
|
||||||
| | | | __/ (_) || | | | (__
|
|
||||||
|_| |_|\___|\___/ |_|_| \___|
|
|
||||||
|
|
||||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
|
||||||
Type /help for available commands.`
|
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Config.
|
// Params defines the dependencies for creating a Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -38,16 +30,12 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MessageMaxAge string
|
MaxHistory int
|
||||||
MaxMessageSize int
|
MaxMessageSize int
|
||||||
QueueMaxAge string
|
|
||||||
MOTD string
|
MOTD string
|
||||||
ServerName string
|
ServerName string
|
||||||
FederationKey string
|
FederationKey string
|
||||||
SessionIdleTimeout string
|
SessionIdleTimeout string
|
||||||
HashcashBits int
|
|
||||||
OperName string
|
|
||||||
OperPassword string
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -68,20 +56,16 @@ func New(
|
|||||||
viper.SetDefault("DEBUG", "false")
|
viper.SetDefault("DEBUG", "false")
|
||||||
viper.SetDefault("MAINTENANCE_MODE", "false")
|
viper.SetDefault("MAINTENANCE_MODE", "false")
|
||||||
viper.SetDefault("PORT", "8080")
|
viper.SetDefault("PORT", "8080")
|
||||||
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
|
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
|
||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("MESSAGE_MAX_AGE", "720h")
|
viper.SetDefault("MAX_HISTORY", "10000")
|
||||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||||
viper.SetDefault("QUEUE_MAX_AGE", "720h")
|
viper.SetDefault("MOTD", "")
|
||||||
viper.SetDefault("MOTD", defaultMOTD)
|
|
||||||
viper.SetDefault("SERVER_NAME", "")
|
viper.SetDefault("SERVER_NAME", "")
|
||||||
viper.SetDefault("FEDERATION_KEY", "")
|
viper.SetDefault("FEDERATION_KEY", "")
|
||||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h")
|
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
|
||||||
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
|
||||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,16 +84,12 @@ func New(
|
|||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
|
MaxHistory: viper.GetInt("MAX_HISTORY"),
|
||||||
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
|
||||||
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
|
|
||||||
MOTD: viper.GetString("MOTD"),
|
MOTD: viper.GetString("MOTD"),
|
||||||
ServerName: viper.GetString("SERVER_NAME"),
|
ServerName: viper.GetString("SERVER_NAME"),
|
||||||
FederationKey: viper.GetString("FEDERATION_KEY"),
|
FederationKey: viper.GetString("FEDERATION_KEY"),
|
||||||
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
|
||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
|
||||||
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
|
||||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,8 @@ var errNoPassword = errors.New(
|
|||||||
// and returns session ID, client ID, and token.
|
// and returns session ID, client ID, and token.
|
||||||
func (database *Database) RegisterUser(
|
func (database *Database) RegisterUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password, username, hostname, remoteIP string,
|
nick, password string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
if username == "" {
|
|
||||||
username = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(password), bcryptCost,
|
[]byte(password), bcryptCost,
|
||||||
)
|
)
|
||||||
@@ -54,11 +50,10 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
res, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO sessions
|
`INSERT INTO sessions
|
||||||
(uuid, nick, username, hostname, ip,
|
(uuid, nick, password_hash,
|
||||||
password_hash, created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
sessionUUID, nick, username, hostname,
|
sessionUUID, nick, string(hash), now, now)
|
||||||
remoteIP, string(hash), now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -69,15 +64,12 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, token, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -102,7 +94,7 @@ func (database *Database) RegisterUser(
|
|||||||
// client token.
|
// client token.
|
||||||
func (database *Database) LoginUser(
|
func (database *Database) LoginUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password, remoteIP, hostname string,
|
nick, password string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
var (
|
var (
|
||||||
sessionID int64
|
sessionID int64
|
||||||
@@ -145,15 +137,12 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, token, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestRegisterUser(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.RegisterUser(ctx, "reguser", "password123", "", "", "")
|
database.RegisterUser(ctx, "reguser", "password123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -108,7 +45,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
|
database.RegisterUser(ctx, "dupnick", "password123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -118,7 +55,7 @@ func TestRegisterUserDuplicateNick(t *testing.T) {
|
|||||||
_ = regToken
|
_ = regToken
|
||||||
|
|
||||||
dupSID, dupCID, dupToken, dupErr :=
|
dupSID, dupCID, dupToken, dupErr :=
|
||||||
database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
|
database.RegisterUser(ctx, "dupnick", "other12345")
|
||||||
if dupErr == nil {
|
if dupErr == nil {
|
||||||
t.Fatal("expected error for duplicate nick")
|
t.Fatal("expected error for duplicate nick")
|
||||||
}
|
}
|
||||||
@@ -135,7 +72,7 @@ func TestLoginUser(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
|
database.RegisterUser(ctx, "loginuser", "mypassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +82,7 @@ func TestLoginUser(t *testing.T) {
|
|||||||
_ = regToken
|
_ = regToken
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
database.LoginUser(ctx, "loginuser", "mypassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestLoginUserWrongPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -250,7 +110,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
|
database.RegisterUser(ctx, "wrongpw", "correctpass")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -260,7 +120,7 @@ func TestLoginUserWrongPassword(t *testing.T) {
|
|||||||
_ = regToken
|
_ = regToken
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||||
if loginErr == nil {
|
if loginErr == nil {
|
||||||
t.Fatal("expected error for wrong password")
|
t.Fatal("expected error for wrong password")
|
||||||
}
|
}
|
||||||
@@ -278,7 +138,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
|
|
||||||
// Create anonymous session (no password).
|
// Create anonymous session (no password).
|
||||||
anonSID, anonCID, anonToken, err :=
|
anonSID, anonCID, anonToken, err :=
|
||||||
database.CreateSession(ctx, "anon", "", "", "")
|
database.CreateSession(ctx, "anon")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -288,7 +148,7 @@ func TestLoginUserNoPassword(t *testing.T) {
|
|||||||
_ = anonToken
|
_ = anonToken
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
database.LoginUser(ctx, "anon", "anything1", "", "")
|
database.LoginUser(ctx, "anon", "anything1")
|
||||||
if loginErr == nil {
|
if loginErr == nil {
|
||||||
t.Fatal(
|
t.Fatal(
|
||||||
"expected error for login on passwordless account",
|
"expected error for login on passwordless account",
|
||||||
@@ -307,7 +167,7 @@ func TestLoginUserNonexistent(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
loginSID, loginCID, loginToken, err :=
|
||||||
database.LoginUser(ctx, "ghost", "password123", "", "")
|
database.LoginUser(ctx, "ghost", "password123")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for nonexistent user")
|
t.Fatal("expected error for nonexistent user")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // .env
|
||||||
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
|
|||||||
func (database *Database) connect(ctx context.Context) error {
|
func (database *Database) connect(ctx context.Context) error {
|
||||||
dbURL := database.params.Config.DBURL
|
dbURL := database.params.Config.DBURL
|
||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
|
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000"
|
||||||
}
|
}
|
||||||
|
|
||||||
database.log.Info(
|
database.log.Info(
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Package db provides database access and migration management.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"modernc.org/sqlite"
|
|
||||||
sqlite3 "modernc.org/sqlite/lib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsUniqueConstraintError reports whether err is a SQLite
|
|
||||||
// unique-constraint violation.
|
|
||||||
func IsUniqueConstraintError(err error) bool {
|
|
||||||
var sqliteErr *sqlite.Error
|
|
||||||
if !errors.As(err, &sqliteErr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,12 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,37 +29,18 @@ func generateToken() (string, error) {
|
|||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashToken returns the lowercase hex-encoded SHA-256
|
|
||||||
// digest of a plaintext token string.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// IRCMessage is the IRC envelope for all messages.
|
// IRCMessage is the IRC envelope for all messages.
|
||||||
type IRCMessage struct {
|
type IRCMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Code int `json:"code,omitempty"`
|
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
To string `json:"to,omitempty"`
|
To string `json:"to,omitempty"`
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
|
||||||
Body json.RawMessage `json:"body,omitempty"`
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
TS string `json:"ts"`
|
TS string `json:"ts"`
|
||||||
Meta json.RawMessage `json:"meta,omitempty"`
|
Meta json.RawMessage `json:"meta,omitempty"`
|
||||||
DBID int64 `json:"-"`
|
DBID int64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNumericCode returns true if s is exactly a 3-digit
|
|
||||||
// IRC numeric reply code.
|
|
||||||
func isNumericCode(s string) bool {
|
|
||||||
return len(s) == 3 &&
|
|
||||||
s[0] >= '0' && s[0] <= '9' &&
|
|
||||||
s[1] >= '0' && s[1] <= '9' &&
|
|
||||||
s[2] >= '0' && s[2] <= '9'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelInfo is a lightweight channel representation.
|
// ChannelInfo is a lightweight channel representation.
|
||||||
type ChannelInfo struct {
|
type ChannelInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -74,40 +52,14 @@ type ChannelInfo struct {
|
|||||||
type MemberInfo struct {
|
type MemberInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Username string `json:"username"`
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
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.
|
// CreateSession registers a new session and its first client.
|
||||||
func (database *Database) CreateSession(
|
func (database *Database) CreateSession(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, username, hostname, remoteIP string,
|
nick string,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
if username == "" {
|
|
||||||
username = nick
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionUUID := uuid.New().String()
|
sessionUUID := uuid.New().String()
|
||||||
clientUUID := uuid.New().String()
|
clientUUID := uuid.New().String()
|
||||||
|
|
||||||
@@ -127,11 +79,9 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
res, err := transaction.ExecContext(ctx,
|
res, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO sessions
|
`INSERT INTO sessions
|
||||||
(uuid, nick, username, hostname, ip,
|
(uuid, nick, created_at, last_seen)
|
||||||
created_at, last_seen)
|
VALUES (?, ?, ?, ?)`,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
sessionUUID, nick, now, now)
|
||||||
sessionUUID, nick, username, hostname,
|
|
||||||
remoteIP, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -142,15 +92,12 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token, ip, hostname,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash,
|
clientUUID, sessionID, token, now, now)
|
||||||
remoteIP, hostname, now, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -183,8 +130,6 @@ func (database *Database) GetSessionByToken(
|
|||||||
nick string
|
nick string
|
||||||
)
|
)
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT s.id, c.id, s.nick
|
`SELECT s.id, c.id, s.nick
|
||||||
@@ -192,7 +137,7 @@ func (database *Database) GetSessionByToken(
|
|||||||
INNER JOIN sessions s
|
INNER JOIN sessions s
|
||||||
ON s.id = c.session_id
|
ON s.id = c.session_id
|
||||||
WHERE c.token = ?`,
|
WHERE c.token = ?`,
|
||||||
tokenHash,
|
token,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
@@ -238,135 +183,6 @@ func (database *Database) GetSessionByNick(
|
|||||||
return sessionID, nil
|
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.
|
// GetChannelByName returns the channel ID for a name.
|
||||||
func (database *Database) GetChannelByName(
|
func (database *Database) GetChannelByName(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -546,8 +362,7 @@ func (database *Database) ChannelMembers(
|
|||||||
channelID int64,
|
channelID int64,
|
||||||
) ([]MemberInfo, error) {
|
) ([]MemberInfo, error) {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT s.id, s.nick, s.username,
|
`SELECT s.id, s.nick, s.last_seen
|
||||||
s.hostname, s.last_seen
|
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
INNER JOIN channel_members cm
|
INNER JOIN channel_members cm
|
||||||
ON cm.session_id = s.id
|
ON cm.session_id = s.id
|
||||||
@@ -567,9 +382,7 @@ func (database *Database) ChannelMembers(
|
|||||||
var member MemberInfo
|
var member MemberInfo
|
||||||
|
|
||||||
err = rows.Scan(
|
err = rows.Scan(
|
||||||
&member.ID, &member.Nick,
|
&member.ID, &member.Nick, &member.LastSeen,
|
||||||
&member.Username, &member.Hostname,
|
|
||||||
&member.LastSeen,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -678,17 +491,12 @@ func (database *Database) GetSessionChannelIDs(
|
|||||||
func (database *Database) InsertMessage(
|
func (database *Database) InsertMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
params json.RawMessage,
|
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
meta json.RawMessage,
|
meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
msgUUID := uuid.New().String()
|
msgUUID := uuid.New().String()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
if params == nil {
|
|
||||||
params = json.RawMessage("[]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if body == nil {
|
if body == nil {
|
||||||
body = json.RawMessage("[]")
|
body = json.RawMessage("[]")
|
||||||
}
|
}
|
||||||
@@ -700,10 +508,10 @@ func (database *Database) InsertMessage(
|
|||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(uuid, command, msg_from, msg_to,
|
(uuid, command, msg_from, msg_to,
|
||||||
params, body, meta, created_at)
|
body, meta, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msgUUID, command, from, target,
|
msgUUID, command, from, target,
|
||||||
string(params), string(body), string(meta), now)
|
string(body), string(meta), now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", fmt.Errorf(
|
return 0, "", fmt.Errorf(
|
||||||
"insert message: %w", err,
|
"insert message: %w", err,
|
||||||
@@ -770,7 +578,7 @@ func (database *Database) PollMessages(
|
|||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT cq.id, m.uuid, m.command,
|
`SELECT cq.id, m.uuid, m.command,
|
||||||
m.msg_from, m.msg_to,
|
m.msg_from, m.msg_to,
|
||||||
m.params, m.body, m.meta, m.created_at
|
m.body, m.meta, m.created_at
|
||||||
FROM client_queues cq
|
FROM client_queues cq
|
||||||
INNER JOIN messages m
|
INNER JOIN messages m
|
||||||
ON m.id = cq.message_id
|
ON m.id = cq.message_id
|
||||||
@@ -834,7 +642,7 @@ func (database *Database) queryHistory(
|
|||||||
if beforeID > 0 {
|
if beforeID > 0 {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ? AND id < ?
|
WHERE msg_to = ? AND id < ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -851,7 +659,7 @@ func (database *Database) queryHistory(
|
|||||||
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT id, uuid, command, msg_from,
|
`SELECT id, uuid, command, msg_from,
|
||||||
msg_to, params, body, meta, created_at
|
msg_to, body, meta, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE msg_to = ?
|
WHERE msg_to = ?
|
||||||
AND command = 'PRIVMSG'
|
AND command = 'PRIVMSG'
|
||||||
@@ -878,14 +686,14 @@ func scanMessages(
|
|||||||
var (
|
var (
|
||||||
msg IRCMessage
|
msg IRCMessage
|
||||||
qID int64
|
qID int64
|
||||||
params, body, meta string
|
body, meta string
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&qID, &msg.ID, &msg.Command,
|
&qID, &msg.ID, &msg.Command,
|
||||||
&msg.From, &msg.To,
|
&msg.From, &msg.To,
|
||||||
¶ms, &body, &meta, &createdAt,
|
&body, &meta, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fallbackQID, fmt.Errorf(
|
return nil, fallbackQID, fmt.Errorf(
|
||||||
@@ -893,25 +701,12 @@ func scanMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params != "" && params != "[]" {
|
|
||||||
msg.Params = json.RawMessage(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.Body = json.RawMessage(body)
|
msg.Body = json.RawMessage(body)
|
||||||
msg.Meta = json.RawMessage(meta)
|
msg.Meta = json.RawMessage(meta)
|
||||||
msg.TS = createdAt.Format(time.RFC3339Nano)
|
msg.TS = createdAt.Format(time.RFC3339Nano)
|
||||||
msg.DBID = qID
|
msg.DBID = qID
|
||||||
lastQID = qID
|
lastQID = qID
|
||||||
|
|
||||||
if isNumericCode(msg.Command) {
|
|
||||||
code, _ := strconv.Atoi(msg.Command)
|
|
||||||
msg.Code = code
|
|
||||||
|
|
||||||
if mt, err := irc.FromInt(code); err == nil {
|
|
||||||
msg.Command = mt.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs = append(msgs, msg)
|
msgs = append(msgs, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,26 +815,6 @@ func (database *Database) GetUserCount(
|
|||||||
return count, nil
|
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
|
// ClientCountForSession returns the number of clients
|
||||||
// belonging to a session.
|
// belonging to a session.
|
||||||
func (database *Database) ClientCountForSession(
|
func (database *Database) ClientCountForSession(
|
||||||
@@ -1168,321 +943,3 @@ func (database *Database) GetSessionChannels(
|
|||||||
|
|
||||||
return scanChannels(rows)
|
return scanChannels(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelCount returns the total number of channels.
|
|
||||||
func (database *Database) GetChannelCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM channels",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get channel count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelInfoFull contains extended channel information.
|
|
||||||
type ChannelInfoFull struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
MemberCount int64 `json:"memberCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllChannelsWithCounts returns every channel
|
|
||||||
// with its member count.
|
|
||||||
func (database *Database) ListAllChannelsWithCounts(
|
|
||||||
ctx context.Context,
|
|
||||||
) ([]ChannelInfoFull, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.id, c.name, c.topic,
|
|
||||||
COUNT(cm.session_id) AS member_count
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"list channels with counts: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var out []ChannelInfoFull
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfoFull
|
|
||||||
|
|
||||||
err = rows.Scan(
|
|
||||||
&chanInfo.ID, &chanInfo.Name,
|
|
||||||
&chanInfo.Topic, &chanInfo.MemberCount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel full: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = rows.Err()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("rows error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out == nil {
|
|
||||||
out = []ChannelInfoFull{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetChannelCreatedAt returns the creation time of a
|
|
||||||
// channel.
|
|
||||||
func (database *Database) GetChannelCreatedAt(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT created_at FROM channels WHERE id = ?",
|
|
||||||
channelID,
|
|
||||||
).Scan(&createdAt)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get channel created_at: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionCreatedAt returns the creation time of a
|
|
||||||
// session.
|
|
||||||
func (database *Database) GetSessionCreatedAt(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT created_at FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&createdAt)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get session created_at: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return createdAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAway sets the away message for a session.
|
|
||||||
// An empty message clears the away status.
|
|
||||||
func (database *Database) SetAway(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
message string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
"UPDATE sessions SET away_message = ? WHERE id = ?",
|
|
||||||
message, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set away: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAway returns the away message for a session.
|
|
||||||
// Returns an empty string if the user is not away.
|
|
||||||
func (database *Database) GetAway(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (string, error) {
|
|
||||||
var msg string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
"SELECT away_message FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&msg)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get away: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTopicMeta sets the topic along with who set it and
|
|
||||||
// when.
|
|
||||||
func (database *Database) SetTopicMeta(
|
|
||||||
ctx context.Context,
|
|
||||||
channelName, topic, setBy string,
|
|
||||||
) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET topic = ?, topic_set_by = ?,
|
|
||||||
topic_set_at = ?, updated_at = ?
|
|
||||||
WHERE name = ?`,
|
|
||||||
topic, setBy, now, now, channelName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set topic meta: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TopicMeta holds topic metadata for a channel.
|
|
||||||
type TopicMeta struct {
|
|
||||||
SetBy string
|
|
||||||
SetAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTopicMeta returns who set the topic and when.
|
|
||||||
func (database *Database) GetTopicMeta(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (*TopicMeta, error) {
|
|
||||||
var (
|
|
||||||
setBy string
|
|
||||||
setAt sql.NullTime
|
|
||||||
)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT topic_set_by, topic_set_at
|
|
||||||
FROM channels WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&setBy, &setAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get topic meta: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if setBy == "" || !setAt.Valid {
|
|
||||||
return nil, nil //nolint:nilnil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TopicMeta{
|
|
||||||
SetBy: setBy,
|
|
||||||
SetAt: setAt.Time,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionLastSeen returns the last_seen time for a
|
|
||||||
// session.
|
|
||||||
func (database *Database) GetSessionLastSeen(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (time.Time, error) {
|
|
||||||
var lastSeen time.Time
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
"SELECT last_seen FROM sessions WHERE id = ?",
|
|
||||||
sessionID,
|
|
||||||
).Scan(&lastSeen)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf(
|
|
||||||
"get session last_seen: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastSeen, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneOldQueueEntries deletes client output queue entries
|
|
||||||
// older than cutoff and returns the number of rows removed.
|
|
||||||
func (database *Database) PruneOldQueueEntries(
|
|
||||||
ctx context.Context,
|
|
||||||
cutoff time.Time,
|
|
||||||
) (int64, error) {
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
|
||||||
"DELETE FROM client_queues WHERE created_at < ?",
|
|
||||||
cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"prune old client output queue entries: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, _ := res.RowsAffected()
|
|
||||||
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneOldMessages deletes messages older than cutoff and
|
|
||||||
// returns the number of rows removed.
|
|
||||||
func (database *Database) PruneOldMessages(
|
|
||||||
ctx context.Context,
|
|
||||||
cutoff time.Time,
|
|
||||||
) (int64, error) {
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
|
||||||
"DELETE FROM messages WHERE created_at < ?",
|
|
||||||
cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"prune old messages: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted, _ := res.RowsAffected()
|
|
||||||
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetClientCount returns the total number of clients.
|
|
||||||
func (database *Database) GetClientCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM clients",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get client count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetQueueEntryCount returns the total number of entries
|
|
||||||
// in the client output queues.
|
|
||||||
func (database *Database) GetQueueEntryCount(
|
|
||||||
ctx context.Context,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
"SELECT COUNT(*) FROM client_queues",
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get queue entry count: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -34,7 +34,7 @@ func TestCreateSession(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, token, err := database.CreateSession(
|
sessionID, _, token, err := database.CreateSession(
|
||||||
ctx, "alice", "", "", "",
|
ctx, "alice",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -45,7 +45,7 @@ func TestCreateSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, dupToken, dupErr := database.CreateSession(
|
_, _, dupToken, dupErr := database.CreateSession(
|
||||||
ctx, "alice", "", "", "",
|
ctx, "alice",
|
||||||
)
|
)
|
||||||
if dupErr == nil {
|
if dupErr == nil {
|
||||||
t.Fatal("expected error for duplicate nick")
|
t.Fatal("expected error for duplicate nick")
|
||||||
@@ -54,249 +54,13 @@ func TestCreateSession(t *testing.T) {
|
|||||||
_ = dupToken
|
_ = 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) {
|
func TestGetSessionByToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(ctx, "bob", "", "", "")
|
_, _, token, err := database.CreateSession(ctx, "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -329,7 +93,7 @@ func TestGetSessionByNick(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
charlieID, charlieClientID, charlieToken, err :=
|
charlieID, charlieClientID, charlieToken, err :=
|
||||||
database.CreateSession(ctx, "charlie", "", "", "")
|
database.CreateSession(ctx, "charlie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -386,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, "user1", "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, "user1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -435,7 +199,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, "temp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -470,7 +234,7 @@ func createSessionWithChannels(
|
|||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(ctx, nick, "", "", "")
|
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -553,7 +317,7 @@ func TestChangeNick(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
sid, _, token, err := database.CreateSession(
|
||||||
ctx, "old", "", "", "",
|
ctx, "old",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -619,7 +383,7 @@ func TestInsertMessage(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, msgUUID, err := database.InsertMessage(
|
dbID, msgUUID, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -637,7 +401,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, token, err := database.CreateSession(
|
sid, _, token, err := database.CreateSession(
|
||||||
ctx, "poller", "", "", "",
|
ctx, "poller",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -653,7 +417,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
body := json.RawMessage(`["hello"]`)
|
body := json.RawMessage(`["hello"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
ctx, "PRIVMSG", "poller", "#test", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -711,7 +475,7 @@ func TestGetHistory(t *testing.T) {
|
|||||||
for range msgCount {
|
for range msgCount {
|
||||||
_, _, err := database.InsertMessage(
|
_, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "user", "#hist",
|
ctx, "PRIVMSG", "user", "#hist",
|
||||||
nil, json.RawMessage(`["msg"]`), nil,
|
json.RawMessage(`["msg"]`), nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -744,7 +508,7 @@ func TestDeleteSession(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
sid, _, _, err := database.CreateSession(
|
||||||
ctx, "deleteme", "", "", "",
|
ctx, "deleteme",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -784,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sid1, _, _, err := database.CreateSession(ctx, "m1", "", "", "")
|
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sid2, _, _, err := database.CreateSession(ctx, "m2", "", "", "")
|
sid2, _, _, err := database.CreateSession(ctx, "m2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -847,7 +611,7 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, _, token, err := database.CreateSession(
|
_, _, token, err := database.CreateSession(
|
||||||
ctx, "enqclient", "", "", "",
|
ctx, "enqclient",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -863,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
body := json.RawMessage(`["test"]`)
|
body := json.RawMessage(`["test"]`)
|
||||||
|
|
||||||
dbID, _, err := database.InsertMessage(
|
dbID, _, err := database.InsertMessage(
|
||||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -887,133 +651,3 @@ func TestEnqueueToClient(t *testing.T) {
|
|||||||
t.Fatalf("expected 1, got %d", len(msgs))
|
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,13 +6,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
nick 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 '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -24,8 +19,6 @@ CREATE TABLE IF NOT EXISTS clients (
|
|||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
|
||||||
hostname TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -37,8 +30,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
|
||||||
topic_set_at DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -59,7 +50,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||||
msg_from TEXT NOT NULL DEFAULT '',
|
msg_from TEXT NOT NULL DEFAULT '',
|
||||||
msg_to TEXT NOT NULL DEFAULT '',
|
msg_to TEXT NOT NULL DEFAULT '',
|
||||||
params TEXT NOT NULL DEFAULT '[]',
|
|
||||||
body TEXT NOT NULL DEFAULT '[]',
|
body TEXT NOT NULL DEFAULT '[]',
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,16 +17,14 @@ var (
|
|||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
StartTime time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Globals instance from the global state.
|
// New creates a new Globals instance from the global state.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
result := &Globals{
|
n := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
StartTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -30,7 +28,6 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
) {
|
) {
|
||||||
type registerRequest struct {
|
type registerRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,20 +56,6 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
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 {
|
if len(payload.Password) < minPasswordLength {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -83,27 +66,11 @@ func (hdlr *Handlers) handleRegister(
|
|||||||
return
|
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 :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.RegisterUser(
|
hdlr.params.Database.RegisterUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
nick, password, username, hostname, remoteIP,
|
payload.Nick,
|
||||||
|
payload.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.handleRegisterError(
|
hdlr.handleRegisterError(
|
||||||
@@ -113,14 +80,11 @@ func (hdlr *Handlers) executeRegister(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrSessions()
|
hdlr.deliverMOTD(request, clientID, sessionID)
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": payload.Nick,
|
||||||
"token": token,
|
"token": token,
|
||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
@@ -130,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -198,18 +162,11 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteIP := clientIP(request)
|
sessionID, _, token, err :=
|
||||||
|
|
||||||
hostname := resolveHostname(
|
|
||||||
request.Context(), remoteIP,
|
|
||||||
)
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
payload.Nick,
|
||||||
payload.Password,
|
payload.Password,
|
||||||
remoteIP, hostname,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
@@ -221,18 +178,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
|
||||||
// which channels the session already belongs to.
|
|
||||||
hdlr.initChannelState(
|
|
||||||
request, clientID, sessionID, payload.Nick,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package handlers provides HTTP request handlers for the neoirc server.
|
// Package handlers provides HTTP request handlers for the chat server.
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,14 +9,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"git.eeqj.de/sneak/chat/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,10 +29,9 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
@@ -42,8 +39,6 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
|
||||||
stats *stats.Tracker
|
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +47,11 @@ func New(
|
|||||||
lifecycle fx.Lifecycle,
|
lifecycle fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Handlers, error) {
|
) (*Handlers, error) {
|
||||||
resource := params.Config.ServerName
|
|
||||||
if resource == "" {
|
|
||||||
resource = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
|
||||||
stats: params.Stats,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -212,77 +200,4 @@ func (hdlr *Handlers) runCleanup(
|
|||||||
"deleted", deleted,
|
"deleted", deleted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.pruneQueuesAndMessages(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDurationConfig parses a Go duration string,
|
|
||||||
// returning zero on empty input and logging on error.
|
|
||||||
func (hdlr *Handlers) parseDurationConfig(
|
|
||||||
name, raw string,
|
|
||||||
) time.Duration {
|
|
||||||
if raw == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
dur, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"invalid duration config, skipping",
|
|
||||||
"name", name, "value", raw, "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return dur
|
|
||||||
}
|
|
||||||
|
|
||||||
// pruneQueuesAndMessages removes old client output queue
|
|
||||||
// entries per QUEUE_MAX_AGE and old messages per
|
|
||||||
// MESSAGE_MAX_AGE.
|
|
||||||
func (hdlr *Handlers) pruneQueuesAndMessages(
|
|
||||||
ctx context.Context,
|
|
||||||
) {
|
|
||||||
queueMaxAge := hdlr.parseDurationConfig(
|
|
||||||
"QUEUE_MAX_AGE",
|
|
||||||
hdlr.params.Config.QueueMaxAge,
|
|
||||||
)
|
|
||||||
if queueMaxAge > 0 {
|
|
||||||
queueCutoff := time.Now().Add(-queueMaxAge)
|
|
||||||
|
|
||||||
pruned, err := hdlr.params.Database.
|
|
||||||
PruneOldQueueEntries(ctx, queueCutoff)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"client output queue pruning failed", "error", err,
|
|
||||||
)
|
|
||||||
} else if pruned > 0 {
|
|
||||||
hdlr.log.Info(
|
|
||||||
"pruned old client output queue entries",
|
|
||||||
"deleted", pruned,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messageMaxAge := hdlr.parseDurationConfig(
|
|
||||||
"MESSAGE_MAX_AGE",
|
|
||||||
hdlr.params.Config.MessageMaxAge,
|
|
||||||
)
|
|
||||||
if messageMaxAge > 0 {
|
|
||||||
msgCutoff := time.Now().Add(-messageMaxAge)
|
|
||||||
|
|
||||||
pruned, err := hdlr.params.Database.
|
|
||||||
PruneOldMessages(ctx, msgCutoff)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"message pruning failed", "error", err,
|
|
||||||
)
|
|
||||||
} else if pruned > 0 {
|
|
||||||
hdlr.log.Info(
|
|
||||||
"pruned old messages",
|
|
||||||
"deleted", pruned,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (hdlr *Handlers) HandleHealthCheck() http.HandlerFunc {
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
resp := hdlr.hc.Healthcheck(request.Context())
|
resp := hdlr.hc.Healthcheck()
|
||||||
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
hdlr.respondJSON(writer, request, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/chat/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *db.Database
|
Database *db.Database
|
||||||
Stats *stats.Tracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck tracks server uptime and provides health status.
|
// Healthcheck tracks server uptime and provides health status.
|
||||||
@@ -66,22 +64,11 @@ type Response struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Appname string `json:"appname"`
|
Appname string `json:"appname"`
|
||||||
Maintenance bool `json:"maintenanceMode"`
|
Maintenance bool `json:"maintenanceMode"`
|
||||||
|
|
||||||
// Runtime statistics.
|
|
||||||
Sessions int64 `json:"sessions"`
|
|
||||||
Clients int64 `json:"clients"`
|
|
||||||
QueuedLines int64 `json:"queuedLines"`
|
|
||||||
Channels int64 `json:"channels"`
|
|
||||||
ConnectionsSinceBoot int64 `json:"connectionsSinceBoot"`
|
|
||||||
SessionsSinceBoot int64 `json:"sessionsSinceBoot"`
|
|
||||||
MessagesSinceBoot int64 `json:"messagesSinceBoot"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthcheck returns the current health status of the server.
|
// Healthcheck returns the current health status of the server.
|
||||||
func (hcheck *Healthcheck) Healthcheck(
|
func (hcheck *Healthcheck) Healthcheck() *Response {
|
||||||
ctx context.Context,
|
return &Response{
|
||||||
) *Response {
|
|
||||||
resp := &Response{
|
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
UptimeSeconds: int64(hcheck.uptime().Seconds()),
|
||||||
@@ -89,64 +76,6 @@ func (hcheck *Healthcheck) Healthcheck(
|
|||||||
Appname: hcheck.params.Globals.Appname,
|
Appname: hcheck.params.Globals.Appname,
|
||||||
Version: hcheck.params.Globals.Version,
|
Version: hcheck.params.Globals.Version,
|
||||||
Maintenance: hcheck.params.Config.MaintenanceMode,
|
Maintenance: hcheck.params.Config.MaintenanceMode,
|
||||||
|
|
||||||
Sessions: 0,
|
|
||||||
Clients: 0,
|
|
||||||
QueuedLines: 0,
|
|
||||||
Channels: 0,
|
|
||||||
ConnectionsSinceBoot: hcheck.params.Stats.ConnectionsSinceBoot(),
|
|
||||||
SessionsSinceBoot: hcheck.params.Stats.SessionsSinceBoot(),
|
|
||||||
MessagesSinceBoot: hcheck.params.Stats.MessagesSinceBoot(),
|
|
||||||
}
|
|
||||||
|
|
||||||
hcheck.populateDBStats(ctx, resp)
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// populateDBStats fills in database-derived counters.
|
|
||||||
func (hcheck *Healthcheck) populateDBStats(
|
|
||||||
ctx context.Context,
|
|
||||||
resp *Response,
|
|
||||||
) {
|
|
||||||
sessions, err := hcheck.params.Database.GetUserCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: session count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Sessions = sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
clients, err := hcheck.params.Database.GetClientCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: client count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Clients = clients
|
|
||||||
}
|
|
||||||
|
|
||||||
queued, err := hcheck.params.Database.GetQueueEntryCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: queue entry count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.QueuedLines = queued
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, err := hcheck.params.Database.GetChannelCount(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hcheck.log.Error(
|
|
||||||
"healthcheck: channel count failed",
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
resp.Channels = channels
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package middleware provides HTTP middleware for the neoirc server.
|
// Package middleware provides HTTP middleware for the chat server.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth returns middleware that performs authentication.
|
||||||
|
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
mware.log.Info("AUTH: before request")
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
@@ -166,36 +180,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cspPolicy is the Content-Security-Policy header value applied to all
|
|
||||||
// responses. The embedded SPA loads scripts and styles from same-origin
|
|
||||||
// files only (no inline scripts or inline style attributes), so a strict
|
|
||||||
// policy works without 'unsafe-inline'.
|
|
||||||
const cspPolicy = "default-src 'self'; " +
|
|
||||||
"script-src 'self'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"connect-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'self'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"frame-ancestors 'none'; " +
|
|
||||||
"base-uri 'self'; " +
|
|
||||||
"form-action 'self'"
|
|
||||||
|
|
||||||
// CSP returns middleware that sets the Content-Security-Policy header on
|
|
||||||
// every response for defense-in-depth against XSS.
|
|
||||||
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
cspPolicy,
|
|
||||||
)
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/chat/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,7 +29,6 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
srv.router.Use(srv.mw.CSP())
|
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package server implements the main HTTP server for the neoirc application.
|
// Package server implements the main HTTP server for the chat application.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/chat/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/chat/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
"git.eeqj.de/sneak/chat/internal/handlers"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/chat/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/chat/internal/middleware"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package irc
|
|
||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
|
||||||
const (
|
|
||||||
CmdAway = "AWAY"
|
|
||||||
CmdJoin = "JOIN"
|
|
||||||
CmdList = "LIST"
|
|
||||||
CmdLusers = "LUSERS"
|
|
||||||
CmdMode = "MODE"
|
|
||||||
CmdMotd = "MOTD"
|
|
||||||
CmdNames = "NAMES"
|
|
||||||
CmdNick = "NICK"
|
|
||||||
CmdNotice = "NOTICE"
|
|
||||||
CmdOper = "OPER"
|
|
||||||
CmdPart = "PART"
|
|
||||||
CmdPing = "PING"
|
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IRCMessageType represents an IRC numeric reply or error code.
|
|
||||||
type IRCMessageType int //nolint:revive // Name requested by project owner.
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for this numeric code
|
|
||||||
// (e.g., IRCMessageType(252).Name() returns "RPL_LUSEROP").
|
|
||||||
// Returns an empty string if the code is unknown.
|
|
||||||
func (t IRCMessageType) Name() string {
|
|
||||||
return names[t]
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the name and numeric code in angle brackets
|
|
||||||
// (e.g., IRCMessageType(252).String() returns "RPL_LUSEROP <252>").
|
|
||||||
// If the code is unknown, returns "UNKNOWN <NNN>".
|
|
||||||
func (t IRCMessageType) String() string {
|
|
||||||
n := names[t]
|
|
||||||
if n == "" {
|
|
||||||
n = "UNKNOWN"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s <%03d>", n, int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code returns the three-digit zero-padded string representation
|
|
||||||
// of the numeric code (e.g., IRCMessageType(252).Code() returns "252").
|
|
||||||
func (t IRCMessageType) Code() string {
|
|
||||||
return fmt.Sprintf("%03d", int(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Int returns the bare integer value of the numeric code.
|
|
||||||
func (t IRCMessageType) Int() int {
|
|
||||||
return int(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrUnknownNumeric is returned by FromInt when the numeric code is not recognized.
|
|
||||||
var ErrUnknownNumeric = errors.New("unknown IRC numeric code")
|
|
||||||
|
|
||||||
// FromInt converts an integer to an IRCMessageType, returning an error
|
|
||||||
// if the numeric code is not a known IRC reply or error code.
|
|
||||||
func FromInt(n int) (IRCMessageType, error) {
|
|
||||||
t := IRCMessageType(n)
|
|
||||||
if _, ok := names[t]; !ok {
|
|
||||||
return 0, fmt.Errorf("%w: %d", ErrUnknownNumeric, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome IRCMessageType = 1
|
|
||||||
RplYourHost IRCMessageType = 2
|
|
||||||
RplCreated IRCMessageType = 3
|
|
||||||
RplMyInfo IRCMessageType = 4
|
|
||||||
RplBounce IRCMessageType = 5 // RFC 2812; also known as RPL_ISUPPORT in practice
|
|
||||||
RplIsupport IRCMessageType = 5 // De-facto standard (same numeric as RplBounce)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
// RFC 2812 trace/stats/links replies (200-219).
|
|
||||||
RplTraceLink IRCMessageType = 200
|
|
||||||
RplTraceConnecting IRCMessageType = 201
|
|
||||||
RplTraceHandshake IRCMessageType = 202
|
|
||||||
RplTraceUnknown IRCMessageType = 203
|
|
||||||
RplTraceOperator IRCMessageType = 204
|
|
||||||
RplTraceUser IRCMessageType = 205
|
|
||||||
RplTraceServer IRCMessageType = 206
|
|
||||||
RplTraceService IRCMessageType = 207
|
|
||||||
RplTraceNewType IRCMessageType = 208
|
|
||||||
RplTraceClass IRCMessageType = 209
|
|
||||||
RplStatsLinkInfo IRCMessageType = 211
|
|
||||||
RplStatsCommands IRCMessageType = 212
|
|
||||||
RplStatsCLine IRCMessageType = 213
|
|
||||||
RplStatsNLine IRCMessageType = 214
|
|
||||||
RplStatsILine IRCMessageType = 215
|
|
||||||
RplStatsKLine IRCMessageType = 216
|
|
||||||
RplStatsQLine IRCMessageType = 217
|
|
||||||
RplStatsYLine IRCMessageType = 218
|
|
||||||
RplEndOfStats IRCMessageType = 219
|
|
||||||
|
|
||||||
RplUmodeIs IRCMessageType = 221
|
|
||||||
RplServList IRCMessageType = 234
|
|
||||||
RplServListEnd IRCMessageType = 235
|
|
||||||
RplStatsLLine IRCMessageType = 241
|
|
||||||
RplStatsUptime IRCMessageType = 242
|
|
||||||
RplStatsOLine IRCMessageType = 243
|
|
||||||
RplStatsHLine IRCMessageType = 244
|
|
||||||
RplLuserClient IRCMessageType = 251
|
|
||||||
RplLuserOp IRCMessageType = 252
|
|
||||||
RplLuserUnknown IRCMessageType = 253
|
|
||||||
|
|
||||||
RplLuserChannels IRCMessageType = 254
|
|
||||||
RplLuserMe IRCMessageType = 255
|
|
||||||
RplAdminMe IRCMessageType = 256
|
|
||||||
RplAdminLoc1 IRCMessageType = 257
|
|
||||||
RplAdminLoc2 IRCMessageType = 258
|
|
||||||
RplAdminEmail IRCMessageType = 259
|
|
||||||
RplTraceLog IRCMessageType = 261
|
|
||||||
RplTraceEnd IRCMessageType = 262
|
|
||||||
RplTryAgain IRCMessageType = 263
|
|
||||||
|
|
||||||
RplAway IRCMessageType = 301
|
|
||||||
RplUserHost IRCMessageType = 302
|
|
||||||
RplIson IRCMessageType = 303
|
|
||||||
RplUnaway IRCMessageType = 305
|
|
||||||
RplNowAway IRCMessageType = 306
|
|
||||||
RplWhoisUser IRCMessageType = 311
|
|
||||||
RplWhoisServer IRCMessageType = 312
|
|
||||||
RplWhoisOperator IRCMessageType = 313
|
|
||||||
RplWhoWasUser IRCMessageType = 314
|
|
||||||
RplEndOfWho IRCMessageType = 315
|
|
||||||
RplWhoisIdle IRCMessageType = 317
|
|
||||||
RplEndOfWhois IRCMessageType = 318
|
|
||||||
RplWhoisChannels IRCMessageType = 319
|
|
||||||
RplListStart IRCMessageType = 321
|
|
||||||
RplList IRCMessageType = 322
|
|
||||||
RplListEnd IRCMessageType = 323
|
|
||||||
RplChannelModeIs IRCMessageType = 324
|
|
||||||
|
|
||||||
RplUniqOpIs IRCMessageType = 325
|
|
||||||
RplCreationTime IRCMessageType = 329
|
|
||||||
RplNoTopic IRCMessageType = 331
|
|
||||||
RplTopic IRCMessageType = 332
|
|
||||||
RplTopicWhoTime IRCMessageType = 333
|
|
||||||
RplWhoisActually IRCMessageType = 338
|
|
||||||
RplInviting IRCMessageType = 341
|
|
||||||
RplSummoning IRCMessageType = 342
|
|
||||||
RplInviteList IRCMessageType = 346
|
|
||||||
RplEndOfInviteList IRCMessageType = 347
|
|
||||||
RplExceptList IRCMessageType = 348
|
|
||||||
RplEndOfExceptList IRCMessageType = 349
|
|
||||||
RplVersion IRCMessageType = 351
|
|
||||||
RplWhoReply IRCMessageType = 352
|
|
||||||
RplNamReply IRCMessageType = 353
|
|
||||||
RplLinks IRCMessageType = 364
|
|
||||||
RplEndOfLinks IRCMessageType = 365
|
|
||||||
RplEndOfNames IRCMessageType = 366
|
|
||||||
RplBanList IRCMessageType = 367
|
|
||||||
RplEndOfBanList IRCMessageType = 368
|
|
||||||
RplEndOfWhowas IRCMessageType = 369
|
|
||||||
RplInfo IRCMessageType = 371
|
|
||||||
RplMotd IRCMessageType = 372
|
|
||||||
RplEndOfInfo IRCMessageType = 374
|
|
||||||
RplMotdStart IRCMessageType = 375
|
|
||||||
RplEndOfMotd IRCMessageType = 376
|
|
||||||
RplYoureOper IRCMessageType = 381
|
|
||||||
RplRehashing IRCMessageType = 382
|
|
||||||
RplYoureService IRCMessageType = 383
|
|
||||||
RplTime IRCMessageType = 391
|
|
||||||
RplUsersStart IRCMessageType = 392
|
|
||||||
RplUsers IRCMessageType = 393
|
|
||||||
RplEndOfUsers IRCMessageType = 394
|
|
||||||
RplNoUsers IRCMessageType = 395
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick IRCMessageType = 401
|
|
||||||
ErrNoSuchServer IRCMessageType = 402
|
|
||||||
ErrNoSuchChannel IRCMessageType = 403
|
|
||||||
ErrCannotSendToChan IRCMessageType = 404
|
|
||||||
ErrTooManyChannels IRCMessageType = 405
|
|
||||||
ErrWasNoSuchNick IRCMessageType = 406
|
|
||||||
ErrTooManyTargets IRCMessageType = 407
|
|
||||||
ErrNoSuchService IRCMessageType = 408
|
|
||||||
ErrNoOrigin IRCMessageType = 409
|
|
||||||
ErrNoRecipient IRCMessageType = 411
|
|
||||||
ErrNoTextToSend IRCMessageType = 412
|
|
||||||
ErrNoTopLevel IRCMessageType = 413
|
|
||||||
ErrWildTopLevel IRCMessageType = 414
|
|
||||||
ErrBadMask IRCMessageType = 415
|
|
||||||
ErrUnknownCommand IRCMessageType = 421
|
|
||||||
ErrNoMotd IRCMessageType = 422
|
|
||||||
ErrNoAdminInfo IRCMessageType = 423
|
|
||||||
ErrFileError IRCMessageType = 424
|
|
||||||
ErrNoNicknameGiven IRCMessageType = 431
|
|
||||||
ErrErroneusNickname IRCMessageType = 432
|
|
||||||
ErrNicknameInUse IRCMessageType = 433
|
|
||||||
ErrNickCollision IRCMessageType = 436
|
|
||||||
ErrUnavailResource IRCMessageType = 437
|
|
||||||
|
|
||||||
ErrUserNotInChannel IRCMessageType = 441
|
|
||||||
ErrNotOnChannel IRCMessageType = 442
|
|
||||||
ErrUserOnChannel IRCMessageType = 443
|
|
||||||
ErrNoLogin IRCMessageType = 444
|
|
||||||
ErrSummonDisabled IRCMessageType = 445
|
|
||||||
ErrUsersDisabled IRCMessageType = 446
|
|
||||||
ErrNotRegistered IRCMessageType = 451
|
|
||||||
ErrNeedMoreParams IRCMessageType = 461
|
|
||||||
ErrAlreadyRegistered IRCMessageType = 462
|
|
||||||
ErrNoPermForHost IRCMessageType = 463
|
|
||||||
ErrPasswdMismatch IRCMessageType = 464
|
|
||||||
ErrYoureBannedCreep IRCMessageType = 465
|
|
||||||
ErrYouWillBeBanned IRCMessageType = 466
|
|
||||||
ErrKeySet IRCMessageType = 467
|
|
||||||
ErrChannelIsFull IRCMessageType = 471
|
|
||||||
ErrUnknownMode IRCMessageType = 472
|
|
||||||
ErrInviteOnlyChan IRCMessageType = 473
|
|
||||||
ErrBannedFromChan IRCMessageType = 474
|
|
||||||
ErrBadChannelKey IRCMessageType = 475
|
|
||||||
ErrBadChanMask IRCMessageType = 476
|
|
||||||
ErrNoChanModes IRCMessageType = 477
|
|
||||||
ErrBanListFull IRCMessageType = 478
|
|
||||||
ErrNoPrivileges IRCMessageType = 481
|
|
||||||
ErrChanOpPrivsNeeded IRCMessageType = 482
|
|
||||||
ErrCantKillServer IRCMessageType = 483
|
|
||||||
ErrRestricted IRCMessageType = 484
|
|
||||||
ErrUniqOpPrivsNeeded IRCMessageType = 485
|
|
||||||
ErrNoOperHost IRCMessageType = 491
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag IRCMessageType = 501
|
|
||||||
ErrUsersDoNotMatch IRCMessageType = 502
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[IRCMessageType]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplBounce: "RPL_BOUNCE",
|
|
||||||
|
|
||||||
RplTraceLink: "RPL_TRACELINK",
|
|
||||||
RplTraceConnecting: "RPL_TRACECONNECTING",
|
|
||||||
RplTraceHandshake: "RPL_TRACEHANDSHAKE",
|
|
||||||
RplTraceUnknown: "RPL_TRACEUNKNOWN",
|
|
||||||
RplTraceOperator: "RPL_TRACEOPERATOR",
|
|
||||||
RplTraceUser: "RPL_TRACEUSER",
|
|
||||||
RplTraceServer: "RPL_TRACESERVER",
|
|
||||||
RplTraceService: "RPL_TRACESERVICE",
|
|
||||||
RplTraceNewType: "RPL_TRACENEWTYPE",
|
|
||||||
RplTraceClass: "RPL_TRACECLASS",
|
|
||||||
RplStatsLinkInfo: "RPL_STATSLINKINFO",
|
|
||||||
RplStatsCommands: "RPL_STATSCOMMANDS",
|
|
||||||
RplStatsCLine: "RPL_STATSCLINE",
|
|
||||||
RplStatsNLine: "RPL_STATSNLINE",
|
|
||||||
RplStatsILine: "RPL_STATSILINE",
|
|
||||||
RplStatsKLine: "RPL_STATSKLINE",
|
|
||||||
RplStatsQLine: "RPL_STATSQLINE",
|
|
||||||
RplStatsYLine: "RPL_STATSYLINE",
|
|
||||||
RplEndOfStats: "RPL_ENDOFSTATS",
|
|
||||||
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplServList: "RPL_SERVLIST",
|
|
||||||
RplServListEnd: "RPL_SERVLISTEND",
|
|
||||||
RplStatsLLine: "RPL_STATSLLINE",
|
|
||||||
RplStatsUptime: "RPL_STATSUPTIME",
|
|
||||||
RplStatsOLine: "RPL_STATSOLINE",
|
|
||||||
RplStatsHLine: "RPL_STATSHLINE",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAdminMe: "RPL_ADMINME",
|
|
||||||
RplAdminLoc1: "RPL_ADMINLOC1",
|
|
||||||
RplAdminLoc2: "RPL_ADMINLOC2",
|
|
||||||
RplAdminEmail: "RPL_ADMINEMAIL",
|
|
||||||
RplTraceLog: "RPL_TRACELOG",
|
|
||||||
RplTraceEnd: "RPL_TRACEEND",
|
|
||||||
RplTryAgain: "RPL_TRYAGAIN",
|
|
||||||
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplWhoWasUser: "RPL_WHOWASUSER",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplListStart: "RPL_LISTSTART",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
|
|
||||||
RplUniqOpIs: "RPL_UNIQOPIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplWhoisActually: "RPL_WHOISACTUALLY",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplSummoning: "RPL_SUMMONING",
|
|
||||||
RplInviteList: "RPL_INVITELIST",
|
|
||||||
RplEndOfInviteList: "RPL_ENDOFINVITELIST",
|
|
||||||
RplExceptList: "RPL_EXCEPTLIST",
|
|
||||||
RplEndOfExceptList: "RPL_ENDOFEXCEPTLIST",
|
|
||||||
RplVersion: "RPL_VERSION",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplLinks: "RPL_LINKS",
|
|
||||||
RplEndOfLinks: "RPL_ENDOFLINKS",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplEndOfWhowas: "RPL_ENDOFWHOWAS",
|
|
||||||
RplInfo: "RPL_INFO",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplEndOfInfo: "RPL_ENDOFINFO",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
RplYoureOper: "RPL_YOUREOPER",
|
|
||||||
RplRehashing: "RPL_REHASHING",
|
|
||||||
RplYoureService: "RPL_YOURESERVICE",
|
|
||||||
RplTime: "RPL_TIME",
|
|
||||||
RplUsersStart: "RPL_USERSSTART",
|
|
||||||
RplUsers: "RPL_USERS",
|
|
||||||
RplEndOfUsers: "RPL_ENDOFUSERS",
|
|
||||||
RplNoUsers: "RPL_NOUSERS",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrWasNoSuchNick: "ERR_WASNOSUCHNICK",
|
|
||||||
ErrTooManyTargets: "ERR_TOOMANYTARGETS",
|
|
||||||
ErrNoSuchService: "ERR_NOSUCHSERVICE",
|
|
||||||
ErrNoOrigin: "ERR_NOORIGIN",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrNoTopLevel: "ERR_NOTOPLEVEL",
|
|
||||||
ErrWildTopLevel: "ERR_WILDTOPLEVEL",
|
|
||||||
ErrBadMask: "ERR_BADMASK",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoMotd: "ERR_NOMOTD",
|
|
||||||
ErrNoAdminInfo: "ERR_NOADMININFO",
|
|
||||||
ErrFileError: "ERR_FILEERROR",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrNickCollision: "ERR_NICKCOLLISION",
|
|
||||||
ErrUnavailResource: "ERR_UNAVAILRESOURCE",
|
|
||||||
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrUserOnChannel: "ERR_USERONCHANNEL",
|
|
||||||
ErrNoLogin: "ERR_NOLOGIN",
|
|
||||||
ErrSummonDisabled: "ERR_SUMMONDISABLED",
|
|
||||||
ErrUsersDisabled: "ERR_USERSDISABLED",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrNoPermForHost: "ERR_NOPERMFORHOST",
|
|
||||||
ErrPasswdMismatch: "ERR_PASSWDMISMATCH",
|
|
||||||
ErrYoureBannedCreep: "ERR_YOUREBANNEDCREEP",
|
|
||||||
ErrYouWillBeBanned: "ERR_YOUWILLBEBANNED",
|
|
||||||
ErrKeySet: "ERR_KEYSET",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrUnknownMode: "ERR_UNKNOWNMODE",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrBadChanMask: "ERR_BADCHANMASK",
|
|
||||||
ErrNoChanModes: "ERR_NOCHANMODES",
|
|
||||||
ErrBanListFull: "ERR_BANLISTFULL",
|
|
||||||
ErrNoPrivileges: "ERR_NOPRIVILEGES",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
ErrCantKillServer: "ERR_CANTKILLSERVER",
|
|
||||||
ErrRestricted: "ERR_RESTRICTED",
|
|
||||||
ErrUniqOpPrivsNeeded: "ERR_UNIQOPPRIVSNEEDED",
|
|
||||||
ErrNoOperHost: "ERR_NOOPERHOST",
|
|
||||||
|
|
||||||
ErrUmodeUnknownFlag: "ERR_UMODEUNKNOWNFLAG",
|
|
||||||
ErrUsersDoNotMatch: "ERR_USERSDONTMATCH",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
//
|
|
||||||
// Deprecated: Use IRCMessageType.Name() instead.
|
|
||||||
func Name(code IRCMessageType) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package irc_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN"},
|
|
||||||
{irc.ErrNicknameInUse, "ERR_NICKNAMEINUSE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Name(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Name() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestString(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "RPL_WELCOME <001>"},
|
|
||||||
{irc.RplBounce, "RPL_BOUNCE <005>"},
|
|
||||||
{irc.RplLuserOp, "RPL_LUSEROP <252>"},
|
|
||||||
{irc.ErrCannotSendToChan, "ERR_CANNOTSENDTOCHAN <404>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.String(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).String() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCode(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, "001"},
|
|
||||||
{irc.RplBounce, "005"},
|
|
||||||
{irc.RplLuserOp, "252"},
|
|
||||||
{irc.ErrCannotSendToChan, "404"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Code(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Code() = %q, want %q", tc.numeric.Int(), got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
numeric irc.IRCMessageType
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{irc.RplWelcome, 1},
|
|
||||||
{irc.RplBounce, 5},
|
|
||||||
{irc.RplLuserOp, 252},
|
|
||||||
{irc.ErrCannotSendToChan, 404},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
if got := tc.numeric.Int(); got != tc.want {
|
|
||||||
t.Errorf("IRCMessageType(%d).Int() = %d, want %d", tc.want, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Known(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
code int
|
|
||||||
want irc.IRCMessageType
|
|
||||||
}{
|
|
||||||
{1, irc.RplWelcome},
|
|
||||||
{5, irc.RplBounce},
|
|
||||||
{252, irc.RplLuserOp},
|
|
||||||
{404, irc.ErrCannotSendToChan},
|
|
||||||
{433, irc.ErrNicknameInUse},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
got, err := irc.FromInt(test.code)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("FromInt(%d) returned unexpected error: %v", test.code, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != test.want {
|
|
||||||
t.Errorf("FromInt(%d) = %v, want %v", test.code, got, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFromInt_Unknown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknowns := []int{0, 999, 600, -1}
|
|
||||||
for _, code := range unknowns {
|
|
||||||
_, err := irc.FromInt(code)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("FromInt(%d) expected error, got nil", code)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(err, irc.ErrUnknownNumeric) {
|
|
||||||
t.Errorf("FromInt(%d) error = %v, want ErrUnknownNumeric", code, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_Name(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
if got := unknown.Name(); got != "" {
|
|
||||||
t.Errorf("IRCMessageType(999).Name() = %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnknownNumeric_String(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
unknown := irc.IRCMessageType(999)
|
|
||||||
want := "UNKNOWN <999>"
|
|
||||||
|
|
||||||
if got := unknown.String(); got != want {
|
|
||||||
t.Errorf("IRCMessageType(999).String() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeprecatedNameFunc(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if got := irc.Name(irc.RplYourHost); got != "RPL_YOURHOST" {
|
|
||||||
t.Errorf("Name(RplYourHost) = %q, want %q", got, "RPL_YOURHOST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Message Schemas
|
# Message Schemas
|
||||||
|
|
||||||
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
|
JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use
|
||||||
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
|
||||||
over HTTP.
|
over HTTP.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"title": "PRIVMSG",
|
||||||
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"title": "PUBKEY",
|
||||||
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json",
|
||||||
"title": "TOPIC",
|
"title": "TOPIC",
|
||||||
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -17,6 +17,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["command", "to"],
|
"required": ["command", "to"],
|
||||||
"examples": [
|
"examples": [
|
||||||
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
|
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"title": "IRC Message Envelope",
|
||||||
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json",
|
||||||
"title": "002 RPL_YOURHOST",
|
"title": "002 RPL_YOURHOST",
|
||||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "002",
|
"command": "002",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"body": [
|
"body": [
|
||||||
"Your host is neoirc.example.com, running version 0.1.0"
|
"Your host is chat.example.com, running version 0.1.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json",
|
||||||
"title": "004 RPL_MYINFO",
|
"title": "004 RPL_MYINFO",
|
||||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"command": "004",
|
"command": "004",
|
||||||
"to": "alice",
|
"to": "alice",
|
||||||
"params": [
|
"params": [
|
||||||
"neoirc.example.com",
|
"chat.example.com",
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"o",
|
"o",
|
||||||
"imnst+ov"
|
"imnst+ov"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json",
|
||||||
"title": "332 RPL_TOPIC",
|
"title": "332 RPL_TOPIC",
|
||||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"#general"
|
"#general"
|
||||||
],
|
],
|
||||||
"body": [
|
"body": [
|
||||||
"Welcome to the channel"
|
"Welcome to the chat"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json",
|
||||||
"title": "401 ERR_NOSUCHNICK",
|
"title": "401 ERR_NOSUCHNICK",
|
||||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json",
|
||||||
"title": "403 ERR_NOSUCHCHANNEL",
|
"title": "403 ERR_NOSUCHCHANNEL",
|
||||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
"$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
12
web/build.sh
12
web/build.sh
@@ -16,11 +16,19 @@ fi
|
|||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Build JS bundle — preact must be bundled (no CDN/external loader)
|
# Build JS bundle
|
||||||
|
${NPX:+$NPX} esbuild src/app.jsx \
|
||||||
|
--bundle \
|
||||||
|
--minify \
|
||||||
|
--jsx-factory=h \
|
||||||
|
--jsx-fragment=Fragment \
|
||||||
|
--define:process.env.NODE_ENV=\"production\" \
|
||||||
|
--external:preact \
|
||||||
|
--outfile=dist/app.js \
|
||||||
|
2>/dev/null || \
|
||||||
${NPX:+$NPX} esbuild src/app.jsx \
|
${NPX:+$NPX} esbuild src/app.jsx \
|
||||||
--bundle \
|
--bundle \
|
||||||
--minify \
|
--minify \
|
||||||
--format=esm \
|
|
||||||
--jsx-factory=h \
|
--jsx-factory=h \
|
||||||
--jsx-fragment=Fragment \
|
--jsx-fragment=Fragment \
|
||||||
--define:process.env.NODE_ENV=\"production\" \
|
--define:process.env.NODE_ENV=\"production\" \
|
||||||
|
|||||||
2
web/dist/app.js
vendored
Normal file
2
web/dist/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
Normal file
13
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chat</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
317
web/dist/style.css
vendored
Normal file
317
web/dist/style.css
vendored
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-input: #0f3460;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #888;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent2: #0f3460;
|
||||||
|
--border: #2a2a4a;
|
||||||
|
--nick: #53a8b6;
|
||||||
|
--timestamp: #666;
|
||||||
|
--tab-active: #e94560;
|
||||||
|
--tab-bg: #16213e;
|
||||||
|
--tab-hover: #1a1a3e;
|
||||||
|
--topic-bg: #121a30;
|
||||||
|
--unread-bg: #e94560;
|
||||||
|
--warn: #f0ad4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login screen */
|
||||||
|
.login-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen h1 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen input {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen button {
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen .error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen .motd {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: var(--tab-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .close-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .close-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .unread-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--unread-bg);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status */
|
||||||
|
.connection-status {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--warn);
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic bar */
|
||||||
|
.topic-bar {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--topic-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.messages-pane {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 2px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .timestamp {
|
||||||
|
color: var(--timestamp);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .nick {
|
||||||
|
color: var(--nick);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .nick::before { content: '<'; }
|
||||||
|
.message .nick::after { content: '>'; }
|
||||||
|
|
||||||
|
.message.system {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system .nick {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.system .nick::before,
|
||||||
|
.message.system .nick::after { content: ''; }
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User list */
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list h3 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user {
|
||||||
|
padding: 3px 4px;
|
||||||
|
color: var(--nick);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user:hover {
|
||||||
|
background: var(--tab-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server tab */
|
||||||
|
.server-messages {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel join dialog */
|
||||||
|
.join-dialog {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--accent2);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.user-list { display: none; }
|
||||||
|
.tab { padding: 6px 10px; font-size: 13px; }
|
||||||
|
}
|
||||||
1258
web/src/app.jsx
1258
web/src/app.jsx
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NeoIRC</title>
|
<title>Chat</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,466 +1,317 @@
|
|||||||
* {
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0a0e14;
|
--bg: #1a1a2e;
|
||||||
--bg-panel: #0d1117;
|
--bg-secondary: #16213e;
|
||||||
--bg-input: #0d1117;
|
--bg-input: #0f3460;
|
||||||
--bg-tab: #161b22;
|
--text: #e0e0e0;
|
||||||
--bg-tab-active: #0d1117;
|
--text-muted: #888;
|
||||||
--bg-topic: #0d1117;
|
--accent: #e94560;
|
||||||
--text: #c9d1d9;
|
--accent2: #0f3460;
|
||||||
--text-dim: #6e7681;
|
--border: #2a2a4a;
|
||||||
--text-bright: #e6edf3;
|
--nick: #53a8b6;
|
||||||
--accent: #58a6ff;
|
--timestamp: #666;
|
||||||
--accent-dim: #1f6feb;
|
--tab-active: #e94560;
|
||||||
--border: #21262d;
|
--tab-bg: #16213e;
|
||||||
--system: #7d8590;
|
--tab-hover: #1a1a3e;
|
||||||
--action: #d2a8ff;
|
--topic-bg: #121a30;
|
||||||
--warn: #d29922;
|
--unread-bg: #e94560;
|
||||||
--error: #f85149;
|
--warn: #f0ad4e;
|
||||||
--unread: #f0883e;
|
|
||||||
--nick-brackets: #6e7681;
|
|
||||||
--timestamp: #484f58;
|
|
||||||
--input-bg: #161b22;
|
|
||||||
--prompt: #3fb950;
|
|
||||||
--tab-indicator: #58a6ff;
|
|
||||||
--user-list-bg: #0d1117;
|
|
||||||
--user-list-header: #484f58;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html, body, #root {
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
font-family: 'Courier New', Courier, monospace;
|
||||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
font-size: 14px;
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Login screen */
|
||||||
Login Screen
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box {
|
.login-screen h1 {
|
||||||
text-align: center;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box h1 {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 1.8em;
|
font-size: 2em;
|
||||||
margin-bottom: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box .motd {
|
.login-screen input {
|
||||||
color: var(--accent);
|
padding: 10px 16px;
|
||||||
font-size: 11px;
|
font-size: 16px;
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.2;
|
background: var(--bg-input);
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-bright);
|
color: var(--text);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
outline: none;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box input:focus {
|
.login-screen button {
|
||||||
border-color: var(--accent-dim);
|
padding: 10px 24px;
|
||||||
}
|
font-size: 16px;
|
||||||
|
|
||||||
.login-box button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box button:hover {
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-box .error {
|
.login-screen .error {
|
||||||
color: var(--error);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
.login-screen .motd {
|
||||||
IRC App Layout
|
color: var(--text-muted);
|
||||||
============================================ */
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.irc-app {
|
/* Main layout */
|
||||||
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Tab bar */
|
||||||
Tab Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg-tab);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
|
||||||
height: 32px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex: 1;
|
flex-shrink: 0;
|
||||||
scrollbar-width: none;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
.tabs::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
display: flex;
|
padding: 8px 16px;
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-dim);
|
border-bottom: 2px solid transparent;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 4px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
color: var(--text);
|
background: var(--tab-hover);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--text-bright);
|
color: var(--text);
|
||||||
background: var(--bg-tab-active);
|
border-bottom-color: var(--tab-active);
|
||||||
border-bottom: 2px solid var(--tab-indicator);
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.has-unread .tab-label {
|
.tab .close-btn {
|
||||||
color: var(--unread);
|
margin-left: 8px;
|
||||||
font-weight: bold;
|
color: var(--text-muted);
|
||||||
}
|
|
||||||
|
|
||||||
.tab .unread-count {
|
|
||||||
color: var(--unread);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close:hover {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-nick {
|
.tab .close-btn:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .unread-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--unread-bg);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warn {
|
/* Connection status */
|
||||||
color: var(--warn);
|
.connection-status {
|
||||||
animation: blink 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Topic Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.topic-bar {
|
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background: var(--bg-topic);
|
background: var(--warn);
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic bar */
|
||||||
|
.topic-bar {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--topic-bg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-label {
|
/* Content area */
|
||||||
color: var(--text-dim);
|
.content {
|
||||||
}
|
|
||||||
|
|
||||||
.topic-text {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Main Content Area
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.main-area {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* Messages */
|
||||||
Messages Panel
|
.messages-pane {
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.messages-panel {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-scroll {
|
.messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 8px;
|
padding: 8px 12px;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Message Lines
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 1px 0;
|
padding: 2px 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .timestamp {
|
.message .timestamp {
|
||||||
color: var(--timestamp);
|
color: var(--timestamp);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .nick {
|
.message .nick {
|
||||||
|
color: var(--nick);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message .content {
|
.message .nick::before { content: '<'; }
|
||||||
color: var(--text);
|
.message .nick::after { content: '>'; }
|
||||||
}
|
|
||||||
|
|
||||||
/* System messages (joins, parts, quits, etc.) */
|
.message.system {
|
||||||
.system-message {
|
color: var(--text-muted);
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-message .system-text {
|
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* /me action messages */
|
|
||||||
.action-message .action-text {
|
|
||||||
color: var(--action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
User List (Right Panel)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
width: 160px;
|
|
||||||
background: var(--user-list-bg);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-header {
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--user-list-header);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-entries {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
flex: 1;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry {
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-prefix {
|
|
||||||
color: var(--text-dim);
|
|
||||||
display: inline-block;
|
|
||||||
width: 1ch;
|
|
||||||
text-align: right;
|
|
||||||
margin-right: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-name {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Input Line (Bottom)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.input-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
color: var(--prompt);
|
|
||||||
font-size: 13px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
outline: none;
|
|
||||||
caret-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input::placeholder {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
.message.system .nick {
|
||||||
Responsive
|
color: var(--text-muted);
|
||||||
============================================ */
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
.message.system .nick::before,
|
||||||
.user-list {
|
.message.system .nick::after { content: ''; }
|
||||||
display: none;
|
|
||||||
}
|
/* Input */
|
||||||
|
.input-bar {
|
||||||
.tab {
|
display: flex;
|
||||||
padding: 0 8px;
|
border-top: 1px solid var(--border);
|
||||||
font-size: 11px;
|
background: var(--bg-secondary);
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.input-prompt {
|
|
||||||
font-size: 12px;
|
.input-bar input {
|
||||||
}
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User list */
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list h3 {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user {
|
||||||
|
padding: 3px 4px;
|
||||||
|
color: var(--nick);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list .user:hover {
|
||||||
|
background: var(--tab-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server tab */
|
||||||
|
.server-messages {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel join dialog */
|
||||||
|
.join-dialog {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-dialog button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--accent2);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.user-list { display: none; }
|
||||||
|
.tab { padding: 6px 10px; font-size: 13px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user