Compare commits
7 Commits
ab49c32148
...
feat/chi-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cae71171 | ||
| 67446b36a1 | |||
| b1fd2f1b96 | |||
| c07f94a432 | |||
| a98e0ca349 | |||
| f287fdf6d1 | |||
| 687c958bd1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ node_modules/
|
|||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
|
web/dist/
|
||||||
/neoircd
|
/neoircd
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -1,3 +1,13 @@
|
|||||||
|
# Web build stage — compile SPA from source
|
||||||
|
# node:22-alpine, 2026-03-09
|
||||||
|
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/src/ src/
|
||||||
|
COPY web/build.sh build.sh
|
||||||
|
RUN sh build.sh
|
||||||
|
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
# golangci/golangci-lint:v2.1.6, 2026-03-02
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
||||||
@@ -5,6 +15,9 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
|
||||||
|
# without depending on the web-builder stage (lint should fail fast)
|
||||||
|
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN make lint
|
||||||
|
|
||||||
@@ -21,6 +34,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=web-builder /web/dist/ web/dist/
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1032,6 +1032,12 @@ Return the current user's session state.
|
|||||||
|
|
||||||
**Request:** No body. Requires auth.
|
**Request:** No body. Requires auth.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|--------|---------|-------------|
|
||||||
|
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1064,6 +1070,12 @@ curl -s http://localhost:8080/api/v1/state \
|
|||||||
-H "Authorization: Bearer $TOKEN" | jq .
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Reconnect with channel state initialization:**
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
### GET /api/v1/messages — Poll Messages (Long-Poll)
|
||||||
|
|
||||||
Retrieve messages from the client's delivery queue. This is the primary
|
Retrieve messages from the client's delivery queue. This is the primary
|
||||||
@@ -1362,16 +1374,18 @@ Return server metadata. No authentication required.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My NeoIRC Server",
|
"name": "My NeoIRC Server",
|
||||||
|
"version": "0.1.0",
|
||||||
"motd": "Welcome! Be nice.",
|
"motd": "Welcome! Be nice.",
|
||||||
"users": 42
|
"users": 42
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---------|---------|-------------|
|
|-----------|---------|-------------|
|
||||||
| `name` | string | Server display name |
|
| `name` | string | Server display name |
|
||||||
| `motd` | string | Message of the day |
|
| `version` | string | Server version |
|
||||||
| `users` | integer | Number of currently active user sessions |
|
| `motd` | string | Message of the day |
|
||||||
|
| `users` | integer | Number of currently active user sessions |
|
||||||
|
|
||||||
### GET /.well-known/healthcheck.json — Health Check
|
### GET /.well-known/healthcheck.json — Health Check
|
||||||
|
|
||||||
@@ -1610,6 +1624,10 @@ authenticity.
|
|||||||
termination.
|
termination.
|
||||||
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
|
||||||
Restrict this in production via reverse proxy configuration if needed.
|
Restrict this in production via reverse proxy configuration if needed.
|
||||||
|
- **Content-Security-Policy**: The server sets a strict CSP header on all
|
||||||
|
responses, restricting resource loading to same-origin and disabling
|
||||||
|
dangerous features (object embeds, framing, base tag injection). The
|
||||||
|
embedded SPA works without `'unsafe-inline'` for scripts or styles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1838,26 +1856,16 @@ docker run -p 8080:8080 \
|
|||||||
neoirc
|
neoirc
|
||||||
```
|
```
|
||||||
|
|
||||||
The Dockerfile is a multi-stage build:
|
The Dockerfile is a four-stage build:
|
||||||
1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
|
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX →
|
||||||
|
bundled JS via esbuild) into `web/dist/`
|
||||||
|
2. **lint**: Runs formatting checks and golangci-lint against the Go source
|
||||||
|
(uses empty placeholder files for `web/dist/` so it runs independently of
|
||||||
|
web-builder for fast feedback)
|
||||||
|
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
|
||||||
|
binaries with the real SPA assets from web-builder (CLI built to verify
|
||||||
compilation, not included in final image)
|
compilation, not included in final image)
|
||||||
2. **Final stage**: Alpine Linux + `neoircd` binary only
|
4. **final**: Minimal Alpine image with only the `neoircd` binary
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM golang:1.24-alpine AS builder
|
|
||||||
WORKDIR /src
|
|
||||||
RUN apk add --no-cache make
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN go build -o /neoircd ./cmd/neoircd/
|
|
||||||
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["neoircd"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Binary
|
### Binary
|
||||||
|
|
||||||
@@ -2306,10 +2314,14 @@ neoirc/
|
|||||||
│ └── http.go # HTTP timeouts
|
│ └── http.go # HTTP timeouts
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── embed.go # go:embed directive for SPA
|
│ ├── embed.go # go:embed directive for SPA
|
||||||
│ └── dist/ # Built SPA (vanilla JS, no build step)
|
│ ├── build.sh # SPA build script (esbuild, runs in Docker)
|
||||||
│ ├── index.html
|
│ ├── package.json # Node dependencies (preact, esbuild)
|
||||||
│ ├── style.css
|
│ ├── package-lock.json
|
||||||
│ └── app.js
|
│ ├── src/ # SPA source files (JSX + HTML + CSS)
|
||||||
|
│ │ ├── app.jsx
|
||||||
|
│ │ ├── index.html
|
||||||
|
│ │ └── style.css
|
||||||
|
│ └── dist/ # Generated at Docker build time (not committed)
|
||||||
├── schema/ # JSON Schema definitions (planned)
|
├── schema/ # JSON Schema definitions (planned)
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
@@ -2324,7 +2336,7 @@ neoirc/
|
|||||||
| Purpose | Library |
|
| Purpose | Library |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| DI | `go.uber.org/fx` |
|
| DI | `go.uber.org/fx` |
|
||||||
| Router | `github.com/go-chi/chi` |
|
| Router | `github.com/go-chi/chi/v5` |
|
||||||
| Logging | `log/slog` (stdlib) |
|
| Logging | `log/slog` (stdlib) |
|
||||||
| Config | `github.com/spf13/viper` |
|
| Config | `github.com/spf13/viper` |
|
||||||
| Env | `github.com/joho/godotenv/autoload` |
|
| Env | `github.com/joho/godotenv/autoload` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-03-09
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -98,6 +98,13 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -144,8 +151,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/getsentry/sentry-go v0.42.0
|
github.com/getsentry/sentry-go v0.42.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
|
|||||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ func (database *Database) RegisterUser(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -137,12 +139,14 @@ func (database *Database) LoginUser(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
res, err := database.conn.ExecContext(ctx,
|
res, err := database.conn.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
"create login client: %w", err,
|
"create login client: %w", err,
|
||||||
|
|||||||
20
internal/db/errors.go
Normal file
20
internal/db/errors.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Package db provides database access and migration management.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"modernc.org/sqlite"
|
||||||
|
sqlite3 "modernc.org/sqlite/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsUniqueConstraintError reports whether err is a SQLite
|
||||||
|
// unique-constraint violation.
|
||||||
|
func IsUniqueConstraintError(err error) bool {
|
||||||
|
var sqliteErr *sqlite.Error
|
||||||
|
if !errors.As(err, &sqliteErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -31,6 +32,14 @@ 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"`
|
||||||
@@ -105,12 +114,14 @@ func (database *Database) CreateSession(
|
|||||||
|
|
||||||
sessionID, _ := res.LastInsertId()
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
clientRes, err := transaction.ExecContext(ctx,
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
`INSERT INTO clients
|
`INSERT INTO clients
|
||||||
(uuid, session_id, token,
|
(uuid, session_id, token,
|
||||||
created_at, last_seen)
|
created_at, last_seen)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
clientUUID, sessionID, token, now, now)
|
clientUUID, sessionID, tokenHash, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = transaction.Rollback()
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
@@ -143,6 +154,8 @@ func (database *Database) GetSessionByToken(
|
|||||||
nick string
|
nick string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
err := database.conn.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT s.id, c.id, s.nick
|
`SELECT s.id, c.id, s.nick
|
||||||
@@ -150,7 +163,7 @@ func (database *Database) GetSessionByToken(
|
|||||||
INNER JOIN sessions s
|
INNER JOIN sessions s
|
||||||
ON s.id = c.session_id
|
ON s.id = c.session_id
|
||||||
WHERE c.token = ?`,
|
WHERE c.token = ?`,
|
||||||
token,
|
tokenHash,
|
||||||
).Scan(&sessionID, &clientID, &nick)
|
).Scan(&sessionID, &clientID, &nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/irc"
|
"git.eeqj.de/sneak/neoirc/internal/irc"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
@@ -199,7 +200,7 @@ func (hdlr *Handlers) handleCreateSessionError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -444,13 +445,17 @@ func (hdlr *Handlers) enqueueNumeric(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleState returns the current session's info and
|
// HandleState returns the current session's info and
|
||||||
// channels.
|
// channels. When called with ?initChannelState=1, it also
|
||||||
|
// enqueues synthetic JOIN + TOPIC + NAMES messages for
|
||||||
|
// every channel the session belongs to so that a
|
||||||
|
// reconnecting client can rebuild its channel tabs from
|
||||||
|
// the message stream.
|
||||||
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
sessionID, _, nick, ok :=
|
sessionID, clientID, nick, ok :=
|
||||||
hdlr.requireAuth(writer, request)
|
hdlr.requireAuth(writer, request)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -472,6 +477,12 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if request.URL.Query().Get("initChannelState") == "1" {
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, nick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": nick,
|
||||||
@@ -480,6 +491,52 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initChannelState enqueues synthetic JOIN messages and
|
||||||
|
// join-numerics (TOPIC, NAMES) for every channel the
|
||||||
|
// session belongs to. Messages are enqueued only to the
|
||||||
|
// specified client so other clients/sessions are not
|
||||||
|
// affected.
|
||||||
|
func (hdlr *Handlers) initChannelState(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
channels, err := hdlr.params.Database.
|
||||||
|
GetSessionChannels(ctx, sessionID)
|
||||||
|
if err != nil || len(channels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chanInfo := range channels {
|
||||||
|
// Enqueue a synthetic JOIN (only to this client).
|
||||||
|
dbID, _, insErr := hdlr.params.Database.
|
||||||
|
InsertMessage(
|
||||||
|
ctx, "JOIN", nick, chanInfo.Name,
|
||||||
|
nil, nil, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"initChannelState: insert JOIN",
|
||||||
|
"error", insErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = hdlr.params.Database.EnqueueToClient(
|
||||||
|
ctx, clientID, dbID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enqueue TOPIC + NAMES numerics.
|
||||||
|
hdlr.deliverJoinNumerics(
|
||||||
|
request, clientID, sessionID,
|
||||||
|
nick, chanInfo.Name, chanInfo.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HandleListAllChannels returns all channels on the server.
|
// HandleListAllChannels returns all channels on the server.
|
||||||
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -1371,7 +1428,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
request.Context(), sessionID, newNick,
|
request.Context(), sessionID, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNicknameInUse, nick, []string{newNick},
|
irc.ErrNicknameInUse, nick, []string{newNick},
|
||||||
@@ -2336,9 +2393,10 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"name": hdlr.params.Config.ServerName,
|
"name": hdlr.params.Config.ServerName,
|
||||||
"motd": hdlr.params.Config.MOTD,
|
"version": hdlr.params.Globals.Version,
|
||||||
"users": users,
|
"motd": hdlr.params.Config.MOTD,
|
||||||
|
"users": users,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
@@ -94,7 +96,7 @@ func (hdlr *Handlers) handleRegisterError(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
if db.IsUniqueConstraintError(err) {
|
||||||
hdlr.respondError(
|
hdlr.respondError(
|
||||||
writer, request,
|
writer, request,
|
||||||
"nick already taken",
|
"nick already taken",
|
||||||
@@ -182,6 +184,12 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize channel state so the new client knows
|
||||||
|
// which channels the session already belongs to.
|
||||||
|
hdlr.initChannelState(
|
||||||
|
request, clientID, sessionID, payload.Nick,
|
||||||
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": payload.Nick,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
@@ -142,20 +142,6 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns middleware that performs authentication.
|
|
||||||
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
) {
|
|
||||||
mware.log.Info("AUTH: before request")
|
|
||||||
next.ServeHTTP(writer, request)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metrics returns middleware that records HTTP metrics.
|
// Metrics returns middleware that records HTTP metrics.
|
||||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||||
@@ -180,3 +166,36 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cspPolicy is the Content-Security-Policy header value applied to all
|
||||||
|
// responses. The embedded SPA loads scripts and styles from same-origin
|
||||||
|
// files only (no inline scripts or inline style attributes), so a strict
|
||||||
|
// policy works without 'unsafe-inline'.
|
||||||
|
const cspPolicy = "default-src 'self'; " +
|
||||||
|
"script-src 'self'; " +
|
||||||
|
"style-src 'self'; " +
|
||||||
|
"connect-src 'self'; " +
|
||||||
|
"img-src 'self'; " +
|
||||||
|
"font-src 'self'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"frame-ancestors 'none'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self'"
|
||||||
|
|
||||||
|
// CSP returns middleware that sets the Content-Security-Policy header on
|
||||||
|
// every response for defense-in-depth against XSS.
|
||||||
|
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
cspPolicy,
|
||||||
|
)
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/web"
|
"git.eeqj.de/sneak/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@@ -29,6 +29,7 @@ func (srv *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv.router.Use(srv.mw.CORS())
|
srv.router.Use(srv.mw.CORS())
|
||||||
|
srv.router.Use(srv.mw.CSP())
|
||||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||||
|
|
||||||
if srv.sentryEnabled {
|
if srv.sentryEnabled {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
2
web/dist/app.js
vendored
2
web/dist/app.js
vendored
File diff suppressed because one or more lines are too long
13
web/dist/index.html
vendored
13
web/dist/index.html
vendored
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>NeoIRC</title>
|
|
||||||
<link rel="stylesheet" href="/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
466
web/dist/style.css
vendored
466
web/dist/style.css
vendored
@@ -1,466 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0a0e14;
|
|
||||||
--bg-panel: #0d1117;
|
|
||||||
--bg-input: #0d1117;
|
|
||||||
--bg-tab: #161b22;
|
|
||||||
--bg-tab-active: #0d1117;
|
|
||||||
--bg-topic: #0d1117;
|
|
||||||
--text: #c9d1d9;
|
|
||||||
--text-dim: #6e7681;
|
|
||||||
--text-bright: #e6edf3;
|
|
||||||
--accent: #58a6ff;
|
|
||||||
--accent-dim: #1f6feb;
|
|
||||||
--border: #21262d;
|
|
||||||
--system: #7d8590;
|
|
||||||
--action: #d2a8ff;
|
|
||||||
--warn: #d29922;
|
|
||||||
--error: #f85149;
|
|
||||||
--unread: #f0883e;
|
|
||||||
--nick-brackets: #6e7681;
|
|
||||||
--timestamp: #484f58;
|
|
||||||
--input-bg: #161b22;
|
|
||||||
--prompt: #3fb950;
|
|
||||||
--tab-indicator: #58a6ff;
|
|
||||||
--user-list-bg: #0d1117;
|
|
||||||
--user-list-header: #484f58;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "SF Mono",
|
|
||||||
"Consolas", "Liberation Mono", "Courier New", monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Login Screen
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.login-screen {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box h1 {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 1.8em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box .motd {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-bright);
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box input:focus {
|
|
||||||
border-color: var(--accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box button:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-box .error {
|
|
||||||
color: var(--error);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
IRC App Layout
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.irc-app {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Tab Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.tab-bar {
|
|
||||||
display: flex;
|
|
||||||
background: var(--bg-tab);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 32px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
flex: 1;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 4px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
color: var(--text-bright);
|
|
||||||
background: var(--bg-tab-active);
|
|
||||||
border-bottom: 2px solid var(--tab-indicator);
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.has-unread .tab-label {
|
|
||||||
color: var(--unread);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab .unread-count {
|
|
||||||
color: var(--unread);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-close:hover {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-nick {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-warn {
|
|
||||||
color: var(--warn);
|
|
||||||
animation: blink 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Topic Bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.topic-bar {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--bg-topic);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic-text {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Main Content Area
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.main-area {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Messages Panel
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.messages-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 8px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Message Lines
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.message {
|
|
||||||
padding: 1px 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .timestamp {
|
|
||||||
color: var(--timestamp);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .nick {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message .content {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* System messages (joins, parts, quits, etc.) */
|
|
||||||
.system-message {
|
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-message .system-text {
|
|
||||||
color: var(--system);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* /me action messages */
|
|
||||||
.action-message .action-text {
|
|
||||||
color: var(--action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
User List (Right Panel)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
width: 160px;
|
|
||||||
background: var(--user-list-bg);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-header {
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: var(--user-list-header);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list-entries {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 0;
|
|
||||||
flex: 1;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry {
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-prefix {
|
|
||||||
color: var(--text-dim);
|
|
||||||
display: inline-block;
|
|
||||||
width: 1ch;
|
|
||||||
text-align: right;
|
|
||||||
margin-right: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nick-name {
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Input Line (Bottom)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.input-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--input-bg);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
color: var(--prompt);
|
|
||||||
font-size: 13px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 4px 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-bright);
|
|
||||||
outline: none;
|
|
||||||
caret-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-line input::placeholder {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Responsive
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.user-list {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -70,7 +70,7 @@ function LoginScreen({ onLogin }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const saved = localStorage.getItem("neoirc_token");
|
const saved = localStorage.getItem("neoirc_token");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
api("/state")
|
api("/state?initChannelState=1")
|
||||||
.then((u) => onLogin(u.nick, true))
|
.then((u) => onLogin(u.nick, true))
|
||||||
.catch(() => localStorage.removeItem("neoirc_token"));
|
.catch(() => localStorage.removeItem("neoirc_token"));
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,24 @@ function App() {
|
|||||||
case "JOIN": {
|
case "JOIN": {
|
||||||
const text = `${msg.from} has joined ${msg.to}`;
|
const text = `${msg.from} has joined ${msg.to}`;
|
||||||
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
|
||||||
if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
|
if (msg.to && msg.to.startsWith("#")) {
|
||||||
|
// Create a tab when the current user joins a channel
|
||||||
|
// (including JOINs from initChannelState on reconnect).
|
||||||
|
if (msg.from === nickRef.current) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
if (
|
||||||
|
prev.find(
|
||||||
|
(t) => t.type === "channel" && t.name === msg.to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return prev;
|
||||||
|
|
||||||
|
return [...prev, { type: "channel", name: msg.to }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMembers(msg.to);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -636,9 +653,13 @@ function App() {
|
|||||||
setLoggedIn(true);
|
setLoggedIn(true);
|
||||||
addSystemMessage("Server", `Connected as ${userNick}`);
|
addSystemMessage("Server", `Connected as ${userNick}`);
|
||||||
|
|
||||||
// Request MOTD on resumed sessions (new sessions get
|
|
||||||
// it automatically from the server during creation).
|
|
||||||
if (isResumed) {
|
if (isResumed) {
|
||||||
|
// Request MOTD on resumed sessions (new sessions
|
||||||
|
// get it automatically from the server during
|
||||||
|
// creation). Channel state is initialized by the
|
||||||
|
// server via the message queue
|
||||||
|
// (?initChannelState=1), so we do not need to
|
||||||
|
// re-JOIN channels here.
|
||||||
try {
|
try {
|
||||||
await api("/messages", {
|
await api("/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -647,8 +668,11 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// MOTD is non-critical.
|
// MOTD is non-critical.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fresh session — join any previously saved channels.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
localStorage.getItem("neoirc_channels") || "[]",
|
localStorage.getItem("neoirc_channels") || "[]",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user