Compare commits
8 Commits
bc8df117ab
...
feat/chi-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cae71171 | ||
| 67446b36a1 | |||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 | |||
| f287fdf6d1 | |||
| 687c958bd1 | |||
| 946f208ac2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
|
web/dist/
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,3 +1,13 @@
|
|||||||
|
# Web build stage — compile SPA from source
|
||||||
|
# node:22-alpine, 2026-03-09
|
||||||
|
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/src/ src/
|
||||||
|
COPY web/build.sh build.sh
|
||||||
|
RUN sh build.sh
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
@@ -5,6 +15,9 @@ 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
|
||||||
|
|
||||||
@@ -21,6 +34,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1032,6 +1032,12 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**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`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1064,6 +1070,12 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-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)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1362,16 +1374,18 @@ Return server metadata. No authentication required.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
|
"version": "0.1.0",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `motd` | string | Message of the day |
|
| `version` | string | Server version |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
| `motd` | string | Message of the day |
|
||||||
|
| `users` | integer | Number of currently active user sessions |
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1610,6 +1624,10 @@ authenticity.
|
|||||||
termination.
|
termination.
|
||||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||||
Restrict this in production via reverse proxy configuration if needed.
|
Restrict this in production via reverse proxy configuration if needed.
|
||||||
|
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
||||||
|
responses, restricting resource loading to same-origin and disabling
|
||||||
|
dangerous features (object embeds, framing, base tag injection). The
|
||||||
|
embedded SPA works without `'unsafe-inline'` for scripts or styles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1838,26 +1856,16 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a multi-stage build:
|
The Dockerfile is a four-stage build:
|
||||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
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
|
||||||
compilation, not included in final image)
|
compilation, not included in final image)
|
||||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
||||||
|
|
||||||
```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
|
### Binary
|
||||||
|
|
||||||
@@ -2306,10 +2314,14 @@ neoirc/
|
|||||||
│ └── http.go # HTTP timeouts
|
│ └── http.go # HTTP timeouts
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||||
│ ├── index.html
|
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||||
│ ├── style.css
|
│ ├── package-lock.json
|
||||||
│ └── app.js
|
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||||
|
│ │ ├── app.jsx
|
||||||
|
│ │ ├── index.html
|
||||||
|
│ │ └── style.css
|
||||||
|
│ └── dist/ # Generated at Docker build time (not committed)
|
||||||
├── schema/ # JSON Schema definitions (planned)
|
├── schema/ # JSON Schema definitions (planned)
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
@@ -2324,7 +2336,7 @@ neoirc/
|
|||||||
| Purpose | Library |
|
| Purpose | Library |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| DI | `go.uber.org/fx` |
|
| DI | `go.uber.org/fx` |
|
||||||
| Router | `github.com/go-chi/chi` |
|
| Router | `github.com/go-chi/chi/v5` |
|
||||||
| Logging | `log/slog` (stdlib) |
|
| Logging | `log/slog` (stdlib) |
|
||||||
| Config | `github.com/spf13/viper` |
|
| Config | `github.com/spf13/viper` |
|
||||||
| Env | `github.com/joho/godotenv/autoload` |
|
| Env | `github.com/joho/godotenv/autoload` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,6 +98,13 @@ 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`.
|
||||||
@@ -144,8 +151,14 @@ 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. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `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.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -168,7 +170,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: "JOIN", To: channel,
|
Command: irc.CmdJoin, To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,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: "PART", To: channel,
|
Command: irc.CmdPart, To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -86,7 +87,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: irc.CmdPrivmsg,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -241,7 +242,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "NICK",
|
Command: irc.CmdNick,
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,7 +377,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "PRIVMSG",
|
Command: irc.CmdPrivmsg,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -434,7 +435,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: "TOPIC",
|
Command: irc.CmdTopic,
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -447,7 +448,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: "TOPIC",
|
Command: irc.CmdTopic,
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -535,7 +536,7 @@ func (a *App) cmdMotd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
@@ -572,7 +573,7 @@ func (a *App) cmdWho(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: "WHO", To: channel,
|
Command: irc.CmdWho, To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -603,7 +604,7 @@ func (a *App) cmdWhois(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: "WHOIS", To: args,
|
Command: irc.CmdWhois, To: args,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -653,7 +654,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: "QUIT"}, //nolint:exhaustruct
|
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,19 +739,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case "PRIVMSG":
|
case irc.CmdPrivmsg:
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case "JOIN":
|
case irc.CmdJoin:
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case "PART":
|
case irc.CmdPart:
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case "QUIT":
|
case irc.CmdQuit:
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case "NICK":
|
case irc.CmdNick:
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case "NOTICE":
|
case irc.CmdNotice:
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case "TOPIC":
|
case irc.CmdTopic:
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -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 v1.5.5
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
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 v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
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=
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, 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,
|
||||||
|
|||||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// 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,12 +3,15 @@ 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/internal/irc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,10 +32,19 @@ 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"`
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
@@ -42,6 +54,15 @@ type IRCMessage struct {
|
|||||||
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"`
|
||||||
@@ -93,12 +114,14 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -131,6 +154,8 @@ 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
|
||||||
@@ -138,7 +163,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 = ?`,
|
||||||
token,
|
tokenHash,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
@@ -717,6 +742,15 @@ func scanMessages(
|
|||||||
msg.DBID = qID
|
msg.DBID = qID
|
||||||
lastQID = 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)
|
msgs = append(msgs, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -27,7 +29,6 @@ const (
|
|||||||
defaultMaxBodySize = 4096
|
defaultMaxBodySize = 4096
|
||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
cmdPrivmsg = "PRIVMSG"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -199,7 +200,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -232,20 +233,20 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 001 RPL_WELCOME
|
// 001 RPL_WELCOME
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "001", nick, nil,
|
ctx, clientID, irc.RplWelcome, nick, nil,
|
||||||
"Welcome to the network, "+nick,
|
"Welcome to the network, "+nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 002 RPL_YOURHOST
|
// 002 RPL_YOURHOST
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "002", nick, nil,
|
ctx, clientID, irc.RplYourHost, nick, nil,
|
||||||
"Your host is "+srvName+
|
"Your host is "+srvName+
|
||||||
", running version "+version,
|
", running version "+version,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 003 RPL_CREATED
|
// 003 RPL_CREATED
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "003", nick, nil,
|
ctx, clientID, irc.RplCreated, nick, nil,
|
||||||
"This server was created "+
|
"This server was created "+
|
||||||
hdlr.params.Globals.StartTime.
|
hdlr.params.Globals.StartTime.
|
||||||
Format("2006-01-02"),
|
Format("2006-01-02"),
|
||||||
@@ -253,14 +254,14 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 004 RPL_MYINFO
|
// 004 RPL_MYINFO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "004", nick,
|
ctx, clientID, irc.RplMyInfo, nick,
|
||||||
[]string{srvName, version, "", "imnst"},
|
[]string{srvName, version, "", "imnst"},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 005 RPL_ISUPPORT
|
// 005 RPL_ISUPPORT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "005", nick,
|
ctx, clientID, irc.RplIsupport, nick,
|
||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
@@ -305,7 +306,7 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 251 RPL_LUSERCLIENT
|
// 251 RPL_LUSERCLIENT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "251", nick, nil,
|
ctx, clientID, irc.RplLuserClient, nick, nil,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"There are %d users and 0 invisible on 1 servers",
|
"There are %d users and 0 invisible on 1 servers",
|
||||||
userCount,
|
userCount,
|
||||||
@@ -314,21 +315,21 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 252 RPL_LUSEROP
|
// 252 RPL_LUSEROP
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "252", nick,
|
ctx, clientID, irc.RplLuserOp, nick,
|
||||||
[]string{"0"},
|
[]string{"0"},
|
||||||
"operator(s) online",
|
"operator(s) online",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 254 RPL_LUSERCHANNELS
|
// 254 RPL_LUSERCHANNELS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "254", nick,
|
ctx, clientID, irc.RplLuserChannels, nick,
|
||||||
[]string{strconv.FormatInt(chanCount, 10)},
|
[]string{strconv.FormatInt(chanCount, 10)},
|
||||||
"channels formed",
|
"channels formed",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 255 RPL_LUSERME
|
// 255 RPL_LUSERME
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "255", nick, nil,
|
ctx, clientID, irc.RplLuserMe, nick, nil,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"I have %d clients and 1 servers",
|
"I have %d clients and 1 servers",
|
||||||
userCount,
|
userCount,
|
||||||
@@ -366,19 +367,19 @@ func (hdlr *Handlers) deliverMOTD(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "375", nick, nil,
|
ctx, clientID, irc.RplMotdStart, nick, nil,
|
||||||
"- "+srvName+" Message of the Day -",
|
"- "+srvName+" Message of the Day -",
|
||||||
)
|
)
|
||||||
|
|
||||||
for line := range strings.SplitSeq(motd, "\n") {
|
for line := range strings.SplitSeq(motd, "\n") {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "372", nick, nil,
|
ctx, clientID, irc.RplMotd, nick, nil,
|
||||||
"- "+line,
|
"- "+line,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "376", nick, nil,
|
ctx, clientID, irc.RplEndOfMotd, nick, nil,
|
||||||
"End of /MOTD command.",
|
"End of /MOTD command.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,10 +398,13 @@ func (hdlr *Handlers) serverName() string {
|
|||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
command, nick string,
|
code int,
|
||||||
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
|
command := fmt.Sprintf("%03d", code)
|
||||||
|
|
||||||
body, err := json.Marshal([]string{text})
|
body, err := json.Marshal([]string{text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -441,13 +445,17 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels.
|
// 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.
|
||||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -469,6 +477,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request.URL.Query().Get("initChannelState") == "1" {
|
||||||
|
hdlr.initChannelState(
|
||||||
|
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": nick,
|
||||||
@@ -477,6 +491,52 @@ 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.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -750,38 +810,38 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case cmdPrivmsg, "NOTICE":
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case "JOIN":
|
case irc.CmdJoin:
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case "PART":
|
case irc.CmdPart:
|
||||||
hdlr.handlePart(
|
hdlr.handlePart(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target, body,
|
sessionID, clientID, nick, target, body,
|
||||||
)
|
)
|
||||||
case "NICK":
|
case irc.CmdNick:
|
||||||
hdlr.handleNick(
|
hdlr.handleNick(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
case "TOPIC":
|
case irc.CmdTopic:
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, body, bodyLines,
|
target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case "QUIT":
|
case irc.CmdQuit:
|
||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case "MOTD", "LIST", "WHO", "WHOIS", "PING":
|
case irc.CmdMotd, irc.CmdPing:
|
||||||
hdlr.dispatchInfoCommand(
|
hdlr.dispatchInfoCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -804,34 +864,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case "MODE":
|
case irc.CmdMode:
|
||||||
hdlr.handleMode(
|
hdlr.handleMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case "NAMES":
|
case irc.CmdNames:
|
||||||
hdlr.handleNames(
|
hdlr.handleNames(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case "LIST":
|
case irc.CmdList:
|
||||||
hdlr.handleList(
|
hdlr.handleList(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
case "WHOIS":
|
case irc.CmdWhois:
|
||||||
hdlr.handleWhois(
|
hdlr.handleWhois(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case "WHO":
|
case irc.CmdWho:
|
||||||
hdlr.handleWho(
|
hdlr.handleWho(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case "LUSERS":
|
case irc.CmdLusers:
|
||||||
hdlr.handleLusers(
|
hdlr.handleLusers(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -839,7 +899,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"421", nick, []string{command},
|
irc.ErrUnknownCommand, nick, []string{command},
|
||||||
"Unknown command",
|
"Unknown command",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -860,7 +920,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"461", nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -875,7 +935,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"461", nick, []string{command},
|
irc.ErrNeedMoreParams, nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -909,13 +969,14 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
numeric, nick string,
|
code int,
|
||||||
|
nick string,
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
numeric, nick, params, text,
|
code, nick, params, text,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -936,7 +997,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"403", nick, []string{target},
|
irc.ErrNoSuchChannel, nick, []string{target},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -962,7 +1023,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"442", nick, []string{target},
|
irc.ErrNotOnChannel, nick, []string{target},
|
||||||
"You're not on that channel",
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1029,7 +1090,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"401", nick, []string{target},
|
irc.ErrNoSuchNick, nick, []string{target},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1073,7 +1134,7 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"JOIN"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1088,7 +1149,7 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
if !validChannelRe.MatchString(channel) {
|
if !validChannelRe.MatchString(channel) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"403", nick, []string{channel},
|
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1144,7 +1205,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, "JOIN", nick, channel, nil, memberIDs,
|
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1195,12 +1256,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
|
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "332", nick,
|
ctx, clientID, irc.RplTopic, nick,
|
||||||
[]string{channel}, topic,
|
[]string{channel}, topic,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "331", nick,
|
ctx, clientID, irc.RplNoTopic, nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]string{channel}, "No topic is set",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1218,14 +1279,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "353", nick,
|
ctx, clientID, irc.RplNamReply, nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "366", nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1242,7 +1303,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"461", nick, []string{"PART"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1264,7 +1325,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"403", nick, []string{channel},
|
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1280,7 +1341,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, "PART", nick, channel, body, memberIDs,
|
request, irc.CmdPart, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1322,7 +1383,7 @@ func (hdlr *Handlers) handleNick(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"NICK"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1334,7 +1395,7 @@ func (hdlr *Handlers) handleNick(
|
|||||||
if !validNickRe.MatchString(newNick) {
|
if !validNickRe.MatchString(newNick) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"432", nick, []string{newNick},
|
irc.ErrErroneusNickname, nick, []string{newNick},
|
||||||
"Erroneous nickname",
|
"Erroneous nickname",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1367,10 +1428,10 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"433", nick, []string{newNick},
|
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||||
"Nickname is already in use",
|
"Nickname is already in use",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1420,7 +1481,7 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), "NICK", oldNick, "",
|
request.Context(), irc.CmdNick, oldNick, "",
|
||||||
nil, json.RawMessage(nickBody), nil,
|
nil, json.RawMessage(nickBody), nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1461,7 +1522,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"TOPIC"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1472,7 +1533,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"TOPIC"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1490,7 +1551,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"403", nick, []string{channel},
|
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1534,12 +1595,12 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, "TOPIC", nick, channel, body, memberIDs,
|
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"332", nick, []string{channel}, topic,
|
irc.RplTopic, nick, []string{channel}, topic,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
@@ -1551,8 +1612,7 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatchInfoCommand handles informational IRC commands
|
// dispatchInfoCommand handles informational IRC commands
|
||||||
// that produce server-side numerics (MOTD, LIST, WHO,
|
// that produce server-side numerics (MOTD, PING).
|
||||||
// WHOIS, PING).
|
|
||||||
func (hdlr *Handlers) dispatchInfoCommand(
|
func (hdlr *Handlers) dispatchInfoCommand(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1560,31 +1620,20 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
|||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
|
_ = target
|
||||||
|
_ = bodyLines
|
||||||
|
|
||||||
okResp := map[string]string{"status": "ok"}
|
okResp := map[string]string{"status": "ok"}
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "MOTD":
|
case irc.CmdMotd:
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, nick,
|
||||||
)
|
)
|
||||||
case "LIST":
|
case irc.CmdPing:
|
||||||
hdlr.handleListCmd(
|
|
||||||
request, clientID, sessionID, nick,
|
|
||||||
)
|
|
||||||
case "WHO":
|
|
||||||
hdlr.handleWhoCmd(
|
|
||||||
request, clientID, sessionID, nick,
|
|
||||||
target,
|
|
||||||
)
|
|
||||||
case "WHOIS":
|
|
||||||
hdlr.handleWhoisCmd(
|
|
||||||
request, clientID, sessionID, nick,
|
|
||||||
target, bodyLines,
|
|
||||||
)
|
|
||||||
case "PING":
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"command": "PONG",
|
"command": irc.CmdPong,
|
||||||
"from": hdlr.serverName(),
|
"from": hdlr.serverName(),
|
||||||
},
|
},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1597,222 +1646,6 @@ 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{"WHO"}, "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{"WHOIS"}, "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(
|
func (hdlr *Handlers) handleQuit(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1831,7 +1664,7 @@ func (hdlr *Handlers) handleQuit(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), "QUIT", nick, "",
|
request.Context(), irc.CmdQuit, nick, "",
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1884,7 +1717,7 @@ func (hdlr *Handlers) handleMode(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"MODE"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1896,7 +1729,7 @@ func (hdlr *Handlers) handleMode(
|
|||||||
// User mode query — return empty modes.
|
// User mode query — return empty modes.
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
"221", nick, nil, "+",
|
irc.RplUmodeIs, nick, nil, "+",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1928,7 +1761,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"403", nick, []string{channel},
|
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1937,7 +1770,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
|
|
||||||
// 324 RPL_CHANNELMODEIS
|
// 324 RPL_CHANNELMODEIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "324", nick,
|
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||||
[]string{channel, "+n"}, "",
|
[]string{channel, "+n"}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1946,7 +1779,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
GetChannelCreatedAt(ctx, chID)
|
GetChannelCreatedAt(ctx, chID)
|
||||||
if timeErr == nil {
|
if timeErr == nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "329", nick,
|
ctx, clientID, irc.RplCreationTime, nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel,
|
channel,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -1973,7 +1806,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"NAMES"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1993,7 +1826,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"403", nick, []string{channel},
|
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2011,14 +1844,14 @@ func (hdlr *Handlers) handleNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "353", nick,
|
ctx, clientID, irc.RplNamReply, nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "366", nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2056,7 +1889,7 @@ func (hdlr *Handlers) handleList(
|
|||||||
for _, chanInfo := range channels {
|
for _, chanInfo := range channels {
|
||||||
// 322 RPL_LIST
|
// 322 RPL_LIST
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "322", nick,
|
ctx, clientID, irc.RplList, nick,
|
||||||
[]string{
|
[]string{
|
||||||
chanInfo.Name,
|
chanInfo.Name,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -2069,7 +1902,7 @@ func (hdlr *Handlers) handleList(
|
|||||||
|
|
||||||
// 323 — end of channel list.
|
// 323 — end of channel list.
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "323", nick, nil,
|
ctx, clientID, irc.RplListEnd, nick, nil,
|
||||||
"End of /LIST",
|
"End of /LIST",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2100,7 +1933,7 @@ func (hdlr *Handlers) handleWhois(
|
|||||||
if queryNick == "" {
|
if queryNick == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"WHOIS"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2127,12 +1960,12 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "401", nick,
|
ctx, clientID, irc.ErrNoSuchNick, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "318", nick,
|
ctx, clientID, irc.RplEndOfWhois, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -2146,14 +1979,14 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 311 RPL_WHOISUSER
|
// 311 RPL_WHOISUSER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "311", nick,
|
ctx, clientID, irc.RplWhoisUser, nick,
|
||||||
[]string{queryNick, queryNick, srvName, "*"},
|
[]string{queryNick, queryNick, srvName, "*"},
|
||||||
queryNick,
|
queryNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 312 RPL_WHOISSERVER
|
// 312 RPL_WHOISSERVER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "312", nick,
|
ctx, clientID, irc.RplWhoisServer, nick,
|
||||||
[]string{queryNick, srvName},
|
[]string{queryNick, srvName},
|
||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
@@ -2165,7 +1998,7 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 318 RPL_ENDOFWHOIS
|
// 318 RPL_ENDOFWHOIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "318", nick,
|
ctx, clientID, irc.RplEndOfWhois, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -2195,7 +2028,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "319", nick,
|
ctx, clientID, irc.RplWhoisChannels, nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
strings.Join(chanNames, " "),
|
strings.Join(chanNames, " "),
|
||||||
)
|
)
|
||||||
@@ -2211,7 +2044,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
"461", nick, []string{"WHO"},
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2232,7 +2065,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 315 RPL_ENDOFWHO (empty result)
|
// 315 RPL_ENDOFWHO (empty result)
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "315", nick,
|
ctx, clientID, irc.RplEndOfWho, nick,
|
||||||
[]string{target},
|
[]string{target},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2251,7 +2084,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
for _, mem := range members {
|
for _, mem := range members {
|
||||||
// 352 RPL_WHOREPLY
|
// 352 RPL_WHOREPLY
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "352", nick,
|
ctx, clientID, irc.RplWhoReply, nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel, mem.Nick, srvName,
|
channel, mem.Nick, srvName,
|
||||||
srvName, mem.Nick, "H",
|
srvName, mem.Nick, "H",
|
||||||
@@ -2263,7 +2096,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
|
|
||||||
// 315 RPL_ENDOFWHO
|
// 315 RPL_ENDOFWHO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, "315", nick,
|
ctx, clientID, irc.RplEndOfWho, nick,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2499,7 +2332,7 @@ func (hdlr *Handlers) cleanupUser(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
ctx, "QUIT", nick, "",
|
ctx, irc.CmdQuit, nick, "",
|
||||||
nil, nil, nil,
|
nil, nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2560,9 +2393,10 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"version": hdlr.params.Globals.Version,
|
||||||
"users": users,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
|
"users": users,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -467,8 +468,11 @@ func findNumeric(
|
|||||||
msgs []map[string]any,
|
msgs []map[string]any,
|
||||||
numeric string,
|
numeric string,
|
||||||
) bool {
|
) bool {
|
||||||
|
want, _ := strconv.Atoi(numeric)
|
||||||
|
|
||||||
for _, msg := range msgs {
|
for _, msg := range msgs {
|
||||||
if msg[commandKey] == numeric {
|
code, ok := msg["code"].(float64)
|
||||||
|
if ok && int(code) == want {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -182,6 +184,12 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
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,
|
||||||
|
|||||||
21
internal/irc/commands.go
Normal file
21
internal/irc/commands.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
150
internal/irc/numerics.go
Normal file
150
internal/irc/numerics.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// 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]
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/v5/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,20 +142,6 @@ 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
|
||||||
@@ -180,3 +166,36 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,6 +29,7 @@ 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 {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
2
web/dist/app.js
vendored
2
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<!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
466
web/dist/style.css
vendored
@@ -1,466 +0,0 @@
|
|||||||
* {
|
|
||||||
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(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state")
|
api("/state?initChannelState=1")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,24 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -636,9 +653,13 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
// Request MOTD on resumed sessions (new sessions get
|
|
||||||
// it automatically from the server during creation).
|
|
||||||
if (isResumed) {
|
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 {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -647,8 +668,11 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh session — join any previously saved channels.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user