Compare commits
2 Commits
feat/chi-v
...
a193831ed4
| Author | SHA1 | Date | |
|---|---|---|---|
| a193831ed4 | |||
|
|
8d26df60db |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,7 +21,6 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
web/dist/
|
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,13 +1,3 @@
|
|||||||
# Web build stage — compile SPA from source
|
|
||||||
# node:22-alpine, 2026-03-09
|
|
||||||
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
|
||||||
WORKDIR /web
|
|
||||||
COPY web/package.json web/package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY web/src/ src/
|
|
||||||
COPY web/build.sh build.sh
|
|
||||||
RUN sh build.sh
|
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
@@ -15,9 +5,6 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
|
||||||
# without depending on the web-builder stage (lint should fail fast)
|
|
||||||
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
|
|
||||||
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist/ web/dist/
|
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1032,12 +1032,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -1070,12 +1064,6 @@ 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
|
||||||
@@ -1374,18 +1362,16 @@ 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 |
|
||||||
| `version` | string | Server version |
|
| `motd` | string | Message of the day |
|
||||||
| `motd` | string | Message of the day |
|
| `users` | integer | Number of currently active user sessions |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1624,10 +1610,6 @@ 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1856,16 +1838,26 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a four-stage build:
|
The Dockerfile is a multi-stage build:
|
||||||
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
|
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
||||||
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)
|
||||||
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache make
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o /neoircd ./cmd/neoircd/
|
||||||
|
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["neoircd"]
|
||||||
|
```
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
@@ -2314,14 +2306,10 @@ 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
|
||||||
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
||||||
│ ├── package.json # Node dependencies (preact, esbuild)
|
│ ├── index.html
|
||||||
│ ├── package-lock.json
|
│ ├── style.css
|
||||||
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
│ └── app.js
|
||||||
│ │ ├── 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
|
||||||
@@ -2336,7 +2324,7 @@ neoirc/
|
|||||||
| Purpose | Library |
|
| Purpose | Library |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| DI | `go.uber.org/fx` |
|
| DI | `go.uber.org/fx` |
|
||||||
| Router | `github.com/go-chi/chi/v5` |
|
| Router | `github.com/go-chi/chi` |
|
||||||
| 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-03-09
|
last_modified: 2026-02-22
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,13 +98,6 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
|
||||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
|
||||||
should generate these at build time. Notable exception: Go protobuf generated
|
|
||||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
|
||||||
downloads code but does not execute code generation.
|
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -151,14 +144,8 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
Post-1.0.0: add new migration files.
|
||||||
tracking table itself. Nothing else.
|
|
||||||
- `001_schema.sql` — the full application schema.
|
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
|
||||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
|
||||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
|
||||||
Never edit existing migrations after release.
|
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -170,7 +168,7 @@ func (client *Client) PollMessages(
|
|||||||
func (client *Client) JoinChannel(channel string) error {
|
func (client *Client) JoinChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdJoin, To: channel,
|
Command: "JOIN", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,7 +177,7 @@ func (client *Client) JoinChannel(channel string) error {
|
|||||||
func (client *Client) PartChannel(channel string) error {
|
func (client *Client) PartChannel(channel string) error {
|
||||||
return client.SendMessage(
|
return client.SendMessage(
|
||||||
&Message{ //nolint:exhaustruct // only command+to needed
|
&Message{ //nolint:exhaustruct // only command+to needed
|
||||||
Command: irc.CmdPart, To: channel,
|
Command: "PART", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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 (
|
||||||
@@ -87,7 +86,7 @@ func (a *App) handleInput(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -242,7 +241,7 @@ func (a *App) cmdNick(nick string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdNick,
|
Command: "NICK",
|
||||||
Body: []string{nick},
|
Body: []string{nick},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,7 +376,7 @@ func (a *App) cmdMsg(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdPrivmsg,
|
Command: "PRIVMSG",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{text},
|
Body: []string{text},
|
||||||
})
|
})
|
||||||
@@ -435,7 +434,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
|
|
||||||
if args == "" {
|
if args == "" {
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +447,7 @@ func (a *App) cmdTopic(args string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdTopic,
|
Command: "TOPIC",
|
||||||
To: target,
|
To: target,
|
||||||
Body: []string{args},
|
Body: []string{args},
|
||||||
})
|
})
|
||||||
@@ -536,7 +535,7 @@ func (a *App) cmdMotd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
|
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.ui.AddStatus(fmt.Sprintf(
|
a.ui.AddStatus(fmt.Sprintf(
|
||||||
@@ -573,7 +572,7 @@ func (a *App) cmdWho(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdWho, To: channel,
|
Command: "WHO", To: channel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -604,7 +603,7 @@ func (a *App) cmdWhois(args string) {
|
|||||||
|
|
||||||
err := a.client.SendMessage(
|
err := a.client.SendMessage(
|
||||||
&api.Message{ //nolint:exhaustruct
|
&api.Message{ //nolint:exhaustruct
|
||||||
Command: irc.CmdWhois, To: args,
|
Command: "WHOIS", To: args,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -654,7 +653,7 @@ func (a *App) cmdQuit() {
|
|||||||
|
|
||||||
if a.connected && a.client != nil {
|
if a.connected && a.client != nil {
|
||||||
_ = a.client.SendMessage(
|
_ = a.client.SendMessage(
|
||||||
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
|
&api.Message{Command: "QUIT"}, //nolint:exhaustruct
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,19 +738,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
switch msg.Command {
|
switch msg.Command {
|
||||||
case irc.CmdPrivmsg:
|
case "PRIVMSG":
|
||||||
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
a.handlePrivmsgEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdJoin:
|
case "JOIN":
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case irc.CmdPart:
|
case "PART":
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp)
|
||||||
case irc.CmdQuit:
|
case "QUIT":
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case irc.CmdNick:
|
case "NICK":
|
||||||
a.handleNickEvent(msg, timestamp, myNick)
|
a.handleNickEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdNotice:
|
case "NOTICE":
|
||||||
a.handleNoticeEvent(msg, timestamp)
|
a.handleNoticeEvent(msg, timestamp)
|
||||||
case irc.CmdTopic:
|
case "TOPIC":
|
||||||
a.handleTopicEvent(msg, timestamp)
|
a.handleTopicEvent(msg, timestamp)
|
||||||
default:
|
default:
|
||||||
a.handleDefaultEvent(msg, timestamp)
|
a.handleDefaultEvent(msg, timestamp)
|
||||||
|
|||||||
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/v5 v5.2.1
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -64,14 +64,12 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -139,14 +137,12 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Package db provides database access and migration management.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"modernc.org/sqlite"
|
|
||||||
sqlite3 "modernc.org/sqlite/lib"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsUniqueConstraintError reports whether err is a SQLite
|
|
||||||
// unique-constraint violation.
|
|
||||||
func IsUniqueConstraintError(err error) bool {
|
|
||||||
var sqliteErr *sqlite.Error
|
|
||||||
if !errors.As(err, &sqliteErr) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,12 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,19 +29,10 @@ 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"`
|
||||||
@@ -54,15 +42,6 @@ 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"`
|
||||||
@@ -114,14 +93,12 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, tokenHash, now, now)
|
clientUUID, sessionID, token, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -154,8 +131,6 @@ func (database *Database) GetSessionByToken(
|
|||||||
nick string
|
nick string
|
||||||
)
|
)
|
||||||
|
|
||||||
tokenHash := hashToken(token)
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT s.id, c.id, s.nick
|
`SELECT s.id, c.id, s.nick
|
||||||
@@ -163,7 +138,7 @@ func (database *Database) GetSessionByToken(
|
|||||||
INNER JOIN sessions s
|
INNER JOIN sessions s
|
||||||
ON s.id = c.session_id
|
ON s.id = c.session_id
|
||||||
WHERE c.token = ?`,
|
WHERE c.token = ?`,
|
||||||
tokenHash,
|
token,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
@@ -742,15 +717,6 @@ 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,9 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"github.com/go-chi/chi"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -29,6 +27,15 @@ const (
|
|||||||
defaultMaxBodySize = 4096
|
defaultMaxBodySize = 4096
|
||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
|
cmdJoin = "JOIN"
|
||||||
|
cmdList = "LIST"
|
||||||
|
cmdNick = "NICK"
|
||||||
|
cmdPart = "PART"
|
||||||
|
cmdPrivmsg = "PRIVMSG"
|
||||||
|
cmdQuit = "QUIT"
|
||||||
|
cmdTopic = "TOPIC"
|
||||||
|
cmdWho = "WHO"
|
||||||
|
cmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -200,7 +207,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -233,20 +240,20 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 001 RPL_WELCOME
|
// 001 RPL_WELCOME
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWelcome, nick, nil,
|
ctx, clientID, "001", nick, nil,
|
||||||
"Welcome to the network, "+nick,
|
"Welcome to the network, "+nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 002 RPL_YOURHOST
|
// 002 RPL_YOURHOST
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplYourHost, nick, nil,
|
ctx, clientID, "002", 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, irc.RplCreated, nick, nil,
|
ctx, clientID, "003", 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"),
|
||||||
@@ -254,14 +261,14 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
|
|
||||||
// 004 RPL_MYINFO
|
// 004 RPL_MYINFO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplMyInfo, nick,
|
ctx, clientID, "004", nick,
|
||||||
[]string{srvName, version, "", "imnst"},
|
[]string{srvName, version, "", "imnst"},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 005 RPL_ISUPPORT
|
// 005 RPL_ISUPPORT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplIsupport, nick,
|
ctx, clientID, "005", nick,
|
||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
@@ -306,7 +313,7 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 251 RPL_LUSERCLIENT
|
// 251 RPL_LUSERCLIENT
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserClient, nick, nil,
|
ctx, clientID, "251", 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,
|
||||||
@@ -315,21 +322,21 @@ func (hdlr *Handlers) deliverLusers(
|
|||||||
|
|
||||||
// 252 RPL_LUSEROP
|
// 252 RPL_LUSEROP
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserOp, nick,
|
ctx, clientID, "252", nick,
|
||||||
[]string{"0"},
|
[]string{"0"},
|
||||||
"operator(s) online",
|
"operator(s) online",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 254 RPL_LUSERCHANNELS
|
// 254 RPL_LUSERCHANNELS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplLuserChannels, nick,
|
ctx, clientID, "254", 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, irc.RplLuserMe, nick, nil,
|
ctx, clientID, "255", nick, nil,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"I have %d clients and 1 servers",
|
"I have %d clients and 1 servers",
|
||||||
userCount,
|
userCount,
|
||||||
@@ -367,19 +374,19 @@ func (hdlr *Handlers) deliverMOTD(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplMotdStart, nick, nil,
|
ctx, clientID, "375", 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, irc.RplMotd, nick, nil,
|
ctx, clientID, "372", nick, nil,
|
||||||
"- "+line,
|
"- "+line,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfMotd, nick, nil,
|
ctx, clientID, "376", nick, nil,
|
||||||
"End of /MOTD command.",
|
"End of /MOTD command.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -398,13 +405,10 @@ func (hdlr *Handlers) serverName() string {
|
|||||||
func (hdlr *Handlers) enqueueNumeric(
|
func (hdlr *Handlers) enqueueNumeric(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
clientID int64,
|
clientID int64,
|
||||||
code int,
|
command, nick string,
|
||||||
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(
|
||||||
@@ -445,17 +449,13 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels. When called with ?initChannelState=1, it also
|
// channels.
|
||||||
// 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, clientID, nick, ok :=
|
sessionID, _, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -477,12 +477,6 @@ 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,
|
||||||
@@ -491,52 +485,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// initChannelState enqueues synthetic JOIN messages and
|
|
||||||
// join-numerics (TOPIC, NAMES) for every channel the
|
|
||||||
// session belongs to. Messages are enqueued only to the
|
|
||||||
// specified client so other clients/sessions are not
|
|
||||||
// affected.
|
|
||||||
func (hdlr *Handlers) initChannelState(
|
|
||||||
request *http.Request,
|
|
||||||
clientID, sessionID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
channels, err := hdlr.params.Database.
|
|
||||||
GetSessionChannels(ctx, sessionID)
|
|
||||||
if err != nil || len(channels) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, chanInfo := range channels {
|
|
||||||
// Enqueue a synthetic JOIN (only to this client).
|
|
||||||
dbID, _, insErr := hdlr.params.Database.
|
|
||||||
InsertMessage(
|
|
||||||
ctx, "JOIN", nick, chanInfo.Name,
|
|
||||||
nil, nil, nil,
|
|
||||||
)
|
|
||||||
if insErr != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"initChannelState: insert JOIN",
|
|
||||||
"error", insErr,
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToClient(
|
|
||||||
ctx, clientID, dbID,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enqueue TOPIC + NAMES numerics.
|
|
||||||
hdlr.deliverJoinNumerics(
|
|
||||||
request, clientID, sessionID,
|
|
||||||
nick, chanInfo.Name, chanInfo.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleListAllChannels returns all channels on the server.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -810,38 +758,38 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case cmdPrivmsg, "NOTICE":
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdJoin:
|
case cmdJoin:
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdPart:
|
case cmdPart:
|
||||||
hdlr.handlePart(
|
hdlr.handlePart(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target, body,
|
sessionID, clientID, nick, target, body,
|
||||||
)
|
)
|
||||||
case irc.CmdNick:
|
case cmdNick:
|
||||||
hdlr.handleNick(
|
hdlr.handleNick(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdTopic:
|
case cmdTopic:
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, body, bodyLines,
|
target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdQuit:
|
case cmdQuit:
|
||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdMotd, irc.CmdPing:
|
case "MOTD", cmdList, cmdWho, cmdWhois, "PING":
|
||||||
hdlr.dispatchInfoCommand(
|
hdlr.dispatchInfoCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -864,34 +812,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdMode:
|
case "MODE":
|
||||||
hdlr.handleMode(
|
hdlr.handleMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdNames:
|
case "NAMES":
|
||||||
hdlr.handleNames(
|
hdlr.handleNames(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdList:
|
case cmdList:
|
||||||
hdlr.handleList(
|
hdlr.handleList(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
case irc.CmdWhois:
|
case cmdWhois:
|
||||||
hdlr.handleWhois(
|
hdlr.handleWhois(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, bodyLines,
|
target, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdWho:
|
case cmdWho:
|
||||||
hdlr.handleWho(
|
hdlr.handleWho(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target,
|
sessionID, clientID, nick, target,
|
||||||
)
|
)
|
||||||
case irc.CmdLusers:
|
case "LUSERS":
|
||||||
hdlr.handleLusers(
|
hdlr.handleLusers(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -899,7 +847,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrUnknownCommand, nick, []string{command},
|
"421", nick, []string{command},
|
||||||
"Unknown command",
|
"Unknown command",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -920,7 +868,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
"461", nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -935,7 +883,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{command},
|
"461", nick, []string{command},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -969,14 +917,13 @@ func (hdlr *Handlers) respondIRCError(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
clientID, sessionID int64,
|
clientID, sessionID int64,
|
||||||
code int,
|
numeric, nick string,
|
||||||
nick string,
|
|
||||||
params []string,
|
params []string,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
code, nick, params, text,
|
numeric, nick, params, text,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -997,7 +944,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{target},
|
"403", nick, []string{target},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1023,7 +970,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
if !isMember {
|
if !isMember {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNotOnChannel, nick, []string{target},
|
"442", nick, []string{target},
|
||||||
"You're not on that channel",
|
"You're not on that channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1090,7 +1037,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoSuchNick, nick, []string{target},
|
"401", nick, []string{target},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1134,7 +1081,7 @@ func (hdlr *Handlers) handleJoin(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
|
"461", nick, []string{cmdJoin},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1149,7 +1096,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,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1205,7 +1152,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
request, cmdJoin, nick, channel, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1256,12 +1203,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
|
|
||||||
if topic != "" {
|
if topic != "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, "332", nick,
|
||||||
[]string{channel}, topic,
|
[]string{channel}, topic,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, "331", nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]string{channel}, "No topic is set",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1279,14 +1226,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, "353", nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, "366", nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1303,7 +1250,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
|
"461", nick, []string{cmdPart},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1325,7 +1272,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
@@ -1341,7 +1288,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
request, cmdPart, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1383,7 +1330,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,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
|
"461", nick, []string{cmdNick},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1395,7 +1342,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,
|
||||||
irc.ErrErroneusNickname, nick, []string{newNick},
|
"432", nick, []string{newNick},
|
||||||
"Erroneous nickname",
|
"Erroneous nickname",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1428,10 +1375,10 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
"433", nick, []string{newNick},
|
||||||
"Nickname is already in use",
|
"Nickname is already in use",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1481,7 +1428,7 @@ func (hdlr *Handlers) broadcastNick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
dbID, _, _ := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), irc.CmdNick, oldNick, "",
|
request.Context(), cmdNick, oldNick, "",
|
||||||
nil, json.RawMessage(nickBody), nil,
|
nil, json.RawMessage(nickBody), nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1522,7 +1469,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
"461", nick, []string{cmdTopic},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1533,7 +1480,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,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
|
"461", nick, []string{cmdTopic},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1551,7 +1498,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1595,12 +1542,12 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
request, cmdTopic, nick, channel, body, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
"332", nick, []string{channel}, topic,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
@@ -1612,7 +1559,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatchInfoCommand handles informational IRC commands
|
// dispatchInfoCommand handles informational IRC commands
|
||||||
// that produce server-side numerics (MOTD, PING).
|
// that produce server-side numerics (MOTD, LIST, WHO,
|
||||||
|
// WHOIS, PING).
|
||||||
func (hdlr *Handlers) dispatchInfoCommand(
|
func (hdlr *Handlers) dispatchInfoCommand(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1620,20 +1568,31 @@ 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 irc.CmdMotd:
|
case "MOTD":
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, nick,
|
||||||
)
|
)
|
||||||
case irc.CmdPing:
|
case cmdList:
|
||||||
|
hdlr.handleListCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
case cmdWho:
|
||||||
|
hdlr.handleWhoCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
case cmdWhois:
|
||||||
|
hdlr.handleWhoisCmd(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
target, bodyLines,
|
||||||
|
)
|
||||||
|
case "PING":
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"command": irc.CmdPong,
|
"command": "PONG",
|
||||||
"from": hdlr.serverName(),
|
"from": hdlr.serverName(),
|
||||||
},
|
},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1646,6 +1605,222 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListCmd sends RPL_LIST (322) for each channel,
|
||||||
|
// then sends 323 to signal the end of the list.
|
||||||
|
func (hdlr *Handlers) handleListCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
channels, err := hdlr.params.Database.ListAllChannels(
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "323", nick, nil,
|
||||||
|
"End of /LIST",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channel := range channels {
|
||||||
|
memberIDs, _ :=
|
||||||
|
hdlr.params.Database.GetChannelMemberIDs(
|
||||||
|
ctx, channel.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
count := strconv.Itoa(len(memberIDs))
|
||||||
|
topic := channel.Topic
|
||||||
|
|
||||||
|
if topic == "" {
|
||||||
|
topic = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "322", nick,
|
||||||
|
[]string{channel.Name, count}, topic,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "323", nick, nil,
|
||||||
|
"End of /LIST",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
|
||||||
|
// of the target channel, followed by RPL_ENDOFWHO (315).
|
||||||
|
func (hdlr *Handlers) handleWhoCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, target string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "461", nick,
|
||||||
|
[]string{cmdWho}, "Not enough parameters",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := target
|
||||||
|
if !strings.HasPrefix(channel, "#") {
|
||||||
|
channel = "#" + channel
|
||||||
|
}
|
||||||
|
|
||||||
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
|
ctx, channel,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "315", nick,
|
||||||
|
[]string{channel}, "End of /WHO list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := hdlr.params.Database.ChannelMembers(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
for _, mem := range members {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "352", nick,
|
||||||
|
[]string{
|
||||||
|
channel, mem.Nick, "neoirc",
|
||||||
|
srvName, mem.Nick, "H",
|
||||||
|
},
|
||||||
|
"0 "+mem.Nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "315", nick,
|
||||||
|
[]string{channel}, "End of /WHO list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
|
||||||
|
// 319, 318) for the target nick.
|
||||||
|
func (hdlr *Handlers) handleWhoisCmd(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, target string,
|
||||||
|
bodyLines func() []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
whoisNick := target
|
||||||
|
if whoisNick == "" {
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) > 0 {
|
||||||
|
whoisNick = strings.TrimSpace(lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if whoisNick == "" {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "461", nick,
|
||||||
|
[]string{cmdWhois}, "Not enough parameters",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, err :=
|
||||||
|
hdlr.params.Database.GetSessionByNick(
|
||||||
|
ctx, whoisNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "401", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
)
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "318", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"End of /WHOIS list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.sendWhoisNumerics(
|
||||||
|
ctx, clientID, sessionID, nick,
|
||||||
|
whoisNick, targetSID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWhoisNumerics emits 311/312/319/318 for a
|
||||||
|
// resolved WHOIS target.
|
||||||
|
func (hdlr *Handlers) sendWhoisNumerics(
|
||||||
|
ctx context.Context,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, whoisNick string,
|
||||||
|
targetSID int64,
|
||||||
|
) {
|
||||||
|
srvName := hdlr.serverName()
|
||||||
|
|
||||||
|
// 311 RPL_WHOISUSER
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "311", nick,
|
||||||
|
[]string{whoisNick, whoisNick, "neoirc", "*"},
|
||||||
|
whoisNick,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 312 RPL_WHOISSERVER
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "312", nick,
|
||||||
|
[]string{whoisNick, srvName},
|
||||||
|
srvName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 319 RPL_WHOISCHANNELS
|
||||||
|
channels, _ := hdlr.params.Database.GetSessionChannels(
|
||||||
|
ctx, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(channels) > 0 {
|
||||||
|
names := make([]string, 0, len(channels))
|
||||||
|
|
||||||
|
for _, chanInfo := range channels {
|
||||||
|
names = append(names, chanInfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "319", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
strings.Join(names, " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 318 RPL_ENDOFWHOIS
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, "318", nick,
|
||||||
|
[]string{whoisNick},
|
||||||
|
"End of /WHOIS list",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handleQuit(
|
func (hdlr *Handlers) handleQuit(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -1664,7 +1839,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(), irc.CmdQuit, nick, "",
|
request.Context(), cmdQuit, nick, "",
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1717,7 +1892,7 @@ func (hdlr *Handlers) handleMode(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
"461", nick, []string{"MODE"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1729,7 +1904,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,
|
||||||
irc.RplUmodeIs, nick, nil, "+",
|
"221", nick, nil, "+",
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
hdlr.broker.Notify(sessionID)
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1761,7 +1936,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1770,7 +1945,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
|
|
||||||
// 324 RPL_CHANNELMODEIS
|
// 324 RPL_CHANNELMODEIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
ctx, clientID, "324", nick,
|
||||||
[]string{channel, "+n"}, "",
|
[]string{channel, "+n"}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1779,7 +1954,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
GetChannelCreatedAt(ctx, chID)
|
GetChannelCreatedAt(ctx, chID)
|
||||||
if timeErr == nil {
|
if timeErr == nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplCreationTime, nick,
|
ctx, clientID, "329", nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel,
|
channel,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -1806,7 +1981,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
|
"461", nick, []string{"NAMES"},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1826,7 +2001,7 @@ func (hdlr *Handlers) handleNames(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
"403", nick, []string{channel},
|
||||||
"No such channel",
|
"No such channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1844,14 +2019,14 @@ func (hdlr *Handlers) handleNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNamReply, nick,
|
ctx, clientID, "353", nick,
|
||||||
[]string{"=", channel},
|
[]string{"=", channel},
|
||||||
strings.Join(nicks, " "),
|
strings.Join(nicks, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, "366", nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1889,7 +2064,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, irc.RplList, nick,
|
ctx, clientID, "322", nick,
|
||||||
[]string{
|
[]string{
|
||||||
chanInfo.Name,
|
chanInfo.Name,
|
||||||
strconv.FormatInt(
|
strconv.FormatInt(
|
||||||
@@ -1902,7 +2077,7 @@ func (hdlr *Handlers) handleList(
|
|||||||
|
|
||||||
// 323 — end of channel list.
|
// 323 — end of channel list.
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplListEnd, nick, nil,
|
ctx, clientID, "323", nick, nil,
|
||||||
"End of /LIST",
|
"End of /LIST",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1933,7 +2108,7 @@ func (hdlr *Handlers) handleWhois(
|
|||||||
if queryNick == "" {
|
if queryNick == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
|
"461", nick, []string{cmdWhois},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1960,12 +2135,12 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.ErrNoSuchNick, nick,
|
ctx, clientID, "401", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"No such nick/channel",
|
"No such nick/channel",
|
||||||
)
|
)
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
ctx, clientID, "318", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -1979,14 +2154,14 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 311 RPL_WHOISUSER
|
// 311 RPL_WHOISUSER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisUser, nick,
|
ctx, clientID, "311", nick,
|
||||||
[]string{queryNick, queryNick, srvName, "*"},
|
[]string{queryNick, queryNick, srvName, "*"},
|
||||||
queryNick,
|
queryNick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 312 RPL_WHOISSERVER
|
// 312 RPL_WHOISSERVER
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisServer, nick,
|
ctx, clientID, "312", nick,
|
||||||
[]string{queryNick, srvName},
|
[]string{queryNick, srvName},
|
||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
@@ -1998,7 +2173,7 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
|
|
||||||
// 318 RPL_ENDOFWHOIS
|
// 318 RPL_ENDOFWHOIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWhois, nick,
|
ctx, clientID, "318", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
"End of /WHOIS list",
|
"End of /WHOIS list",
|
||||||
)
|
)
|
||||||
@@ -2028,7 +2203,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplWhoisChannels, nick,
|
ctx, clientID, "319", nick,
|
||||||
[]string{queryNick},
|
[]string{queryNick},
|
||||||
strings.Join(chanNames, " "),
|
strings.Join(chanNames, " "),
|
||||||
)
|
)
|
||||||
@@ -2044,7 +2219,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
|
"461", nick, []string{cmdWho},
|
||||||
"Not enough parameters",
|
"Not enough parameters",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2065,7 +2240,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, irc.RplEndOfWho, nick,
|
ctx, clientID, "315", nick,
|
||||||
[]string{target},
|
[]string{target},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2084,7 +2259,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, irc.RplWhoReply, nick,
|
ctx, clientID, "352", nick,
|
||||||
[]string{
|
[]string{
|
||||||
channel, mem.Nick, srvName,
|
channel, mem.Nick, srvName,
|
||||||
srvName, mem.Nick, "H",
|
srvName, mem.Nick, "H",
|
||||||
@@ -2096,7 +2271,7 @@ func (hdlr *Handlers) handleWho(
|
|||||||
|
|
||||||
// 315 RPL_ENDOFWHO
|
// 315 RPL_ENDOFWHO
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplEndOfWho, nick,
|
ctx, clientID, "315", nick,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"End of /WHO list",
|
"End of /WHO list",
|
||||||
)
|
)
|
||||||
@@ -2332,7 +2507,7 @@ func (hdlr *Handlers) cleanupUser(
|
|||||||
|
|
||||||
if len(channels) > 0 {
|
if len(channels) > 0 {
|
||||||
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
|
||||||
ctx, irc.CmdQuit, nick, "",
|
ctx, cmdQuit, nick, "",
|
||||||
nil, nil, nil,
|
nil, nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2393,10 +2568,9 @@ 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,
|
||||||
"version": hdlr.params.Globals.Version,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"users": users,
|
||||||
"users": users,
|
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -468,11 +467,8 @@ 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 {
|
||||||
code, ok := msg["code"].(float64)
|
if msg[commandKey] == numeric {
|
||||||
if ok && int(code) == want {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -96,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if db.IsUniqueConstraintError(err) {
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -184,12 +182,6 @@ 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,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package irc
|
|
||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
|
||||||
const (
|
|
||||||
CmdJoin = "JOIN"
|
|
||||||
CmdList = "LIST"
|
|
||||||
CmdLusers = "LUSERS"
|
|
||||||
CmdMode = "MODE"
|
|
||||||
CmdMotd = "MOTD"
|
|
||||||
CmdNames = "NAMES"
|
|
||||||
CmdNick = "NICK"
|
|
||||||
CmdNotice = "NOTICE"
|
|
||||||
CmdPart = "PART"
|
|
||||||
CmdPing = "PING"
|
|
||||||
CmdPong = "PONG"
|
|
||||||
CmdPrivmsg = "PRIVMSG"
|
|
||||||
CmdQuit = "QUIT"
|
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// Package irc provides constants and utilities for the
|
|
||||||
// IRC protocol, including numeric reply codes from
|
|
||||||
// RFC 1459 and RFC 2812, and standard command names.
|
|
||||||
package irc
|
|
||||||
|
|
||||||
// Connection registration replies (001-005).
|
|
||||||
const (
|
|
||||||
RplWelcome = 1
|
|
||||||
RplYourHost = 2
|
|
||||||
RplCreated = 3
|
|
||||||
RplMyInfo = 4
|
|
||||||
RplIsupport = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command responses (200-399).
|
|
||||||
const (
|
|
||||||
RplUmodeIs = 221
|
|
||||||
RplLuserClient = 251
|
|
||||||
RplLuserOp = 252
|
|
||||||
RplLuserUnknown = 253
|
|
||||||
RplLuserChannels = 254
|
|
||||||
RplLuserMe = 255
|
|
||||||
RplAway = 301
|
|
||||||
RplUserHost = 302
|
|
||||||
RplIson = 303
|
|
||||||
RplUnaway = 305
|
|
||||||
RplNowAway = 306
|
|
||||||
RplWhoisUser = 311
|
|
||||||
RplWhoisServer = 312
|
|
||||||
RplWhoisOperator = 313
|
|
||||||
RplEndOfWho = 315
|
|
||||||
RplWhoisIdle = 317
|
|
||||||
RplEndOfWhois = 318
|
|
||||||
RplWhoisChannels = 319
|
|
||||||
RplList = 322
|
|
||||||
RplListEnd = 323
|
|
||||||
RplChannelModeIs = 324
|
|
||||||
RplCreationTime = 329
|
|
||||||
RplNoTopic = 331
|
|
||||||
RplTopic = 332
|
|
||||||
RplTopicWhoTime = 333
|
|
||||||
RplInviting = 341
|
|
||||||
RplWhoReply = 352
|
|
||||||
RplNamReply = 353
|
|
||||||
RplEndOfNames = 366
|
|
||||||
RplBanList = 367
|
|
||||||
RplEndOfBanList = 368
|
|
||||||
RplMotd = 372
|
|
||||||
RplMotdStart = 375
|
|
||||||
RplEndOfMotd = 376
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error replies (400-599).
|
|
||||||
const (
|
|
||||||
ErrNoSuchNick = 401
|
|
||||||
ErrNoSuchServer = 402
|
|
||||||
ErrNoSuchChannel = 403
|
|
||||||
ErrCannotSendToChan = 404
|
|
||||||
ErrTooManyChannels = 405
|
|
||||||
ErrNoRecipient = 411
|
|
||||||
ErrNoTextToSend = 412
|
|
||||||
ErrUnknownCommand = 421
|
|
||||||
ErrNoNicknameGiven = 431
|
|
||||||
ErrErroneusNickname = 432
|
|
||||||
ErrNicknameInUse = 433
|
|
||||||
ErrUserNotInChannel = 441
|
|
||||||
ErrNotOnChannel = 442
|
|
||||||
ErrNotRegistered = 451
|
|
||||||
ErrNeedMoreParams = 461
|
|
||||||
ErrAlreadyRegistered = 462
|
|
||||||
ErrChannelIsFull = 471
|
|
||||||
ErrInviteOnlyChan = 473
|
|
||||||
ErrBannedFromChan = 474
|
|
||||||
ErrBadChannelKey = 475
|
|
||||||
ErrChanOpPrivsNeeded = 482
|
|
||||||
)
|
|
||||||
|
|
||||||
// names maps numeric codes to their standard IRC names.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals
|
|
||||||
var names = map[int]string{
|
|
||||||
RplWelcome: "RPL_WELCOME",
|
|
||||||
RplYourHost: "RPL_YOURHOST",
|
|
||||||
RplCreated: "RPL_CREATED",
|
|
||||||
RplMyInfo: "RPL_MYINFO",
|
|
||||||
RplIsupport: "RPL_ISUPPORT",
|
|
||||||
RplUmodeIs: "RPL_UMODEIS",
|
|
||||||
RplLuserClient: "RPL_LUSERCLIENT",
|
|
||||||
RplLuserOp: "RPL_LUSEROP",
|
|
||||||
RplLuserUnknown: "RPL_LUSERUNKNOWN",
|
|
||||||
RplLuserChannels: "RPL_LUSERCHANNELS",
|
|
||||||
RplLuserMe: "RPL_LUSERME",
|
|
||||||
RplAway: "RPL_AWAY",
|
|
||||||
RplUserHost: "RPL_USERHOST",
|
|
||||||
RplIson: "RPL_ISON",
|
|
||||||
RplUnaway: "RPL_UNAWAY",
|
|
||||||
RplNowAway: "RPL_NOWAWAY",
|
|
||||||
RplWhoisUser: "RPL_WHOISUSER",
|
|
||||||
RplWhoisServer: "RPL_WHOISSERVER",
|
|
||||||
RplWhoisOperator: "RPL_WHOISOPERATOR",
|
|
||||||
RplEndOfWho: "RPL_ENDOFWHO",
|
|
||||||
RplWhoisIdle: "RPL_WHOISIDLE",
|
|
||||||
RplEndOfWhois: "RPL_ENDOFWHOIS",
|
|
||||||
RplWhoisChannels: "RPL_WHOISCHANNELS",
|
|
||||||
RplList: "RPL_LIST",
|
|
||||||
RplListEnd: "RPL_LISTEND", //nolint:misspell
|
|
||||||
RplChannelModeIs: "RPL_CHANNELMODEIS",
|
|
||||||
RplCreationTime: "RPL_CREATIONTIME",
|
|
||||||
RplNoTopic: "RPL_NOTOPIC",
|
|
||||||
RplTopic: "RPL_TOPIC",
|
|
||||||
RplTopicWhoTime: "RPL_TOPICWHOTIME",
|
|
||||||
RplInviting: "RPL_INVITING",
|
|
||||||
RplWhoReply: "RPL_WHOREPLY",
|
|
||||||
RplNamReply: "RPL_NAMREPLY",
|
|
||||||
RplEndOfNames: "RPL_ENDOFNAMES",
|
|
||||||
RplBanList: "RPL_BANLIST",
|
|
||||||
RplEndOfBanList: "RPL_ENDOFBANLIST",
|
|
||||||
RplMotd: "RPL_MOTD",
|
|
||||||
RplMotdStart: "RPL_MOTDSTART",
|
|
||||||
RplEndOfMotd: "RPL_ENDOFMOTD",
|
|
||||||
|
|
||||||
ErrNoSuchNick: "ERR_NOSUCHNICK",
|
|
||||||
ErrNoSuchServer: "ERR_NOSUCHSERVER",
|
|
||||||
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
|
|
||||||
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
|
|
||||||
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
|
|
||||||
ErrNoRecipient: "ERR_NORECIPIENT",
|
|
||||||
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
|
|
||||||
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
|
|
||||||
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
|
|
||||||
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
|
|
||||||
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
|
|
||||||
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
|
|
||||||
ErrNotOnChannel: "ERR_NOTONCHANNEL",
|
|
||||||
ErrNotRegistered: "ERR_NOTREGISTERED",
|
|
||||||
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
|
|
||||||
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
|
|
||||||
ErrChannelIsFull: "ERR_CHANNELISFULL",
|
|
||||||
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
|
|
||||||
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
|
|
||||||
ErrBadChannelKey: "ERR_BADCHANNELKEY",
|
|
||||||
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the standard IRC name for a numeric code
|
|
||||||
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
|
|
||||||
// empty string if the code is unknown.
|
|
||||||
func Name(code int) string {
|
|
||||||
return names[code]
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth returns middleware that performs authentication.
|
||||||
|
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
mware.log.Info("AUTH: before request")
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
@@ -166,36 +180,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cspPolicy is the Content-Security-Policy header value applied to all
|
|
||||||
// responses. The embedded SPA loads scripts and styles from same-origin
|
|
||||||
// files only (no inline scripts or inline style attributes), so a strict
|
|
||||||
// policy works without 'unsafe-inline'.
|
|
||||||
const cspPolicy = "default-src 'self'; " +
|
|
||||||
"script-src 'self'; " +
|
|
||||||
"style-src 'self'; " +
|
|
||||||
"connect-src 'self'; " +
|
|
||||||
"img-src 'self'; " +
|
|
||||||
"font-src 'self'; " +
|
|
||||||
"object-src 'none'; " +
|
|
||||||
"frame-ancestors 'none'; " +
|
|
||||||
"base-uri 'self'; " +
|
|
||||||
"form-action 'self'"
|
|
||||||
|
|
||||||
// CSP returns middleware that sets the Content-Security-Policy header on
|
|
||||||
// every response for defense-in-depth against XSS.
|
|
||||||
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Content-Security-Policy",
|
|
||||||
cspPolicy,
|
|
||||||
)
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,7 +29,6 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
srv.router.Use(srv.mw.CSP())
|
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
2
web/dist/app.js
vendored
Normal file
2
web/dist/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
Normal file
13
web/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NeoIRC</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
466
web/dist/style.css
vendored
Normal file
466
web/dist/style.css
vendored
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0e14;
|
||||||
|
--bg-panel: #0d1117;
|
||||||
|
--bg-input: #0d1117;
|
||||||
|
--bg-tab: #161b22;
|
||||||
|
--bg-tab-active: #0d1117;
|
||||||
|
--bg-topic: #0d1117;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-dim: #6e7681;
|
||||||
|
--text-bright: #e6edf3;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-dim: #1f6feb;
|
||||||
|
--border: #21262d;
|
||||||
|
--system: #7d8590;
|
||||||
|
--action: #d2a8ff;
|
||||||
|
--warn: #d29922;
|
||||||
|
--error: #f85149;
|
||||||
|
--unread: #f0883e;
|
||||||
|
--nick-brackets: #6e7681;
|
||||||
|
--timestamp: #484f58;
|
||||||
|
--input-bg: #161b22;
|
||||||
|
--prompt: #3fb950;
|
||||||
|
--tab-indicator: #58a6ff;
|
||||||
|
--user-list-bg: #0d1117;
|
||||||
|
--user-list-header: #484f58;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
||||||
|
"Consolas", "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Login Screen
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.8em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .motd {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input:focus {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
IRC App Layout
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.irc-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Tab Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-tab);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 32px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text-bright);
|
||||||
|
background: var(--bg-tab-active);
|
||||||
|
border-bottom: 2px solid var(--tab-indicator);
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.has-unread .tab-label {
|
||||||
|
color: var(--unread);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .unread-count {
|
||||||
|
color: var(--unread);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-nick {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Topic Bar
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.topic-bar {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--bg-topic);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-text {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Main Content Area
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Messages Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.messages-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Message Lines
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 1px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .timestamp {
|
||||||
|
color: var(--timestamp);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .nick {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .content {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System messages (joins, parts, quits, etc.) */
|
||||||
|
.system-message {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message .system-text {
|
||||||
|
color: var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /me action messages */
|
||||||
|
.action-message .action-text {
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
User List (Right Panel)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
width: 160px;
|
||||||
|
background: var(--user-list-bg);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-header {
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--user-list-header);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-entries {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry {
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-entry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-prefix {
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-block;
|
||||||
|
width: 1ch;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nick-name {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Input Line (Bottom)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.input-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
color: var(--prompt);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-bright);
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Responsive
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.user-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-prompt {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state?initChannelState=1")
|
api("/state")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -333,24 +333,7 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) {
|
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
||||||
// Create a tab when the current user joins a channel
|
|
||||||
// (including JOINs from initChannelState on reconnect).
|
|
||||||
if (msg.from === nickRef.current) {
|
|
||||||
setTabs((prev) => {
|
|
||||||
if (
|
|
||||||
prev.find(
|
|
||||||
(t) => t.type === "channel" && t.name === msg.to,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return prev;
|
|
||||||
|
|
||||||
return [...prev, { type: "channel", name: msg.to }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMembers(msg.to);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -653,13 +636,9 @@ 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",
|
||||||
@@ -668,11 +647,8 @@ 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