6 Commits

Author SHA1 Message Date
6e7bf028c1 fix: change appname to neoirc, default DB to /var/lib/neoirc/state.db (#45)
All checks were successful
check / check (push) Successful in 6s
## Changes

- Change `Appname` from `"chat"` to `"neoirc"` in `cmd/chatd/main.go`
- Change default `DBURL` from `file:./data.db?_journal_mode=WAL` to `file:///var/lib/neoirc/state.db?_journal_mode=WAL` in both `internal/config/config.go` and the `internal/db/db.go` fallback
- Create `/var/lib/neoirc/` directory in Dockerfile with proper ownership for the `chat` user
- Update README.md to reflect new defaults (DBURL table, `.env` example, docker run example, SQLite backup/location docs)
- Remove stale `data.db` reference from Makefile `clean` target

The DB path remains configurable via the `DBURL` environment variable. No Go packages were renamed.

Closes #44

Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Reviewed-on: #45
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-06 12:40:28 +01:00
2761ee156a feat: split Dockerfile into dedicated lint stage for faster CI (#32)
All checks were successful
check / check (push) Successful in 3m4s
## Summary

Split the Dockerfile into a dedicated lint stage using the prebuilt `golangci/golangci-lint:v2.1.6` image, so lint failures are reported faster without needing to download/compile golangci-lint first.

## Changes

- **New lint stage** (`AS lint`): Uses the prebuilt `golangci/golangci-lint` image (pinned by sha256). Runs `make fmt-check` and `make lint`.
- **Build stage** (`AS builder`): Runs `make test` + compilation. No longer installs golangci-lint via `go install`.
- **`COPY --from=lint`**: Forces BuildKit to execute the lint stage before proceeding with the build.
- **Runtime stage**: Unchanged.

All base images remain pinned by sha256 hash.

closes #27

<!-- session: agent:sdlc-manager:subagent:76cebdf6-86f0-4383-93e3-ff3e10fbc7a6 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #32
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:05:08 +01:00
cd909d59c4 Merge pull request 'feat: logout, users/me, user count, session timeout' (#24) from feature/mvp-remaining into main
All checks were successful
check / check (push) Successful in 1m58s
Reviewed-on: #24
2026-03-01 15:47:03 +01:00
clawbot
f5cc098b7b docs: update README for new endpoints, fix config name, remove dead field
All checks were successful
check / check (push) Successful in 1m24s
- Document POST /api/v1/logout endpoint
- Document GET /api/v1/users/me endpoint
- Add 'users' field to GET /api/v1/server response docs
- Fix config: SESSION_TIMEOUT -> SESSION_IDLE_TIMEOUT
- Update storage section: session expiry is implemented
- Update roadmap: move session expiry to implemented
- Remove dead SessionTimeout config field from Go code
2026-03-01 06:41:10 -08:00
user
4d7b7618b2 fix: send QUIT notifications for background idle cleanup
All checks were successful
check / check (push) Successful in 2m2s
The background idle cleanup (DeleteStaleUsers) was removing stale
clients/sessions directly via SQL without sending QUIT notifications
to channel members. This caused timed-out users to silently disappear
from channels.

Now runCleanup identifies sessions that will be orphaned by the stale
client deletion and calls cleanupUser for each one first, ensuring
QUIT messages are sent to all channel members — matching the explicit
logout behavior.

Also refactored cleanupUser to accept context.Context instead of
*http.Request so it can be called from both HTTP handlers and the
background cleanup goroutine.
2026-03-01 06:33:15 -08:00
user
910a5c2606 fix: OnStart ctx bug, rename session→user, full logout cleanup
All checks were successful
check / check (push) Successful in 1m57s
- Use context.Background() for cleanup goroutine instead of
  OnStart ctx which is cancelled after startup completes
- Rename GetSessionCount→GetUserCount, DeleteStaleSessions→
  DeleteStaleUsers to reflect that sessions represent users
- HandleLogout now fully cleans up when last client disconnects:
  parts all channels (notifying members via QUIT), removes
  empty channels, and deletes the session/user record
- docker build passes, all tests green, 0 lint issues
2026-02-28 11:14:23 -08:00
9 changed files with 255 additions and 41 deletions

View File

@@ -1,28 +1,41 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.24-alpine, 2026-02-26 # golang:1.24-alpine, 2026-02-26
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
WORKDIR /src WORKDIR /src
RUN apk add --no-cache git build-base make RUN apk add --no-cache git build-base make
# golangci-lint v2.1.6 (eabc2638a66d), 2026-02-26 # Force BuildKit to run the lint stage before proceeding
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@eabc2638a66daf5bb6c6fb052a32fa3ef7b6600d COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Run all checks — build fails if branch is not green RUN make test
RUN make check
# Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) # Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go)
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /chatd ./cmd/chatd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /chat-cli ./cmd/chat-cli/
# Runtime stage
# alpine:3.21, 2026-02-26 # alpine:3.21, 2026-02-26
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates \ RUN apk add --no-cache ca-certificates \
&& addgroup -S chat && adduser -S chat -G chat && addgroup -S chat && adduser -S chat -G chat \
&& mkdir -p /var/lib/neoirc \
&& chown chat:chat /var/lib/neoirc
COPY --from=builder /chatd /usr/local/bin/chatd COPY --from=builder /chatd /usr/local/bin/chatd
USER chat USER chat

View File

@@ -37,7 +37,7 @@ debug: build
DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY) DEBUG=1 GOTRACEBACK=all ./bin/$(BINARY)
clean: clean:
rm -rf bin/ chatd data.db rm -rf bin/ chatd
docker: docker:
docker build -t chat . docker build -t chat .

View File

@@ -1158,6 +1158,55 @@ curl -s http://localhost:8080/api/v1/channels/general/members \
-H "Authorization: Bearer $TOKEN" | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
### POST /api/v1/logout — Logout
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/users/me — Current User Info
Return the current user's session state. This is an alias for
`GET /api/v1/state`.
**Request:** No body. Requires auth.
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"}
]
}
```
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/server — Server Info ### GET /api/v1/server — Server Info
Return server metadata. No authentication required. Return server metadata. No authentication required.
@@ -1166,10 +1215,17 @@ Return server metadata. No authentication required.
```json ```json
{ {
"name": "My Chat Server", "name": "My Chat Server",
"motd": "Welcome! Be nice." "motd": "Welcome! Be nice.",
"users": 42
} }
``` ```
| Field | Type | Description |
|---------|---------|-------------|
| `name` | string | Server display name |
| `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions |
### GET /.well-known/healthcheck.json — Health Check ### GET /.well-known/healthcheck.json — Health Check
Standard health check endpoint. No authentication required. Standard health check endpoint. No authentication required.
@@ -1572,8 +1628,10 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is - **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
planned. planned.
- **Channels**: Deleted when the last member leaves (ephemeral). - **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT`. Session expiry by `SESSION_TIMEOUT` - **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
is planned. sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
24h) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
--- ---
@@ -1587,10 +1645,10 @@ directory is also loaded automatically via
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
|--------------------|---------|--------------------------------------|-------------| |--------------------|---------|--------------------------------------|-------------|
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. |
| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | | `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) |
| `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) | | `MAX_HISTORY` | int | `10000` | Maximum messages retained per channel before rotation (planned) |
| `SESSION_TIMEOUT` | int | `86400` | Session idle timeout in seconds (planned). Sessions with no activity for this long are expired and the nick is released. | | `SESSION_IDLE_TIMEOUT` | string | `24h` | Session idle timeout as a Go duration string (e.g. `24h`, `30m`). Sessions with no activity for this long are expired and the nick is released. |
| `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). | | `QUEUE_MAX_AGE` | int | `172800` | Maximum age of client queue entries in seconds (48h). Entries older than this are pruned (planned). |
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | | `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) |
| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | | `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) |
@@ -1609,8 +1667,8 @@ PORT=8080
SERVER_NAME=My Chat Server SERVER_NAME=My Chat Server
MOTD=Welcome! Be excellent to each other. MOTD=Welcome! Be excellent to each other.
DEBUG=false DEBUG=false
DBURL=file:./data.db?_journal_mode=WAL DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_TIMEOUT=86400 SESSION_IDLE_TIMEOUT=24h
``` ```
--- ---
@@ -1627,8 +1685,7 @@ docker build -t chat .
# Run # Run
docker run -p 8080:8080 \ docker run -p 8080:8080 \
-v chat-data:/data \ -v chat-data:/var/lib/neoirc \
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
-e SERVER_NAME="My Server" \ -e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \ -e MOTD="Welcome!" \
chat chat
@@ -1664,7 +1721,7 @@ make build
# Run # Run
./bin/chatd ./bin/chatd
# Listens on :8080, creates ./data.db # Listens on :8080, writes to /var/lib/neoirc/state.db
``` ```
### Reverse Proxy (Production) ### Reverse Proxy (Production)
@@ -1705,8 +1762,8 @@ seconds to accommodate long-poll connections.
string). This allows concurrent reads during writes. string). This allows concurrent reads during writes.
- **Single writer**: SQLite allows only one writer at a time. For high-traffic - **Single writer**: SQLite allows only one writer at a time. For high-traffic
servers, Postgres support is planned. servers, Postgres support is planned.
- **Backup**: The database is a single file. Back it up with `sqlite3 data.db ".backup backup.db"` or just copy the file (safe with WAL mode). - **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode).
- **Location**: By default, `data.db` is created in the working directory. - **Location**: By default, `state.db` is created in `/var/lib/neoirc/`.
Use the `DBURL` env var to place it elsewhere. Use the `DBURL` env var to place it elsewhere.
--- ---
@@ -2008,11 +2065,14 @@ GET /api/v1/challenge
- [x] Docker deployment - [x] Docker deployment
- [x] Prometheus metrics endpoint - [x] Prometheus metrics endpoint
- [x] Health check endpoint - [x] Health check endpoint
- [x] Session expiry — auto-expire idle sessions, release nicks
- [x] Logout endpoint (`POST /api/v1/logout`)
- [x] Current user endpoint (`GET /api/v1/users/me`)
- [x] User count in server info (`GET /api/v1/server`)
### Post-MVP (Planned) ### Post-MVP (Planned)
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention) - [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
- [ ] **Session expiry** — auto-expire idle sessions, release nicks
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE` - [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`

View File

@@ -15,7 +15,7 @@ import (
var ( var (
// Appname is the application name, set at build time. // Appname is the application name, set at build time.
Appname = "chat" //nolint:gochecknoglobals Appname = "neoirc" //nolint:gochecknoglobals
// Version is the application version, set at build time. // Version is the application version, set at build time.
Version string //nolint:gochecknoglobals Version string //nolint:gochecknoglobals

View File

@@ -31,7 +31,6 @@ type Config struct {
Port int Port int
SentryDSN string SentryDSN string
MaxHistory int MaxHistory int
SessionTimeout int
MaxMessageSize int MaxMessageSize int
MOTD string MOTD string
ServerName string ServerName string
@@ -57,12 +56,11 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL") viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("MAX_HISTORY", "10000") viper.SetDefault("MAX_HISTORY", "10000")
viper.SetDefault("SESSION_TIMEOUT", "86400")
viper.SetDefault("MAX_MESSAGE_SIZE", "4096") viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
viper.SetDefault("MOTD", "") viper.SetDefault("MOTD", "")
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
@@ -87,7 +85,6 @@ func New(
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MaxHistory: viper.GetInt("MAX_HISTORY"), MaxHistory: viper.GetInt("MAX_HISTORY"),
SessionTimeout: viper.GetInt("SESSION_TIMEOUT"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),

View File

@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = "file:./data.db?_journal_mode=WAL&_busy_timeout=5000" dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
} }
database.log.Info( database.log.Info(

View File

@@ -796,8 +796,8 @@ func (database *Database) DeleteClient(
return nil return nil
} }
// GetSessionCount returns the number of active sessions. // GetUserCount returns the number of active users.
func (database *Database) GetSessionCount( func (database *Database) GetUserCount(
ctx context.Context, ctx context.Context,
) (int64, error) { ) (int64, error) {
var count int64 var count int64
@@ -808,7 +808,7 @@ func (database *Database) GetSessionCount(
).Scan(&count) ).Scan(&count)
if err != nil { if err != nil {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"get session count: %w", err, "get user count: %w", err,
) )
} }
@@ -838,9 +838,9 @@ func (database *Database) ClientCountForSession(
return count, nil return count, nil
} }
// DeleteStaleSessions removes clients not seen since the // DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned sessions. // cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleSessions( func (database *Database) DeleteStaleUsers(
ctx context.Context, ctx context.Context,
cutoff time.Time, cutoff time.Time,
) (int64, error) { ) (int64, error) {
@@ -869,6 +869,60 @@ func (database *Database) DeleteStaleSessions(
return deleted, nil return deleted, nil
} }
// StaleSession holds the id and nick of a session
// whose clients are all stale.
type StaleSession struct {
ID int64
Nick string
}
// GetStaleOrphanSessions returns sessions where every
// client has a last_seen before cutoff.
func (database *Database) GetStaleOrphanSessions(
ctx context.Context,
cutoff time.Time,
) ([]StaleSession, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick
FROM sessions s
WHERE s.id IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen < ?
)
AND s.id NOT IN (
SELECT DISTINCT session_id FROM clients
WHERE last_seen >= ?
)`, cutoff, cutoff)
if err != nil {
return nil, fmt.Errorf(
"get stale orphan sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var result []StaleSession
for rows.Next() {
var stale StaleSession
if err := rows.Scan(&stale.ID, &stale.Nick); err != nil {
return nil, fmt.Errorf(
"scan stale session: %w", err,
)
}
result = append(result, stale)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate stale sessions: %w", err,
)
}
return result, nil
}
// GetSessionChannels returns channels a session // GetSessionChannels returns channels a session
// belongs to. // belongs to.
func (database *Database) GetSessionChannels( func (database *Database) GetSessionChannels(

View File

@@ -1361,20 +1361,23 @@ func (hdlr *Handlers) canAccessChannelHistory(
return true return true
} }
// HandleLogout deletes the authenticated client's token. // HandleLogout deletes the authenticated client's token
// and cleans up the user (session) if no clients remain.
func (hdlr *Handlers) HandleLogout() http.HandlerFunc { func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return func( return func(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
_, clientID, _, ok := sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request) hdlr.requireAuth(writer, request)
if !ok { if !ok {
return return
} }
ctx := request.Context()
err := hdlr.params.Database.DeleteClient( err := hdlr.params.Database.DeleteClient(
request.Context(), clientID, ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -1389,12 +1392,77 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
} }
} }
// cleanupUser parts the user from all channels (notifying
// members) and deletes the session.
func (hdlr *Handlers) cleanupUser(
ctx context.Context,
sessionID int64,
nick string,
) {
channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
notified := map[int64]bool{}
var quitDBID int64
if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "", nil, nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(ctx, chanInfo.ID)
for _, mid := range memberIDs {
if mid != sessionID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
ctx, mid, quitDBID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
ctx, chanInfo.ID, sessionID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
ctx, chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(ctx, sessionID)
}
// HandleUsersMe returns the current user's session info. // HandleUsersMe returns the current user's session info.
func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc { func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc {
return hdlr.HandleState() return hdlr.HandleState()
@@ -1406,12 +1474,12 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
users, err := hdlr.params.Database.GetSessionCount( users, err := hdlr.params.Database.GetUserCount(
request.Context(), request.Context(),
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"get session count failed", "error", err, "get user count failed", "error", err,
) )
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,

View File

@@ -124,8 +124,16 @@ func (hdlr *Handlers) idleTimeout() time.Duration {
return dur return dur
} }
func (hdlr *Handlers) startCleanup(ctx context.Context) { // startCleanup launches the idle-user cleanup goroutine.
cleanupCtx, cancel := context.WithCancel(ctx) // We use context.Background rather than the OnStart ctx
// because the OnStart context is startup-scoped and would
// cancel the goroutine once all start hooks complete.
//
//nolint:contextcheck // intentional Background ctx
func (hdlr *Handlers) startCleanup(_ context.Context) {
cleanupCtx, cancel := context.WithCancel(
context.Background(),
)
hdlr.cancelCleanup = cancel hdlr.cancelCleanup = cancel
go hdlr.cleanupLoop(cleanupCtx) go hdlr.cleanupLoop(cleanupCtx)
@@ -161,12 +169,26 @@ func (hdlr *Handlers) runCleanup(
) { ) {
cutoff := time.Now().Add(-timeout) cutoff := time.Now().Add(-timeout)
deleted, err := hdlr.params.Database.DeleteStaleSessions( // Find sessions that will be orphaned so we can send
// QUIT notifications before deleting anything.
stale, err := hdlr.params.Database.
GetStaleOrphanSessions(ctx, cutoff)
if err != nil {
hdlr.log.Error(
"stale session lookup failed", "error", err,
)
}
for _, ss := range stale {
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
}
deleted, err := hdlr.params.Database.DeleteStaleUsers(
ctx, cutoff, ctx, cutoff,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"session cleanup failed", "error", err, "user cleanup failed", "error", err,
) )
return return
@@ -174,7 +196,7 @@ func (hdlr *Handlers) runCleanup(
if deleted > 0 { if deleted > 0 {
hdlr.log.Info( hdlr.log.Info(
"cleaned up stale clients", "cleaned up stale users",
"deleted", deleted, "deleted", deleted,
) )
} }