# Lindic Chat Server

Gemeinsamer Echtzeit-Chat für alle Anwendungen (PWAs) unter `services.lindic.net`.

**Anwendungsname (`app`) ist der Tenant-Schlüssel.** Nachrichten einer App erreichen nie eine andere App.

- WebSocket: `wss://services.lindic.net/ws/chat`
- Token: `POST https://services.lindic.net/api/chat/token.php`
- Client-Modul: `https://services.lindic.net/modules/chat/lindic-chat.js`
- Config: `GET https://services.lindic.net/api/chat/config.php`
- Health: `GET https://services.lindic.net/api/health.php`
- Chat Lab: `https://services.lindic.net/lab/chat/`
- Code: `/var/www/projects/services/services/chat/`
- Systemd: `services-chat-ws.service`

---

## Schnellstart in einer PWA

```js
import { LindicChat } from 'https://services.lindic.net/modules/chat/lindic-chat.js';

const chat = new LindicChat({
  app: 'phoenix',          // Pflicht: Name der Anwendung (Routing-Schlüssel)
  userId: 'user-42',       // Pflicht: stabile User-ID in dieser App
  nickname: 'Alex',        // optional
  // Produktion: token ODER getToken() vom eigenen Backend (mit app_secret).
  // tokenUrl / wsUrl optional — Defaults zeigen auf services.lindic.net
});

chat.on('chat', (m) => {
  console.log(`[${m.room}] ${m.nickname}: ${m.body}`);
});
chat.on('dm', (m) => {
  console.log(`DM von ${m.nickname}: ${m.body}`);
});
chat.on('presence', (m) => {
  console.log('presence', m.event, m.user_id, m.members);
});
chat.on('error', (m) => console.warn('chat error', m));

await chat.connect();
await chat.join('lobby');          // Raum innerhalb der App
chat.send('lobby', 'Hallo Welt');  // Raum-Nachricht
chat.dm('user-99', 'privat');      // optionaler Privatchat
```

Konfiguration = `{ app, userId, nickname?, token? | getToken? }`. Ohne Token muss die Mint-API ein gültiges `app_secret` sehen (nur Backend). Lab: siehe `/lab/chat/`.

---

## Konzepte

| Begriff | Bedeutung |
|--------|-----------|
| **app** | Name der PWA/Anwendung. Isoliert alle Räume, Presence und DMs. |
| **room** | Öffentlicher/Gruppenraum *innerhalb* einer App (`lobby`, `game:1`, …). |
| **dm** | Optionaler Privatchat zwischen zwei User-IDs derselben App. |
| **filter** | Serverseitige Sperrliste für verbotene Inhalte (`words/blocked.txt`). |

Alias: ältere Felder `app_id` / `channel` werden weiterhin akzeptiert (`channel` ≡ `room`).

---

## Authentifizierung

1. App holt kurzlebiges HMAC-Token:

```http
POST /api/chat/token.php
Content-Type: application/json

{ "app": "phoenix", "user_id": "u1", "nickname": "Alex" }
```

Antwort:

```json
{
  "ok": true,
  "token": "...",
  "expires_at": 1710000000,
  "ws_url": "wss://services.lindic.net/ws/chat",
  "module_url": "https://services.lindic.net/modules/chat/lindic-chat.js",
  "features": { "rooms": true, "dm": true, "filter": true }
}
```

2. WebSocket öffnen: `wss://services.lindic.net/ws/chat?token=...`

Token-Secret liegt in `config/.chat_secret` bzw. `config/chat.env` (`CHAT_SECRET`).  
Optional: `config/app.php` → `chat.allowed_apps` auf erlaubte App-Namen beschränken.

---

## Protokoll (JSON)

### Server → Client

| type | Inhalt |
|------|--------|
| `welcome` | `app`, `user_id`, `nickname`, `features`, `version` |
| `joined` | `room`, optional `members[]` |
| `left` | `room` |
| `presence` | `room`, `event`=`join`\|`leave`, `user_id`, `nickname`, optional `members[]` |
| `chat` | `app`, `room`, `from`, `nickname`, `body`, `ts` |
| `dm` | `app`, `room`, `from`, `to`, `nickname`, `body`, `ts` |
| `pong` | `ts` |
| `error` | `error` (z. B. `rate_limited`, `forbidden_content`, `dm_disabled`) |

### Client → Server

