Compare commits
2 Commits
feat/add-c
...
a193831ed4
| Author | SHA1 | Date | |
|---|---|---|---|
| a193831ed4 | |||
|
|
8d26df60db |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,7 +21,6 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Build artifacts
|
||||
web/dist/
|
||||
/neoircd
|
||||
/bin/
|
||||
*.exe
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,13 +1,3 @@
|
||||
# Web build stage — compile SPA from source
|
||||
# node:22-alpine, 2026-03-09
|
||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/src/ src/
|
||||
COPY web/build.sh build.sh
|
||||
RUN sh build.sh
|
||||
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||
@@ -15,9 +5,6 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
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 lint
|
||||
|
||||
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=web-builder /web/dist/ web/dist/
|
||||
|
||||
RUN make test
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -1032,12 +1032,6 @@ Return the current user's session state.
|
||||
|
||||
**Request:** No body. Requires auth.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|--------|---------|-------------|
|
||||
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
@@ -1070,12 +1064,6 @@ curl -s http://localhost:8080/api/v1/state \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
**Reconnect with channel state initialization:**
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||
|
||||
Retrieve messages from the client's delivery queue. This is the primary
|
||||
@@ -1374,18 +1362,16 @@ Return server metadata. No authentication required.
|
||||
```json
|
||||
{
|
||||
"name": "My NeoIRC Server",
|
||||
"version": "0.1.0",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| Field | Type | Description |
|
||||
|---------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
@@ -1852,16 +1838,26 @@ docker run -p 8080:8080 \
|
||||
neoirc
|
||||
```
|
||||
|
||||
The Dockerfile is a four-stage build:
|
||||
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
|
||||
bundled JS via esbuild) into `web/dist/`
|
||||
2. **lint**: Runs formatting checks and golangci-lint against the Go source
|
||||
(uses empty placeholder files for `web/dist/` so it runs independently of
|
||||
web-builder for fast feedback)
|
||||
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
|
||||
binaries with the real SPA assets from web-builder (CLI built to verify
|
||||
The Dockerfile is a multi-stage build:
|
||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||
compilation, not included in final image)
|
||||
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache make
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o /neoircd ./cmd/neoircd/
|
||||
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||
EXPOSE 8080
|
||||
CMD ["neoircd"]
|
||||
```
|
||||
|
||||
### Binary
|
||||
|
||||
@@ -2310,14 +2306,10 @@ neoirc/
|
||||
│ └── http.go # HTTP timeouts
|
||||
├── web/
|
||||
│ ├── embed.go # go:embed directive for SPA
|
||||
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||
│ ├── package-lock.json
|
||||
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||
│ │ ├── app.jsx
|
||||
│ │ ├── index.html
|
||||
│ │ └── style.css
|
||||
│ └── dist/ # Generated at Docker build time (not committed)
|
||||
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
||||
│ ├── index.html
|
||||
│ ├── style.css
|
||||
│ └── app.js
|
||||
├── schema/ # JSON Schema definitions (planned)
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-03-09
|
||||
last_modified: 2026-02-22
|
||||
---
|
||||
|
||||
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
|
||||
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 force-push to `main`.
|
||||
@@ -151,14 +144,8 @@ style conventions are in separate documents:
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
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.
|
||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||
Post-1.0.0: add new migration files.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -170,7 +168,7 @@ func (client *Client) PollMessages(
|
||||
func (client *Client) JoinChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: irc.CmdJoin, To: channel,
|
||||
Command: "JOIN", To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -179,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
|
||||
func (client *Client) PartChannel(channel string) error {
|
||||
return client.SendMessage(
|
||||
&Message{ //nolint:exhaustruct // only command+to needed
|
||||
Command: irc.CmdPart, To: channel,
|
||||
Command: "PART", To: channel,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
Command: "PRIVMSG",
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -242,7 +241,7 @@ func (a *App) cmdNick(nick string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdNick,
|
||||
Command: "NICK",
|
||||
Body: []string{nick},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -377,7 +376,7 @@ func (a *App) cmdMsg(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdPrivmsg,
|
||||
Command: "PRIVMSG",
|
||||
To: target,
|
||||
Body: []string{text},
|
||||
})
|
||||
@@ -435,7 +434,7 @@ func (a *App) cmdTopic(args string) {
|
||||
|
||||
if args == "" {
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
Command: "TOPIC",
|
||||
To: target,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -448,7 +447,7 @@ func (a *App) cmdTopic(args string) {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdTopic,
|
||||
Command: "TOPIC",
|
||||
To: target,
|
||||
Body: []string{args},
|
||||
})
|
||||
@@ -536,7 +535,7 @@ func (a *App) cmdMotd() {
|
||||
}
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
||||
)
|
||||
if err != nil {
|
||||
a.ui.AddStatus(fmt.Sprintf(
|
||||
@@ -573,7 +572,7 @@ func (a *App) cmdWho(args string) {
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWho, To: channel,
|
||||
Command: "WHO", To: channel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -604,7 +603,7 @@ func (a *App) cmdWhois(args string) {
|
||||
|
||||
err := a.client.SendMessage(
|
||||
&api.Message{ //nolint:exhaustruct
|
||||
Command: irc.CmdWhois, To: args,
|
||||
Command: "WHOIS", To: args,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -654,7 +653,7 @@ func (a *App) cmdQuit() {
|
||||
|
||||
if a.connected && a.client != nil {
|
||||
_ = a.client.SendMessage(
|
||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||
)
|
||||
}
|
||||
|
||||
@@ -739,19 +738,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
||||
a.mu.Unlock()
|
||||
|
||||
switch msg.Command {
|
||||
case irc.CmdPrivmsg:
|
||||
case "PRIVMSG":
|
||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||
case irc.CmdJoin:
|
||||
case "JOIN":
|
||||
a.handleJoinEvent(msg, timestamp)
|
||||
case irc.CmdPart:
|
||||
case "PART":
|
||||
a.handlePartEvent(msg, timestamp)
|
||||
case irc.CmdQuit:
|
||||
case "QUIT":
|
||||
a.handleQuitEvent(msg, timestamp)
|
||||
case irc.CmdNick:
|
||||
case "NICK":
|
||||
a.handleNickEvent(msg, timestamp, myNick)
|
||||
case irc.CmdNotice:
|
||||
case "NOTICE":
|
||||
a.handleNoticeEvent(msg, timestamp)
|
||||
case irc.CmdTopic:
|
||||
case "TOPIC":
|
||||
a.handleTopicEvent(msg, timestamp)
|
||||
default:
|
||||
a.handleDefaultEvent(msg, timestamp)
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,6 @@ func generateToken() (string, error) {
|
||||
type IRCMessage struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Code int `json:"code,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
@@ -45,15 +42,6 @@ type IRCMessage struct {
|
||||
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.
|
||||
type ChannelInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -729,15 +717,6 @@ func scanMessages(
|
||||
msg.DBID = qID
|
||||
lastQID = qID
|
||||
|
||||
if isNumericCode(msg.Command) {
|
||||
code, _ := strconv.Atoi(msg.Command)
|
||||
msg.Code = code
|
||||
|
||||
if name := irc.Name(code); name != "" {
|
||||
msg.Command = name
|
||||
}
|
||||
}
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
@@ -28,6 +27,15 @@ const (
|
||||
defaultMaxBodySize = 4096
|
||||
defaultHistLimit = 50
|
||||
maxHistLimit = 500
|
||||
cmdJoin = "JOIN"
|
||||
cmdList = "LIST"
|
||||
cmdNick = "NICK"
|
||||
cmdPart = "PART"
|
||||
cmdPrivmsg = "PRIVMSG"
|
||||
cmdQuit = "QUIT"
|
||||
cmdTopic = "TOPIC"
|
||||
cmdWho = "WHO"
|
||||
cmdWhois = "WHOIS"
|
||||
)
|
||||
|
||||
func (hdlr *Handlers) maxBodySize() int64 {
|
||||
@@ -232,20 +240,20 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
|
||||
// 001 RPL_WELCOME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWelcome, nick, nil,
|
||||
ctx, clientID, "001", nick, nil,
|
||||
"Welcome to the network, "+nick,
|
||||
)
|
||||
|
||||
// 002 RPL_YOURHOST
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplYourHost, nick, nil,
|
||||
ctx, clientID, "002", nick, nil,
|
||||
"Your host is "+srvName+
|
||||
", running version "+version,
|
||||
)
|
||||
|
||||
// 003 RPL_CREATED
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplCreated, nick, nil,
|
||||
ctx, clientID, "003", nick, nil,
|
||||
"This server was created "+
|
||||
hdlr.params.Globals.StartTime.
|
||||
Format("2006-01-02"),
|
||||
@@ -253,14 +261,14 @@ func (hdlr *Handlers) deliverWelcome(
|
||||
|
||||
// 004 RPL_MYINFO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplMyInfo, nick,
|
||||
ctx, clientID, "004", nick,
|
||||
[]string{srvName, version, "", "imnst"},
|
||||
"",
|
||||
)
|
||||
|
||||
// 005 RPL_ISUPPORT
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplIsupport, nick,
|
||||
ctx, clientID, "005", nick,
|
||||
[]string{
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
@@ -305,7 +313,7 @@ func (hdlr *Handlers) deliverLusers(
|
||||
|
||||
// 251 RPL_LUSERCLIENT
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplLuserClient, nick, nil,
|
||||
ctx, clientID, "251", nick, nil,
|
||||
fmt.Sprintf(
|
||||
"There are %d users and 0 invisible on 1 servers",
|
||||
userCount,
|
||||
@@ -314,21 +322,21 @@ func (hdlr *Handlers) deliverLusers(
|
||||
|
||||
// 252 RPL_LUSEROP
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplLuserOp, nick,
|
||||
ctx, clientID, "252", nick,
|
||||
[]string{"0"},
|
||||
"operator(s) online",
|
||||
)
|
||||
|
||||
// 254 RPL_LUSERCHANNELS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplLuserChannels, nick,
|
||||
ctx, clientID, "254", nick,
|
||||
[]string{strconv.FormatInt(chanCount, 10)},
|
||||
"channels formed",
|
||||
)
|
||||
|
||||
// 255 RPL_LUSERME
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplLuserMe, nick, nil,
|
||||
ctx, clientID, "255", nick, nil,
|
||||
fmt.Sprintf(
|
||||
"I have %d clients and 1 servers",
|
||||
userCount,
|
||||
@@ -366,19 +374,19 @@ func (hdlr *Handlers) deliverMOTD(
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplMotdStart, nick, nil,
|
||||
ctx, clientID, "375", nick, nil,
|
||||
"- "+srvName+" Message of the Day -",
|
||||
)
|
||||
|
||||
for line := range strings.SplitSeq(motd, "\n") {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplMotd, nick, nil,
|
||||
ctx, clientID, "372", nick, nil,
|
||||
"- "+line,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfMotd, nick, nil,
|
||||
ctx, clientID, "376", nick, nil,
|
||||
"End of /MOTD command.",
|
||||
)
|
||||
|
||||
@@ -397,13 +405,10 @@ func (hdlr *Handlers) serverName() string {
|
||||
func (hdlr *Handlers) enqueueNumeric(
|
||||
ctx context.Context,
|
||||
clientID int64,
|
||||
code int,
|
||||
nick string,
|
||||
command, nick string,
|
||||
params []string,
|
||||
text string,
|
||||
) {
|
||||
command := fmt.Sprintf("%03d", code)
|
||||
|
||||
body, err := json.Marshal([]string{text})
|
||||
if err != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -444,17 +449,13 @@ func (hdlr *Handlers) enqueueNumeric(
|
||||
}
|
||||
|
||||
// HandleState returns the current session's info and
|
||||
// channels. When called with ?initChannelState=1, it also
|
||||
// enqueues synthetic JOIN + TOPIC + NAMES messages for
|
||||
// every channel the session belongs to so that a
|
||||
// reconnecting client can rebuild its channel tabs from
|
||||
// the message stream.
|
||||
// channels.
|
||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
sessionID, clientID, nick, ok :=
|
||||
sessionID, _, nick, ok :=
|
||||
hdlr.requireAuth(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
@@ -476,12 +477,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if request.URL.Query().Get("initChannelState") == "1" {
|
||||
hdlr.initChannelState(
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"id": sessionID,
|
||||
"nick": nick,
|
||||
@@ -490,52 +485,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// initChannelState enqueues synthetic JOIN messages and
|
||||
// join-numerics (TOPIC, NAMES) for every channel the
|
||||
// session belongs to. Messages are enqueued only to the
|
||||
// specified client so other clients/sessions are not
|
||||
// affected.
|
||||
func (hdlr *Handlers) initChannelState(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
channels, err := hdlr.params.Database.
|
||||
GetSessionChannels(ctx, sessionID)
|
||||
if err != nil || len(channels) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, chanInfo := range channels {
|
||||
// Enqueue a synthetic JOIN (only to this client).
|
||||
dbID, _, insErr := hdlr.params.Database.
|
||||
InsertMessage(
|
||||
ctx, "JOIN", nick, chanInfo.Name,
|
||||
nil, nil, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
hdlr.log.Error(
|
||||
"initChannelState: insert JOIN",
|
||||
"error", insErr,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
_ = hdlr.params.Database.EnqueueToClient(
|
||||
ctx, clientID, dbID,
|
||||
)
|
||||
|
||||
// Enqueue TOPIC + NAMES numerics.
|
||||
hdlr.deliverJoinNumerics(
|
||||
request, clientID, sessionID,
|
||||
nick, chanInfo.Name, chanInfo.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListAllChannels returns all channels on the server.
|
||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||
return func(
|
||||
@@ -809,38 +758,38 @@ func (hdlr *Handlers) dispatchCommand(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||
case cmdPrivmsg, "NOTICE":
|
||||
hdlr.handlePrivmsg(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
command, target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdJoin:
|
||||
case cmdJoin:
|
||||
hdlr.handleJoin(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target,
|
||||
)
|
||||
case irc.CmdPart:
|
||||
case cmdPart:
|
||||
hdlr.handlePart(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target, body,
|
||||
)
|
||||
case irc.CmdNick:
|
||||
case cmdNick:
|
||||
hdlr.handleNick(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, bodyLines,
|
||||
)
|
||||
case irc.CmdTopic:
|
||||
case cmdTopic:
|
||||
hdlr.handleTopic(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, body, bodyLines,
|
||||
)
|
||||
case irc.CmdQuit:
|
||||
case cmdQuit:
|
||||
hdlr.handleQuit(
|
||||
writer, request, sessionID, nick, body,
|
||||
)
|
||||
case irc.CmdMotd, irc.CmdPing:
|
||||
case "MOTD", cmdList, cmdWho, cmdWhois, "PING":
|
||||
hdlr.dispatchInfoCommand(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
@@ -863,34 +812,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
switch command {
|
||||
case irc.CmdMode:
|
||||
case "MODE":
|
||||
hdlr.handleMode(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, bodyLines,
|
||||
)
|
||||
case irc.CmdNames:
|
||||
case "NAMES":
|
||||
hdlr.handleNames(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target,
|
||||
)
|
||||
case irc.CmdList:
|
||||
case cmdList:
|
||||
hdlr.handleList(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
)
|
||||
case irc.CmdWhois:
|
||||
case cmdWhois:
|
||||
hdlr.handleWhois(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
target, bodyLines,
|
||||
)
|
||||
case irc.CmdWho:
|
||||
case cmdWho:
|
||||
hdlr.handleWho(
|
||||
writer, request,
|
||||
sessionID, clientID, nick, target,
|
||||
)
|
||||
case irc.CmdLusers:
|
||||
case "LUSERS":
|
||||
hdlr.handleLusers(
|
||||
writer, request,
|
||||
sessionID, clientID, nick,
|
||||
@@ -898,7 +847,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
||||
default:
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrUnknownCommand, nick, []string{command},
|
||||
"421", nick, []string{command},
|
||||
"Unknown command",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -919,7 +868,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
if target == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"461", nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -934,7 +883,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
||||
if len(lines) == 0 {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNeedMoreParams, nick, []string{command},
|
||||
"461", nick, []string{command},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -968,14 +917,13 @@ func (hdlr *Handlers) respondIRCError(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
code int,
|
||||
nick string,
|
||||
numeric, nick string,
|
||||
params []string,
|
||||
text string,
|
||||
) {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
code, nick, params, text,
|
||||
numeric, nick, params, text,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -996,7 +944,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{target},
|
||||
"403", nick, []string{target},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
@@ -1022,7 +970,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
||||
if !isMember {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNotOnChannel, nick, []string{target},
|
||||
"442", nick, []string{target},
|
||||
"You're not on that channel",
|
||||
)
|
||||
|
||||
@@ -1089,7 +1037,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoSuchNick, nick, []string{target},
|
||||
"401", nick, []string{target},
|
||||
"No such nick/channel",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -1133,7 +1081,7 @@ func (hdlr *Handlers) handleJoin(
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
|
||||
"461", nick, []string{cmdJoin},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1148,7 +1096,7 @@ func (hdlr *Handlers) handleJoin(
|
||||
if !validChannelRe.MatchString(channel) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"403", nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
@@ -1204,7 +1152,7 @@ func (hdlr *Handlers) executeJoin(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||
request, cmdJoin, nick, channel, nil, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.deliverJoinNumerics(
|
||||
@@ -1255,12 +1203,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
||||
|
||||
if topic != "" {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplTopic, nick,
|
||||
ctx, clientID, "332", nick,
|
||||
[]string{channel}, topic,
|
||||
)
|
||||
} else {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNoTopic, nick,
|
||||
ctx, clientID, "331", nick,
|
||||
[]string{channel}, "No topic is set",
|
||||
)
|
||||
}
|
||||
@@ -1278,14 +1226,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNamReply, nick,
|
||||
ctx, clientID, "353", nick,
|
||||
[]string{"=", channel},
|
||||
strings.Join(nicks, " "),
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfNames, nick,
|
||||
ctx, clientID, "366", nick,
|
||||
[]string{channel}, "End of /NAMES list",
|
||||
)
|
||||
|
||||
@@ -1302,7 +1250,7 @@ func (hdlr *Handlers) handlePart(
|
||||
if target == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
|
||||
"461", nick, []string{cmdPart},
|
||||
"Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -1324,7 +1272,7 @@ func (hdlr *Handlers) handlePart(
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"403", nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
@@ -1340,7 +1288,7 @@ func (hdlr *Handlers) handlePart(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||
request, cmdPart, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
err = hdlr.params.Database.PartChannel(
|
||||
@@ -1382,7 +1330,7 @@ func (hdlr *Handlers) handleNick(
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
|
||||
"461", nick, []string{cmdNick},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1394,7 +1342,7 @@ func (hdlr *Handlers) handleNick(
|
||||
if !validNickRe.MatchString(newNick) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrErroneusNickname, nick, []string{newNick},
|
||||
"432", nick, []string{newNick},
|
||||
"Erroneous nickname",
|
||||
)
|
||||
|
||||
@@ -1430,7 +1378,7 @@ func (hdlr *Handlers) executeNickChange(
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||
"433", nick, []string{newNick},
|
||||
"Nickname is already in use",
|
||||
)
|
||||
|
||||
@@ -1480,7 +1428,7 @@ func (hdlr *Handlers) broadcastNick(
|
||||
}
|
||||
|
||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||
request.Context(), irc.CmdNick, oldNick, "",
|
||||
request.Context(), cmdNick, oldNick, "",
|
||||
nil, json.RawMessage(nickBody), nil,
|
||||
)
|
||||
|
||||
@@ -1521,7 +1469,7 @@ func (hdlr *Handlers) handleTopic(
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
||||
"461", nick, []string{cmdTopic},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1532,7 +1480,7 @@ func (hdlr *Handlers) handleTopic(
|
||||
if len(lines) == 0 {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
||||
"461", nick, []string{cmdTopic},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1550,7 +1498,7 @@ func (hdlr *Handlers) handleTopic(
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"403", nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
@@ -1594,12 +1542,12 @@ func (hdlr *Handlers) executeTopic(
|
||||
)
|
||||
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||
request, cmdTopic, nick, channel, body, memberIDs,
|
||||
)
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplTopic, nick, []string{channel}, topic,
|
||||
"332", nick, []string{channel}, topic,
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
@@ -1611,7 +1559,8 @@ func (hdlr *Handlers) executeTopic(
|
||||
}
|
||||
|
||||
// dispatchInfoCommand handles informational IRC commands
|
||||
// that produce server-side numerics (MOTD, PING).
|
||||
// that produce server-side numerics (MOTD, LIST, WHO,
|
||||
// WHOIS, PING).
|
||||
func (hdlr *Handlers) dispatchInfoCommand(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -1619,20 +1568,31 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
||||
nick, command, target string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
_ = target
|
||||
_ = bodyLines
|
||||
|
||||
okResp := map[string]string{"status": "ok"}
|
||||
|
||||
switch command {
|
||||
case irc.CmdMotd:
|
||||
case "MOTD":
|
||||
hdlr.deliverMOTD(
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
case irc.CmdPing:
|
||||
case cmdList:
|
||||
hdlr.handleListCmd(
|
||||
request, clientID, sessionID, nick,
|
||||
)
|
||||
case cmdWho:
|
||||
hdlr.handleWhoCmd(
|
||||
request, clientID, sessionID, nick,
|
||||
target,
|
||||
)
|
||||
case cmdWhois:
|
||||
hdlr.handleWhoisCmd(
|
||||
request, clientID, sessionID, nick,
|
||||
target, bodyLines,
|
||||
)
|
||||
case "PING":
|
||||
hdlr.respondJSON(writer, request,
|
||||
map[string]string{
|
||||
"command": irc.CmdPong,
|
||||
"command": "PONG",
|
||||
"from": hdlr.serverName(),
|
||||
},
|
||||
http.StatusOK)
|
||||
@@ -1645,6 +1605,222 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
||||
)
|
||||
}
|
||||
|
||||
// handleListCmd sends RPL_LIST (322) for each channel,
|
||||
// then sends 323 to signal the end of the list.
|
||||
func (hdlr *Handlers) handleListCmd(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
nick string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
channels, err := hdlr.params.Database.ListAllChannels(
|
||||
ctx,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "323", nick, nil,
|
||||
"End of /LIST",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, channel := range channels {
|
||||
memberIDs, _ :=
|
||||
hdlr.params.Database.GetChannelMemberIDs(
|
||||
ctx, channel.ID,
|
||||
)
|
||||
|
||||
count := strconv.Itoa(len(memberIDs))
|
||||
topic := channel.Topic
|
||||
|
||||
if topic == "" {
|
||||
topic = " "
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "322", nick,
|
||||
[]string{channel.Name, count}, topic,
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "323", nick, nil,
|
||||
"End of /LIST",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
|
||||
// of the target channel, followed by RPL_ENDOFWHO (315).
|
||||
func (hdlr *Handlers) handleWhoCmd(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
if target == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "461", nick,
|
||||
[]string{cmdWho}, "Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
channel := target
|
||||
if !strings.HasPrefix(channel, "#") {
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
ctx, channel,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "315", nick,
|
||||
[]string{channel}, "End of /WHO list",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
members, err := hdlr.params.Database.ChannelMembers(
|
||||
ctx, chID,
|
||||
)
|
||||
if err == nil {
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
for _, mem := range members {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "352", nick,
|
||||
[]string{
|
||||
channel, mem.Nick, "neoirc",
|
||||
srvName, mem.Nick, "H",
|
||||
},
|
||||
"0 "+mem.Nick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "315", nick,
|
||||
[]string{channel}, "End of /WHO list",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
|
||||
// 319, 318) for the target nick.
|
||||
func (hdlr *Handlers) handleWhoisCmd(
|
||||
request *http.Request,
|
||||
clientID, sessionID int64,
|
||||
nick, target string,
|
||||
bodyLines func() []string,
|
||||
) {
|
||||
ctx := request.Context()
|
||||
|
||||
whoisNick := target
|
||||
if whoisNick == "" {
|
||||
lines := bodyLines()
|
||||
if len(lines) > 0 {
|
||||
whoisNick = strings.TrimSpace(lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
if whoisNick == "" {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "461", nick,
|
||||
[]string{cmdWhois}, "Not enough parameters",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targetSID, err :=
|
||||
hdlr.params.Database.GetSessionByNick(
|
||||
ctx, whoisNick,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "401", nick,
|
||||
[]string{whoisNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "318", nick,
|
||||
[]string{whoisNick},
|
||||
"End of /WHOIS list",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hdlr.sendWhoisNumerics(
|
||||
ctx, clientID, sessionID, nick,
|
||||
whoisNick, targetSID,
|
||||
)
|
||||
}
|
||||
|
||||
// sendWhoisNumerics emits 311/312/319/318 for a
|
||||
// resolved WHOIS target.
|
||||
func (hdlr *Handlers) sendWhoisNumerics(
|
||||
ctx context.Context,
|
||||
clientID, sessionID int64,
|
||||
nick, whoisNick string,
|
||||
targetSID int64,
|
||||
) {
|
||||
srvName := hdlr.serverName()
|
||||
|
||||
// 311 RPL_WHOISUSER
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "311", nick,
|
||||
[]string{whoisNick, whoisNick, "neoirc", "*"},
|
||||
whoisNick,
|
||||
)
|
||||
|
||||
// 312 RPL_WHOISSERVER
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "312", nick,
|
||||
[]string{whoisNick, srvName},
|
||||
srvName,
|
||||
)
|
||||
|
||||
// 319 RPL_WHOISCHANNELS
|
||||
channels, _ := hdlr.params.Database.GetSessionChannels(
|
||||
ctx, targetSID,
|
||||
)
|
||||
|
||||
if len(channels) > 0 {
|
||||
names := make([]string, 0, len(channels))
|
||||
|
||||
for _, chanInfo := range channels {
|
||||
names = append(names, chanInfo.Name)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "319", nick,
|
||||
[]string{whoisNick},
|
||||
strings.Join(names, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// 318 RPL_ENDOFWHOIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, "318", nick,
|
||||
[]string{whoisNick},
|
||||
"End of /WHOIS list",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) handleQuit(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
@@ -1663,7 +1839,7 @@ func (hdlr *Handlers) handleQuit(
|
||||
|
||||
if len(channels) > 0 {
|
||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
||||
request.Context(), irc.CmdQuit, nick, "",
|
||||
request.Context(), cmdQuit, nick, "",
|
||||
nil, body, nil,
|
||||
)
|
||||
}
|
||||
@@ -1716,7 +1892,7 @@ func (hdlr *Handlers) handleMode(
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||
"461", nick, []string{"MODE"},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1728,7 +1904,7 @@ func (hdlr *Handlers) handleMode(
|
||||
// User mode query — return empty modes.
|
||||
hdlr.enqueueNumeric(
|
||||
request.Context(), clientID,
|
||||
irc.RplUmodeIs, nick, nil, "+",
|
||||
"221", nick, nil, "+",
|
||||
)
|
||||
hdlr.broker.Notify(sessionID)
|
||||
hdlr.respondJSON(writer, request,
|
||||
@@ -1760,7 +1936,7 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"403", nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
@@ -1769,7 +1945,7 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
|
||||
// 324 RPL_CHANNELMODEIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||
ctx, clientID, "324", nick,
|
||||
[]string{channel, "+n"}, "",
|
||||
)
|
||||
|
||||
@@ -1778,7 +1954,7 @@ func (hdlr *Handlers) handleChannelMode(
|
||||
GetChannelCreatedAt(ctx, chID)
|
||||
if timeErr == nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplCreationTime, nick,
|
||||
ctx, clientID, "329", nick,
|
||||
[]string{
|
||||
channel,
|
||||
strconv.FormatInt(
|
||||
@@ -1805,7 +1981,7 @@ func (hdlr *Handlers) handleNames(
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
|
||||
"461", nick, []string{"NAMES"},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1825,7 +2001,7 @@ func (hdlr *Handlers) handleNames(
|
||||
if err != nil {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"403", nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
@@ -1843,14 +2019,14 @@ func (hdlr *Handlers) handleNames(
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplNamReply, nick,
|
||||
ctx, clientID, "353", nick,
|
||||
[]string{"=", channel},
|
||||
strings.Join(nicks, " "),
|
||||
)
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfNames, nick,
|
||||
ctx, clientID, "366", nick,
|
||||
[]string{channel}, "End of /NAMES list",
|
||||
)
|
||||
|
||||
@@ -1888,7 +2064,7 @@ func (hdlr *Handlers) handleList(
|
||||
for _, chanInfo := range channels {
|
||||
// 322 RPL_LIST
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplList, nick,
|
||||
ctx, clientID, "322", nick,
|
||||
[]string{
|
||||
chanInfo.Name,
|
||||
strconv.FormatInt(
|
||||
@@ -1901,7 +2077,7 @@ func (hdlr *Handlers) handleList(
|
||||
|
||||
// 323 — end of channel list.
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplListEnd, nick, nil,
|
||||
ctx, clientID, "323", nick, nil,
|
||||
"End of /LIST",
|
||||
)
|
||||
|
||||
@@ -1932,7 +2108,7 @@ func (hdlr *Handlers) handleWhois(
|
||||
if queryNick == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
|
||||
"461", nick, []string{cmdWhois},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -1959,12 +2135,12 @@ func (hdlr *Handlers) executeWhois(
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.ErrNoSuchNick, nick,
|
||||
ctx, clientID, "401", nick,
|
||||
[]string{queryNick},
|
||||
"No such nick/channel",
|
||||
)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
||||
ctx, clientID, "318", nick,
|
||||
[]string{queryNick},
|
||||
"End of /WHOIS list",
|
||||
)
|
||||
@@ -1978,14 +2154,14 @@ func (hdlr *Handlers) executeWhois(
|
||||
|
||||
// 311 RPL_WHOISUSER
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoisUser, nick,
|
||||
ctx, clientID, "311", nick,
|
||||
[]string{queryNick, queryNick, srvName, "*"},
|
||||
queryNick,
|
||||
)
|
||||
|
||||
// 312 RPL_WHOISSERVER
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoisServer, nick,
|
||||
ctx, clientID, "312", nick,
|
||||
[]string{queryNick, srvName},
|
||||
"neoirc server",
|
||||
)
|
||||
@@ -1997,7 +2173,7 @@ func (hdlr *Handlers) executeWhois(
|
||||
|
||||
// 318 RPL_ENDOFWHOIS
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
||||
ctx, clientID, "318", nick,
|
||||
[]string{queryNick},
|
||||
"End of /WHOIS list",
|
||||
)
|
||||
@@ -2027,7 +2203,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
|
||||
}
|
||||
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoisChannels, nick,
|
||||
ctx, clientID, "319", nick,
|
||||
[]string{queryNick},
|
||||
strings.Join(chanNames, " "),
|
||||
)
|
||||
@@ -2043,7 +2219,7 @@ func (hdlr *Handlers) handleWho(
|
||||
if target == "" {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
|
||||
"461", nick, []string{cmdWho},
|
||||
"Not enough parameters",
|
||||
)
|
||||
|
||||
@@ -2064,7 +2240,7 @@ func (hdlr *Handlers) handleWho(
|
||||
if err != nil {
|
||||
// 315 RPL_ENDOFWHO (empty result)
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfWho, nick,
|
||||
ctx, clientID, "315", nick,
|
||||
[]string{target},
|
||||
"End of /WHO list",
|
||||
)
|
||||
@@ -2083,7 +2259,7 @@ func (hdlr *Handlers) handleWho(
|
||||
for _, mem := range members {
|
||||
// 352 RPL_WHOREPLY
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplWhoReply, nick,
|
||||
ctx, clientID, "352", nick,
|
||||
[]string{
|
||||
channel, mem.Nick, srvName,
|
||||
srvName, mem.Nick, "H",
|
||||
@@ -2095,7 +2271,7 @@ func (hdlr *Handlers) handleWho(
|
||||
|
||||
// 315 RPL_ENDOFWHO
|
||||
hdlr.enqueueNumeric(
|
||||
ctx, clientID, irc.RplEndOfWho, nick,
|
||||
ctx, clientID, "315", nick,
|
||||
[]string{channel},
|
||||
"End of /WHO list",
|
||||
)
|
||||
@@ -2331,7 +2507,7 @@ func (hdlr *Handlers) cleanupUser(
|
||||
|
||||
if len(channels) > 0 {
|
||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
ctx, cmdQuit, nick, "",
|
||||
nil, nil, nil,
|
||||
)
|
||||
}
|
||||
@@ -2392,10 +2568,9 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
||||
}
|
||||
|
||||
hdlr.respondJSON(writer, request, map[string]any{
|
||||
"name": hdlr.params.Config.ServerName,
|
||||
"version": hdlr.params.Globals.Version,
|
||||
"motd": hdlr.params.Config.MOTD,
|
||||
"users": users,
|
||||
"name": hdlr.params.Config.ServerName,
|
||||
"motd": hdlr.params.Config.MOTD,
|
||||
"users": users,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -468,11 +467,8 @@ func findNumeric(
|
||||
msgs []map[string]any,
|
||||
numeric string,
|
||||
) bool {
|
||||
want, _ := strconv.Atoi(numeric)
|
||||
|
||||
for _, msg := range msgs {
|
||||
code, ok := msg["code"].(float64)
|
||||
if ok && int(code) == want {
|
||||
if msg[commandKey] == numeric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,12 +182,6 @@ func (hdlr *Handlers) handleLogin(
|
||||
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{
|
||||
"id": sessionID,
|
||||
"nick": payload.Nick,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package irc
|
||||
|
||||
// IRC command names (RFC 1459 / RFC 2812).
|
||||
const (
|
||||
CmdJoin = "JOIN"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
CmdMode = "MODE"
|
||||
CmdMotd = "MOTD"
|
||||
CmdNames = "NAMES"
|
||||
CmdNick = "NICK"
|
||||
CmdNotice = "NOTICE"
|
||||
CmdPart = "PART"
|
||||
CmdPing = "PING"
|
||||
CmdPong = "PONG"
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
)
|
||||
@@ -1,150 +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
|
||||
|
||||
// Connection registration replies (001-005).
|
||||
const (
|
||||
RplWelcome = 1
|
||||
RplYourHost = 2
|
||||
RplCreated = 3
|
||||
RplMyInfo = 4
|
||||
RplIsupport = 5
|
||||
)
|
||||
|
||||
// Command responses (200-399).
|
||||
const (
|
||||
RplUmodeIs = 221
|
||||
RplLuserClient = 251
|
||||
RplLuserOp = 252
|
||||
RplLuserUnknown = 253
|
||||
RplLuserChannels = 254
|
||||
RplLuserMe = 255
|
||||
RplAway = 301
|
||||
RplUserHost = 302
|
||||
RplIson = 303
|
||||
RplUnaway = 305
|
||||
RplNowAway = 306
|
||||
RplWhoisUser = 311
|
||||
RplWhoisServer = 312
|
||||
RplWhoisOperator = 313
|
||||
RplEndOfWho = 315
|
||||
RplWhoisIdle = 317
|
||||
RplEndOfWhois = 318
|
||||
RplWhoisChannels = 319
|
||||
RplList = 322
|
||||
RplListEnd = 323
|
||||
RplChannelModeIs = 324
|
||||
RplCreationTime = 329
|
||||
RplNoTopic = 331
|
||||
RplTopic = 332
|
||||
RplTopicWhoTime = 333
|
||||
RplInviting = 341
|
||||
RplWhoReply = 352
|
||||
RplNamReply = 353
|
||||
RplEndOfNames = 366
|
||||
RplBanList = 367
|
||||
RplEndOfBanList = 368
|
||||
RplMotd = 372
|
||||
RplMotdStart = 375
|
||||
RplEndOfMotd = 376
|
||||
)
|
||||
|
||||
// Error replies (400-599).
|
||||
const (
|
||||
ErrNoSuchNick = 401
|
||||
ErrNoSuchServer = 402
|
||||
ErrNoSuchChannel = 403
|
||||
ErrCannotSendToChan = 404
|
||||
ErrTooManyChannels = 405
|
||||
ErrNoRecipient = 411
|
||||
ErrNoTextToSend = 412
|
||||
ErrUnknownCommand = 421
|
||||
ErrNoNicknameGiven = 431
|
||||
ErrErroneusNickname = 432
|
||||
ErrNicknameInUse = 433
|
||||
ErrUserNotInChannel = 441
|
||||
ErrNotOnChannel = 442
|
||||
ErrNotRegistered = 451
|
||||
ErrNeedMoreParams = 461
|
||||
ErrAlreadyRegistered = 462
|
||||
ErrChannelIsFull = 471
|
||||
ErrInviteOnlyChan = 473
|
||||
ErrBannedFromChan = 474
|
||||
ErrBadChannelKey = 475
|
||||
ErrChanOpPrivsNeeded = 482
|
||||
)
|
||||
|
||||
// names maps numeric codes to their standard IRC names.
|
||||
//
|
||||
//nolint:gochecknoglobals
|
||||
var names = map[int]string{
|
||||
RplWelcome: "RPL_WELCOME",
|
||||
RplYourHost: "RPL_YOURHOST",
|
||||
RplCreated: "RPL_CREATED",
|
||||
RplMyInfo: "RPL_MYINFO",
|
||||
RplIsupport: "RPL_ISUPPORT",
|
||||
RplUmodeIs: "RPL_UMODEIS",
|
||||
RplLuserClient: "RPL_LUSERCLIENT",
|
||||
RplLuserOp: "RPL_LUSEROP",
|
||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
||||
RplLuserMe: "RPL_LUSERME",
|
||||
RplAway: "RPL_AWAY",
|
||||
RplUserHost: "RPL_USERHOST",
|
||||
RplIson: "RPL_ISON",
|
||||
RplUnaway: "RPL_UNAWAY",
|
||||
RplNowAway: "RPL_NOWAWAY",
|
||||
RplWhoisUser: "RPL_WHOISUSER",
|
||||
RplWhoisServer: "RPL_WHOISSERVER",
|
||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
||||
RplEndOfWho: "RPL_ENDOFWHO",
|
||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
||||
RplList: "RPL_LIST",
|
||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
||||
RplCreationTime: "RPL_CREATIONTIME",
|
||||
RplNoTopic: "RPL_NOTOPIC",
|
||||
RplTopic: "RPL_TOPIC",
|
||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
||||
RplInviting: "RPL_INVITING",
|
||||
RplWhoReply: "RPL_WHOREPLY",
|
||||
RplNamReply: "RPL_NAMREPLY",
|
||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
||||
RplBanList: "RPL_BANLIST",
|
||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
||||
RplMotd: "RPL_MOTD",
|
||||
RplMotdStart: "RPL_MOTDSTART",
|
||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
||||
|
||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
||||
}
|
||||
|
||||
// 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.
|
||||
func Name(code int) string {
|
||||
return names[code]
|
||||
}
|
||||
@@ -16,11 +16,6 @@ import (
|
||||
|
||||
const routeTimeout = 60 * time.Second
|
||||
|
||||
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
|
||||
// The SPA loads external scripts and stylesheets from the same origin only;
|
||||
// all API communication uses same-origin fetch (no WebSockets).
|
||||
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
|
||||
|
||||
// SetupRoutes configures the HTTP routes and middleware.
|
||||
func (srv *Server) SetupRoutes() {
|
||||
srv.router = chi.NewRouter()
|
||||
@@ -138,11 +133,6 @@ func (srv *Server) setupSPA() {
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
writer.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
cspHeader,
|
||||
)
|
||||
|
||||
readFS, ok := distFS.(fs.ReadFileFS)
|
||||
if !ok {
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
|
||||
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>NeoIRC</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
466
web/dist/style.css
vendored
Normal file
466
web/dist/style.css
vendored
Normal file
@@ -0,0 +1,466 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-input: #0d1117;
|
||||
--bg-tab: #161b22;
|
||||
--bg-tab-active: #0d1117;
|
||||
--bg-topic: #0d1117;
|
||||
--text: #c9d1d9;
|
||||
--text-dim: #6e7681;
|
||||
--text-bright: #e6edf3;
|
||||
--accent: #58a6ff;
|
||||
--accent-dim: #1f6feb;
|
||||
--border: #21262d;
|
||||
--system: #7d8590;
|
||||
--action: #d2a8ff;
|
||||
--warn: #d29922;
|
||||
--error: #f85149;
|
||||
--unread: #f0883e;
|
||||
--nick-brackets: #6e7681;
|
||||
--timestamp: #484f58;
|
||||
--input-bg: #161b22;
|
||||
--prompt: #3fb950;
|
||||
--tab-indicator: #58a6ff;
|
||||
--user-list-bg: #0d1117;
|
||||
--user-list-header: #484f58;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Login Screen
|
||||
============================================ */
|
||||
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
color: var(--accent);
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-box .motd {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
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);
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-box input:focus {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box button {
|
||||
padding: 8px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--accent-dim);
|
||||
border: none;
|
||||
color: var(--text-bright);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-box button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.login-box .error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
IRC App Layout
|
||||
============================================ */
|
||||
|
||||
.irc-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tab Bar
|
||||
============================================ */
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-tab);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-tab-active);
|
||||
border-bottom: 2px solid var(--tab-indicator);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab.has-unread .tab-label {
|
||||
color: var(--unread);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab .unread-count {
|
||||
color: var(--unread);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-nick {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--warn);
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Topic Bar
|
||||
============================================ */
|
||||
|
||||
.topic-bar {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-topic);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.topic-label {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.topic-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Main Content Area
|
||||
============================================ */
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Messages Panel
|
||||
============================================ */
|
||||
|
||||
.messages-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Message Lines
|
||||
============================================ */
|
||||
|
||||
.message {
|
||||
padding: 1px 0;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message .timestamp {
|
||||
color: var(--timestamp);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message .nick {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message .content {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* System messages (joins, parts, quits, etc.) */
|
||||
.system-message {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
.system-message .system-text {
|
||||
color: var(--system);
|
||||
}
|
||||
|
||||
/* /me action messages */
|
||||
.action-message .action-text {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
User List (Right Panel)
|
||||
============================================ */
|
||||
|
||||
.user-list {
|
||||
width: 160px;
|
||||
background: var(--user-list-bg);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-list-header {
|
||||
padding: 6px 10px;
|
||||
color: var(--user-list-header);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-list-entries {
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.nick-entry {
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nick-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nick-prefix {
|
||||
color: var(--text-dim);
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
text-align: right;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.nick-name {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Input Line (Bottom)
|
||||
============================================ */
|
||||
|
||||
.input-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--input-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
color: var(--prompt);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-line input {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-bright);
|
||||
outline: none;
|
||||
caret-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-line input::placeholder {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.user-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
|
||||
.catch(() => {});
|
||||
const saved = localStorage.getItem("neoirc_token");
|
||||
if (saved) {
|
||||
api("/state?initChannelState=1")
|
||||
api("/state")
|
||||
.then((u) => onLogin(u.nick, true))
|
||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||
}
|
||||
@@ -333,24 +333,7 @@ function App() {
|
||||
case "JOIN": {
|
||||
const text = `${msg.from} has joined ${msg.to}`;
|
||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||
if (msg.to && msg.to.startsWith("#")) {
|
||||
// Create a tab when the current user joins a channel
|
||||
// (including JOINs from initChannelState on reconnect).
|
||||
if (msg.from === nickRef.current) {
|
||||
setTabs((prev) => {
|
||||
if (
|
||||
prev.find(
|
||||
(t) => t.type === "channel" && t.name === msg.to,
|
||||
)
|
||||
)
|
||||
return prev;
|
||||
|
||||
return [...prev, { type: "channel", name: msg.to }];
|
||||
});
|
||||
}
|
||||
|
||||
refreshMembers(msg.to);
|
||||
}
|
||||
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -653,13 +636,9 @@ function App() {
|
||||
setLoggedIn(true);
|
||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||
|
||||
// Request MOTD on resumed sessions (new sessions get
|
||||
// it automatically from the server during creation).
|
||||
if (isResumed) {
|
||||
// Request MOTD on resumed sessions (new sessions
|
||||
// get it automatically from the server during
|
||||
// creation). Channel state is initialized by the
|
||||
// server via the message queue
|
||||
// (?initChannelState=1), so we do not need to
|
||||
// re-JOIN channels here.
|
||||
try {
|
||||
await api("/messages", {
|
||||
method: "POST",
|
||||
@@ -668,11 +647,8 @@ function App() {
|
||||
} catch (e) {
|
||||
// MOTD is non-critical.
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh session — join any previously saved channels.
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem("neoirc_channels") || "[]",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user