| type | Felder |
|------|--------|
| `join` | `room` (oder `channel`) |
| `leave` | `room` |
| `chat` / `message` | `room`, `body` (oder `text`) |
| `dm` | `to`, `body` |
| `ping` | — |

---

## Skalierung / Flutschutz

Der Hub ist für hohe Parallelität ausgelegt:

1. **Outbound-Queues pro Verbindung** — langsame Clients blockieren den Fan-out nicht. Bei Überlauf werden älteste Frames verworfen (`CHAT_OUTBOX_MAX`).
2. **Einmal serialisieren** — JSON wird pro Broadcast einmal gebaut, dann an alle Queues verteilt.
3. **Token-Bucket inbound** — `CHAT_RATE_MSG_PER_SEC` / `CHAT_RATE_BURST` gegen Nachrichtenfluten.
4. **Limits** — max. Räume pro Socket, max. Sockets pro User (`CHAT_MAX_*`).
5. **Kein Sync-I/O im Hot-Path** — Filter ist In-Memory-Regex; Persistenz kann später als Plugin ergänzt werden.

Nginx terminiert TLS und proxyt nach `127.0.0.1:8780` (`snippets/services-chat-ws.conf`).

---

## Inhaltsfilter

- Datei: `services/chat/words/blocked.txt`
- Eine Phrase pro Zeile; `#` = Kommentar
- Regex: `/ausdruck/i`-ähnlich als `/ausdruck/`
- Ein/Aus: `CHAT_FILTER_ENABLED=1`
- Nach Änderung: Dienst neu starten oder später Hot-Reload-Endpoint ergänzen

```bash
sudo systemctl restart services-chat-ws
```

Treffer → Client erhält `{ "type":"error", "error":"forbidden_content" }`, Nachricht wird nicht verteilt.

---

## Privatchat (optional)

- Default an: `CHAT_DM_ENABLED=1`
- Nur innerhalb derselben `app`
- Zustellung an alle Sockets beider User
- Virtueller Raum-Key: `dm:<userA>:<userB>` (sortiert) — nicht per `join` betretbar

---

## Betrieb

```bash
# Status
systemctl status services-chat-ws
journalctl -u services-chat-ws -f

# Neustart nach Config-/Filter-Änderung
sudo systemctl restart services-chat-ws

# Nginx-WS-Include erneut setzen (falls VHost neu geschrieben wurde)
sudo /var/www/projects/services/deploy/apply-nginx-chat.sh
```

Wichtige Env-Variablen: `config/chat.env`.

---

## Erweiterungspunkte

| Ziel | Ort |
|------|-----|
| Protokoll / neue Message-Types | `chat/handlers.py` |
| Fan-out / Presence / Queues | `chat/bus.py` |
| Auth / Token-Format | `chat/auth.py` + `src/Chat/TokenService.php` |
| Filter-Logik | `chat/filter.py` + `words/blocked.txt` |
| Rate-Limits | `chat/rate.py` + `config/chat.env` |
| PWA-Client | `public/modules/chat/lindic-chat.js` |
| Multi-Node später | Redis Pub/Sub hinter `Hub.publish_*` einhängen |

Der Einstieg bleibt `server.py`. Neue Features als Module unter `chat/` halten, nicht den Entry monolitisch wachsen lassen.

---

## Sicherheit (Kurz)

- Tokens sind signiert und zeitlich begrenzt.
- Apps sind strikt getrennt.
- DM und Räume nur mit gültigem Token der jeweiligen App.
- Öffentliches Token-Minting aktuell ohne App-Secret — für Produktion `allowed_apps` setzen und idealerweise serverseitig aus der PWA-Backend-Session minten (Token-URL der App zeigt auf eigenes Backend, das intern den Services-Token holt).

Empfohlenes Produktionsmuster:

```
PWA → eigene API (Session) → services token.php (server-to-server) → PWA bekommt Token → WS
```

---

## Changelog

- **1.1.0** — P2P-Dateiversand (Signaling über Chat, Bytes via WebRTC), Client `lindic-file-p2p.js`.
- **1.0.0** — Multi-App-Hub, Räume, optional DM, Inhaltsfilter, Outbox-Fan-out, Rate-Limit, JS-Client-Modul.

---

## Dateiversand (P2P)

Datei-**Bytes** gehen nie über den Chatserver. Der Server leitet nur **Signale** weiter (`type:"file"`). Der eigentliche Transfer läuft per WebRTC DataChannel Gerät ↔ Gerät (mit Resume/Retry).

### Einbindung

```js
import { LindicChat } from 'https://services.lindic.net/modules/chat/lindic-chat.js';
import { LindicFileP2P } from 'https://services.lindic.net/modules/chat/lindic-file-p2p.js';

const chat = new LindicChat({ app: 'phoenix', userId: 'u1', nickname: 'Alex' });
await chat.connect();

const files = new LindicFileP2P(chat);

files.on('offer', async (o) => {
  // o: { transferId, from, name, size, mime, autoAccept }
  if (o.autoAccept) return; // Modul acceptet bereits
  await files.accept(o.transferId);       // einmal
  // await files.acceptAlways(o.transferId); // + Accept-Liste
  // files.reject(o.transferId);
});
files.on('progress', ({ transferId, got, total }) => {
  console.log(transferId, Math.round(100 * got / total) + '%');
});
files.on('done', (d) => console.log('fertig', d));

// Senden
await files.send('u2', fileInput.files[0]);
```

### Signal-Protokoll (`type:"file"`)

| action | Richtung | Inhalt |
|--------|----------|--------|
| `offer` | Sender → Empfänger | `name`, `size`, `mime`, `transfer_id`, optional `auto_accept` |
| `accept` / `reject` | Empfänger → Sender | `transfer_id`, reject ggf. `error` |
| `ready` | Empfänger → Sender | startet WebRTC beim Sender |
| `sdp-offer` / `sdp-answer` / `ice` | beidseitig | WebRTC-Signaling |
| `retry` / `cancel` / `error` / `done` | Steuerung | Resume/Abbruch/Status |

Felder immer: `to`, `transfer_id`, `action`. Server echoed an Sender und zustellt an `to` (gleiche `app`).

### Config

- `CHAT_FILE_P2P_ENABLED=1`
- `CHAT_FILE_REQUIRE_ACCEPT=1`
- `CHAT_ACL_LOG_RETENTION_DAYS=30`
- `CHAT_FILE_SIGNAL_MAX_BYTES=…`
- Features: `file_p2p`, `file_require_accept`, `acl`

### Module

- Chat: `https://services.lindic.net/modules/chat/lindic-chat.js`
- Files: `https://services.lindic.net/modules/chat/lindic-file-p2p.js`

## Block-, Accept-Listen & Datei-Consent

Persistente ACL **pro App** unter `storage/chat/acl/{app}.json`.

### Datei-Consent

- Default: Empfänger muss jedes Offer bestätigen (`CHAT_FILE_REQUIRE_ACCEPT=1`).
- **Ablehnen** → Sender erhält `action:"reject"` (ggf. `error:"rejected"`); kein P2P/`ready`.
- **Annehmen** → nur dieser Transfer.
- **Immer annehmen** → `acl.accept_file` speichert Absender; nächste Offers kommen mit `auto_accept:true`.
- Server-Flag `CHAT_FILE_REQUIRE_ACCEPT=0` → alle Offers mit `auto_accept:true` (kein Dialog nötig).
- **Datei-Block**: Empfänger blockiert Absender mit `block_file` → Offer wird nicht zugestellt; Sender bekommt `reject`/`error:"blocked"`.

### DM-Block

- `block_dm` auf dem Empfänger: Absender erhält `{ type:"error", error:"dm_blocked" }`, Nachricht wird nicht zugestellt.

### WS-API

| type | Felder |
|------|--------|
| `acl.block` | `user`, `block_file?`, `block_dm?` (Default beide true) |
| `acl.unblock` | `user`, optional nur eine Dimension |
| `acl.accept_file` / `acl.unaccept_file` | `user` |
| `acl.list` | → `{ type:"acl", op:"list", blocks, file_accept }` |
| `acl.log` | `limit?` → `{ type:"acl_log", entries }` |

Client: `chat.blockUser`, `unblockUser`, `acceptFileFrom`, `unacceptFileFrom`, `listAcl`, `aclLog`; Files: `acceptAlways`, Event `rejected`.

Jeder Datensatz enthält `by`, `user`, `app`, Timestamps. Änderungs-Log: `storage/chat/acl-log/{app}.jsonl`, Retention `CHAT_ACL_LOG_RETENTION_DAYS` (selbstüberschreibend).

`welcome.features`: `acl`, `file_require_accept`.

